OpenHands / OpenHands/software-agent-sdk
[Bug]: RemoteWorkspace.load_skills_from_agent_server() discards the caller's AgentContext, resetting skill preferences beyond load_public_skills
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 1.1k
- Forks
- 539
- Avg merge
- 1d 19h
- Merged PRs (30d)
- 137
Description
Is there an existing issue for the same bug?
- I have searched existing issues and this is not a duplicate.
Bug Description
RemoteWorkspace.load_skills_from_agent_server() in openhands-sdk/openhands/sdk/workspace/remote/base.py always constructs a brand-new AgentContext from scratch when it returns, instead of updating any AgentContext the caller already had. Every field other than skills and load_public_skills is silently reset to its class default — load_user_skills, load_project_skills, disabled_skills, marketplace_path, system_message_suffix, and user_message_suffix all disappear from the returned context regardless of what the caller had configured.
One instance of this is worth calling out specifically: current_datetime defaults to default_factory=lambda: datetime.now().astimezone(), not None. So a caller's AgentContext(current_datetime=<some fixed value>) doesn't just get dropped — it gets silently replaced with a freshly-generated "now" timestamp at whatever moment load_skills_from_agent_server() happened to run.
Confirmed still present on main at the two AgentContext(...) construction lines near the end of the function (currently ~lines 928–933):
if loaded_skills:
agent_context = AgentContext(skills=loaded_skills, load_public_skills=False)
else:
logger.warning("No skills loaded, falling back to public skills")
agent_context = AgentContext(skills=[], load_public_skills=True)
Originally flagged as an out-of-scope "optional companion change" in #4542 while fixing the load_memory propagation bug (#4566) — noted there as worth doing on its own merits but an independent code path from that fix.
Note:
OpenHandsCloudWorkspace in openhands-workspace/openhands/workspace/cloud/workspace.py re-declares this method's full signature and forwards every argument to super().load_skills_from_agent_server(...) — done deliberately so griffe (this repo's API-compatibility checker) doesn't flag inherited-method removal from the subclass. That means any fix to the base method's signature must also be mirrored here, or OpenHandsCloudWorkspace callers silently never get access to the new capability while its docstring still claims parity with RemoteWorkspace.
Expected Behavior
load_skills_from_agent_server() should preserve any AgentContext fields the caller already set — marketplace_path, disabled_skills, message suffixes, current_datetime, etc. — and only change the two fields the function itself is responsible for: skills (the whole point of the call) and load_public_skills (set by the function's own found-vs-fallback logic). Accepting an optional base context and returning base_context.model_copy(update={"skills": ..., "load_public_skills": ...}) instead of constructing a fresh AgentContext(...) would make the helper non-destructive. AgentContext isn't a frozen model, so nothing forces this mechanically — it's the same convention the SDK already uses elsewhere for non-mutating updates on value-like objects, applied here for the same reason: avoid the caller having to know their prior configuration was reconstructed rather than preserved.
Actual Behavior
The reset is reproducible without a running agent-server by mocking the one network call (_call_skills_api) the function makes internally. Save this as repro_load_skills_context.py:
import time
from unittest.mock import patch
from openhands.sdk import RemoteWorkspace
workspace = RemoteWorkspace(
host="https://agent-server.example.com", working_dir="/workspace"
)
with patch.object(
workspace,
"_call_skills_api",
return_value=[{"name": "demo-skill", "content": "demo"}],
):
_, context_1 = workspace.load_skills_from_agent_server()
time.sleep(1.5)
_, context_2 = workspace.load_skills_from_agent_server()
print("marketplace_path:", context_1.marketplace_path)
print("disabled_skills:", context_1.disabled_skills)
print("current_datetime (call 1):", context_1.current_datetime)
print("current_datetime (call 2):", context_2.current_datetime)
print("Same timestamp across calls?", context_1.current_datetime == context_2.current_datetime)
Run with:
uv run python repro_load_skills_context.py
Observed output:
[09/06/26 09:50:59] INFO Loading skills via agent-server... base.py:907
[09/06/26 09:50:59] INFO Loaded 1 skills base.py:924
[09/06/26 09:51:01] INFO Loading skills via agent-server... base.py:907
[09/06/26 09:51:01] INFO Loaded 1 skills base.py:924
marketplace_path: marketplaces/default.json
disabled_skills: []
current_datetime (call 1): 2026-09-06 09:50:59.622681+05:30
current_datetime (call 2): 2026-09-06 09:51:01.129209+05:30
Same timestamp across calls? False
There is no parameter on load_skills_from_agent_server() today that can change marketplace_path or disabled_skills away from their class defaults — the function offers no way to preserve them. And current_datetime differing between two calls 1.5 seconds apart, with no caller input at all, is direct proof it is being freshly generated on every call rather than sourced from any prior context.
Acceptance Criteria
-
load_skills_from_agent_server()accepts an optional baseAgentContextinstead of always constructing one from scratch - When a base context is provided, all of its fields survive the call except
skillsandload_public_skills(which the function's found/fallback logic continues to set as it does today) -
current_datetimefrom the base context is preserved, not regenerated - Calling the function without a base context (every existing call site today) produces output equivalent to current behavior — no breaking change
- Test coverage for: (1) base context preserved when skills are found, (2) base context preserved on the empty-skills fallback path, (3) no-base-context call matches legacy behavior, (4)
current_datetimespecifically survives a round-trip -
OpenHandsCloudWorkspace.load_skills_from_agent_server()(openhands-workspace/openhands/workspace/cloud/workspace.py) accepts and forwardsbase_contexttosuper().load_skills_from_agent_server(...), with test coverage confirming preserved fields survive through the override
Screenshots and Additional Context
Related: #4542 (root issue), #4566 (merged fix for the load_memory propagation half of that issue — this is the other half, called out there as independent and deferred).
I'd like to work on this — I'll open a PR shortly with regression tests covering the acceptance criteria above.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in openhands-sdk/openhands/sdk/workspace/remote/base.py at load_skills_from_agent_server() and inspect the matching override in openhands-workspace/openhands/workspace/cloud/workspace.py. Use the mocked _call_skills_api reproduction and add regression coverage for found, fallback, no-context, current_datetime, and override paths; done means caller fields survive while skills and load_public_skills retain their intended updates.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- api, backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 35/100