feat(agent-profiles): select every tool from one server catalog - #5151
simonrosenberg wants to merge 14 commits into
Conversation
REST API breakage checks (OpenAPI) — ✅ PASSEDResult: ✅ PASSED |
Coverage Report •
|
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
🤖 OpenHands is reviewing this PR. Head commit: This comment was posted by an AI agent (OpenHands). |
A client building an Agent Profile editor could not ask which tools a user may pick, or what a profile will actually launch with, so it had to hardcode both. - ToolDefinition declares `user_selectable`; the registry serves it via `GET /api/tools/catalog`. The catalog is sealed once the server has loaded its tools, so tools a single conversation registers (client tools, `tool_module_qualnames` imports) are never offered. - `resolve_tool_specs` is the one place a `tools` setting becomes specs, used by `create_agent` and by the profile resolver. Browser availability is an explicit resolver input rather than a post-launch injection, so `resolved_settings.tools` reports what the launch really builds. - Materialize accepts a draft profile body, so an editor can preview before the first save. - `server_info` advertises `tool_catalog_v1` and `agent_profile_draft_materialize_v1`. Closes #4958 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
43b221b to
2bfeff6
Compare
|
🤖 OpenHands is reviewing this PR. Head commit: This comment was posted by an AI agent (OpenHands). |
all-hands-bot
left a comment
There was a problem hiding this comment.
This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.
Summary
Solid, well-structured change. The single resolution path (resolve_tool_specs -> _profile_tool_specs, shared by launch and materialize) is a real simplification -- it removes the post-launch browser append and the duplicated defaulting logic in Agent._initialize, and the test_dry_run_tools_match_the_launched_agent matrix (16 cases) genuinely pins the preview-to-launch invariant. The conversation-scoped registry context var cleanly solves the catalog leakage problem without touching existing call sites, and the draft materialize body typing is correct (path name overrides the draft's name, invalid draft -> 422).
No material bugs found at the line level. Two things for a human maintainer:
Eval risk -- flagging for a human decision (not approving)
This PR ships a deliberate agent-behavior change: enable_sub_agents now adds task_tool_set to an explicit tools list, where previously the flag was silently ignored once tools was set. The author calls this out and it's the right call (a toggle that does nothing is worse than a consistent one), but it does change the toolset an agent launches with for any profile/settings that combines an explicit tools list with enable_sub_agents=True. That plausibly affects benchmark/eval performance, so per the repo's review policy I'm leaving a COMMENT rather than an approval.
Concretely: the integration-test label is not on this PR, so Run Integration Tests and Run Examples Scripts are both skipped for the head SHA, and there is no eval-monitor link in the PR description or comments. Before merging I'd want either:
- a lightweight eval run posted to https://openhands-eval-monitor.vercel.app with a human confirmation, or
- the
integration-testlabel added so the integration/examples workflows actually exercise the changed tool-resolution path, or - an explicit maintainer decision that the
task_tool_setaddition is low-risk and no eval is needed.
If a maintainer confirms the behavior change is acceptable, the rest of the PR is in good shape and I'd approve.
Minor observations (non-blocking)
resolve_builtin_toolscan auto-attachInvokeSkillTool/VisionInspectToolthat are not ininclude_default_tools.test_dry_run_tools_match_the_launched_agentassertsreported == agent.tools + agent.include_default_tools, which only holds when neither auto-attaches (no skills, vision not triggered). It passes in CI today, but the assertion's premise is fragile -- if the default LLM fixture ever becomes non-vision with a vision profile available, it will start failing. Not a blocker; just a heads-up.gather_profile_launch_inputsprobesis_tool_usable(BROWSER_TOOL_NAME)on the host for both launch andmaterialize. The PR notes Docker-runtime mediation still resolves on the host (browser probed outside the container) and defers that to #5141 -- acknowledged, not a regression here.
Risk assessment: MEDIUM -- clean implementation and good test coverage, but the enable_sub_agents toolset change touches agent behavior without eval evidence. Recommend human maintainer review + eval/integration-test confirmation before merge.
all-hands-bot
left a comment
There was a problem hiding this comment.
This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.
The unification of launch and preview through gather_profile_launch_inputs + resolve_tool_specs is a clean design - one resolution path for both, with the browser probe and skill discovery shared, and the post-launch append removed. The catalog seal (seal_tool_catalog at lifespan) correctly excludes conversation-scoped registrations, and tests assert the real (non-mock) resolution path matches between materialize and create_agent().
One material issue to reconcile: the enable_sub_agents "behaviour change" is not implemented.
The PR description states:
Behaviour change:
enable_sub_agentsnow addstask_tool_setto an explicit tools list too. Previously the flag was silently ignored oncetoolswas set.
And the e2e table's first row lists task_tool_set in resolved_tools and task in the LLM-received tools for tools: [glob, grep] with sub-agents on.
But resolve_tool_specs short-circuits on an explicit list:
if tools is not None:
return list(tools) # enable_sub_agents is ignoredtask_tool_set is only appended when tools is None. The new unit test test_openhands_profile_tools_selection_is_used_as_given asserts the opposite of the stated change: an explicit [terminal] list with enable_sub_agents=True resolves to [terminal] only (no task_tool_set).
So the code, the unit test, the docstrings ("enable_sub_agents adds the sub-agent tool set in every case" in settings/model.py and agent_profile.py), and the PR description all disagree. Whichever is the intended behavior, they need to be made consistent before this is safe to ship - a profile editor reading the description/field docstring would expect toggling sub-agents to add the delegation tool regardless of an explicit tool list, and would get silently different behavior.
This PR also touches tool resolution / agent behavior (the launch path now resolves tools through resolve_tool_specs instead of passing tools: None through), which is in the eval-risk category. I don't see an eval-monitor link or maintainer eval confirmation in the description, so I'm leaving a COMMENT rather than approving - recommend a human maintainer sanity-check tool resolution against a lightweight eval (especially the tools=None default path, which now bakes in browser_available at resolve time instead of post-launch).
No other issues found. The catalog, draft-materialize, capability flags, and the profile_launch consolidation all look correct and well-tested.
[RISK ASSESSMENT]
- [Overall PR] Risk: MEDIUM. The refactor is sound and well-tested, but (1) the documented
enable_sub_agentsbehavior change does not match the implementation, and (2) it changes the tool-resolution path for launches, which could affect benchmark behavior. Reconcile the docstring/description with the code and confirm no eval regression before merge.
Drop the stale field descriptions that promised enable_sub_agents would apply to an explicit tools list; that change lives in #5158. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks — the Fixed in 875d2b8: On the earlier review's eval-risk point: that risk now belongs entirely to #5158, so this PR no longer changes any agent's toolset — it only reports what would already be built and adds |
|
🤖 OpenHands is reviewing this PR. Head commit: This comment was posted by an AI agent (OpenHands). |
|
🤖 OpenHands is reviewing this PR. Head commit: This comment was posted by an AI agent (OpenHands). |
f36523e to
875d2b8
Compare
all-hands-bot
left a comment
There was a problem hiding this comment.
This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.
Summary
Reviewed the tool-catalog + truthful-materialize feature at head f36523e2. The design is clean and the implementation is solid; I found no material bugs.
What I verified against the code:
resolve_tool_specs(openhands/sdk/tool/defaults.py) is the single defaulting point:None-> exec set + the subset ofENV_DEPENDENT_DEFAULT_TOOL_NAMESthe caller reports viaruntime_tools+task_tool_setwhenenable_sub_agents; a list (incl.[]) is used as given. No machine probing inside the SDK.- Launch and materialize share
gather_profile_launch_inputs(profile_launch.py), so both resolve against the same skill catalog andruntime_tools. The preview therefore equals whatresolve_agent_profile(...).create_agent()builds - confirmed bytest_dry_run_tools_match_the_launched_agentparametrized overtoolsin {None, [], [glob]} x browser on/off. - The catalog is sealed once in
api_lifespan(api.py) after presets +--import-modules;list_tool_catalogfilters by_CATALOG_NAMES, so per-conversation client tools andtool_module_qualnamesimports registered later are correctly excluded.ClientTool.user_selectable = Falseand the seal test (test_sealed_catalog_ignores_later_registrations) cover this. No KeyError risk sincenamesis filtered before indexing_TOOL_CLASSES. - Browser is no longer injected post-launch in
_initialize/add_runtime_tools; it now lives in the resolvedtoolslist. The profile path resolvesruntime_toolsbeforecreate_agent()re-resolves the (now concrete) list, so there is no double-application and no drop. - Draft materialize:
body.profileoverridesnamewith the path param;None/{}loads the stored profile (404 if missing); a draft missingnamefails Pydantic (422). Matches the contract. server_info.capabilitiesadvertisestool_catalog_v1andagent_profile_draft_materialize_v1for feature detection.- No
# type: ignorein the changed files; no version bumps inpyproject.toml.
Eval-risk flag (not approving)
This PR changes tool resolution and defaulting - what tools an agent actually launches with, including the conditions under which browser_tool_set is attached. That is agent-behavior territory that can plausibly shift benchmark/evaluation performance (e.g. browser availability, tool-set composition). Per the repo review guide I am leaving a COMMENT rather than approving, and flagging this for a human maintainer to decide after running lightweight evals. I did not find an eval-monitor link or a maintainer confirmation in the PR description/comments.
If eval evidence has already been gathered (a run on openhands-eval-monitor.vercel.app plus a maintainer confirmation), treat this flag as satisfied.
Risk Assessment
MEDIUM - The change is well-structured and well-tested at the unit level, but it alters the agent's resolved toolset on the launch path, which is eval-sensitive. The scope cuts (no per-tool provenance, enable_sub_agents split to #5158, Docker host-probe tracked in #5141) are documented and reasonable.
Recommendation: A human maintainer should confirm there is no eval regression before merge; otherwise the code itself looks good to go.
|
🤖 OpenHands is reviewing this PR. Head commit: This comment was posted by an AI agent (OpenHands). |
`enable_sub_agents` and `enable_switch_llm_tool` were separate controls over which tools an agent gets, so the same question had two answers and a tool catalog could not describe either. Both are retired from `OpenHandsAgentProfile`; `task_tool_set` and `SwitchLLMTool` become user-selectable catalog entries picked in `tools` like any other tool. Schema v3 migrates stored profiles: a profile with delegation on has its `tools` pinned to the standard set plus `task_tool_set`, since "the standard set plus delegation" is no longer expressible. Browser is part of that pin because it resolves to nothing where the runtime cannot run it. `enable_switch_llm_tool` defaulted on, so folding it would pin a list on nearly every profile; it is dropped instead and `switch_llm` must be selected again. Both switches remain on `AgentSettingsConfig` for the legacy settings launch path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
This comment was posted by an AI agent (OpenHands). |
|
🤖 OpenHands is reviewing this PR. Head commit: This comment was posted by an AI agent (OpenHands). |
all-hands-bot
left a comment
There was a problem hiding this comment.
This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.
Summary
Re-reviewed at head 2695be6 (the prior bot review was at f36523e2; new commits since). The design is clean and I found no material bugs. The implementation matches the PR description: resolve_tool_specs is the single defaulting point, launch and materialize share gather_profile_launch_inputs, the catalog is sealed once in api_lifespan, and the v2→v3 migration folds enable_sub_agents into tools while dropping enable_switch_llm_tool without pinning.
What I verified against the code
resolve_tool_specs(openhands/sdk/tool/defaults.py) is the single defaulting point:None→ exec set (+ browser only whenenable_browser); a list (incl.[]) is used as given. Used by bothcreate_agentand_build_openhands_settings, so the materialize preview equals what a launch builds — pinned bytest_dry_run_tools_match_the_launched_agent(parametrized overtools∈ {None, [], [glob]} × browser on/off).gather_profile_launch_inputs(profile_launch.py) is shared by_resolve_agent_from_profileandmaterialize_agent_profile, so preview and launch resolve against the same skill catalog + browser probe. Browser is now a resolver input rather than a post-launchmodel_copyinjection; the old "explicit tools never amended" invariant is preserved becauseresolve_tool_specsonly adds browser whentools is None.- Catalog seal (
api.py/registry.py):seal_tool_catalog()snapshots_REGonce after presets +--import-modules;list_tool_catalogfilters by_CATALOG_NAMES, so per-conversation client tools andtool_module_qualnamesimports registered later are excluded._TOOL_CLASSES/_USABILITY_REG/_REGare all populated together under_LOCK, so thetool_classes[name]indexing inlist_tool_catalogcannot KeyError._CATALOG_NAMES is Noneis a graceful pre-seal fallback. Covered bytest_sealed_catalog_ignores_later_registrations. - Migration (
agent_profile.py):_migrate_v2_to_v3popsenable_sub_agents/enable_switch_llm_toolbefore theextra="forbid"validation, so old v2 payloads load. The ACP branch correctly leavestoolsuntouched. The fourtest_v2_*cases pin every row of the migration table. - Draft materialize (
agent_profiles_router.py):body.profileis validated as theAgentProfilediscriminated union, so a draft missingllm_profile_refor with a foreign field returns 422; a bare/{}body falls back to the stored profile (404 if absent).store.list() == []after a draft confirms nothing is persisted.
One minor note (not blocking)
list_tool_catalog hardcodes usable=True for built-in classes added in the extension loop (registry.py:250). Today only SwitchLLMTool is a selectable builtin and it has no is_usable gate, so this is accurate. It would mis-report if a future selectable builtin gained a runtime-conditional is_usable() — worth a follow-up to derive usable from the class the same way registered tools do, but not a live bug now.
⚠️ Eval-risk — leaving COMMENT, not APPROVE
Per the repo review policy, I am not approving because this PR changes agent tool availability on the profile launch path:
- Browser resolution moved from a post-launch injection into the resolver (
browser_availableinput). switch_llmis dropped from the default toolset on the profile path (enable_switch_llm_toolpinnedFalse); agents whose profile had it on loseswitch_llmuntil explicitly selected.- Sub-agent delegation is now a
toolsselection rather than theenable_sub_agentsswitch.
These are squarely in "tool calling/execution" territory and could plausibly affect benchmark/eval performance. The PR description provides CI-green + local pytest + a mock-LLM canvas e2e, but no eval-monitor link and no human-maintainer confirmation of benchmark results, so the eval-risk requirement is not satisfied.
Recommendation: a human maintainer should decide after running a lightweight eval (or confirm eval evidence if already available) before merging.
[RISK ASSESSMENT]
- Overall PR
⚠️ Risk: 🟡 MEDIUM — clean, well-tested implementation with a deliberate, well-documented breaking change; the medium rating is for the unverified eval/benchmark impact of changing which tools a launched agent receives.
KEY INSIGHT: tools is now the single source of truth for an agent's toolset across both launch paths, with resolve_tool_specs as the one defaulting point — a real simplification that removes the conflicting enable_sub_agents/enable_switch_llm_tool side-channels.
Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:
- Add a
.agents/skills/custom-codereview-guide.mdfile to your branch (or edit it if one already exists) with the/codereviewtrigger and the context the reviewer is missing. See the customization docs for the required frontmatter format.- Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
- When your PR is merged, the guideline file goes through normal code review by repository maintainers.
Resolve with AI? Install the iterate skill in your agent and run
/iterateto automatically drive this PR through CI, review, and QA until it's merge-ready.Was this review helpful? React with 👍 or 👎 to give feedback.
The built-in branch of the catalog hardcoded usable=True instead of asking the class, so a selectable built-in with a runtime-conditional is_usable() would be offered on runtimes that cannot run it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Addressed the minor note in Not re-requesting a review for a one-line change on a path you already assessed as not-a-live-bug. The eval-risk flag is the remaining gate and needs a human maintainer — noted in the PR description. |
|
🤖 OpenHands is reviewing this PR. Head commit: This comment was posted by an AI agent (OpenHands). |
all-hands-bot
left a comment
There was a problem hiding this comment.
This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.
Summary
The design is clean and the implementation is sound. I traced the full data flow (catalog -> profile tools -> resolve_tool_specs -> resolve_tool/BUILT_IN_TOOL_CLASSES fallback -> launched agent) and verified the key invariants:
- Catalog <-> profile <-> launch consistency holds. The catalog lists registered tools by their registered names and SDK built-ins by class name;
resolve_tool's class-name fallback means aSwitchLLMToolpick stored intoolsresolves at launch. The newtest_dry_run_tools_match_the_launched_agentprovesresolved_settings.toolsequals whatcreate_agent()builds acrosstoolsin {None, [], [glob]} x browser on/off -- a real end-to-end assertion, not a mock. - Migration is correct.
extra="forbid"is on the profile, but_migrate_v2_to_v3popsenable_sub_agents/enable_switch_llm_toolbefore validation, so old v2 payloads load. The fourtest_v2_*cases pin every row of the migration table, and the v3 persisted-settings baseline + compat gate are in place. - Double-resolution is idempotent.
_build_openhands_settingsresolvesNone -> exec set (+browser), thencreate_agentcallsresolve_tool_specsagain on an already-non-Nonelist, which is a no-op pass-through. No behavior drift on the legacy settings path. - Seal is well-scoped.
seal_tool_catalog()freezes_REG; later conversation/client-tool registrations stay out of the catalog while remaining resolvable. SDK built-ins are always offered regardless of the seal, which is correct since they are deterministic and process-resident. - No secret exposure in the draft
materializepath: a draft can only reference an existing stored LLM profile, andresolved_settingsis dumped with secrets redacted (no expose context).
All relevant tests pass locally (test_registry, test_defaults, test_agent_profile, test_resolver, test_tool_router, test_agent_profiles_router, test_agent_profile_conv_start, test_check_persisted_settings_compat).
Eval-risk flag (not approving)
This PR changes agent behavior: browser injection moved from a post-launch model_copy into the resolver, and enable_sub_agents / enable_switch_llm_tool are retired from profiles (the latter dropped without compensation, so agents lose switch_llm until re-selected). That alters the toolset an agent launches with, which plausibly affects benchmark/evaluation performance.
I did not find an eval-monitor link or a human maintainer's eval confirmation in the PR description or comments. Per the repo review policy I'm leaving a COMMENT rather than approving -- this is not a finding against the code, just a request for a human to run lightweight evals (or confirm a run on the eval monitor) before merge.
Risk assessment
- Overall PR: MEDIUM -- no correctness/security bugs found, but it is a deliberately breaking change to the agent-profile schema and the launched toolset, so it warrants eval validation before merge.
Verdict: Code is merge-ready from a correctness standpoint; deferring the approval decision to a human maintainer pending eval evidence.
Two things the picker needs from the catalog. A `catalog_description` ClassVar carries one line per tool, written for a user rather than the model — `description` is the LLM's prompt and runs to paragraphs. It lives on the class so the catalog can read it without instantiating anything, and so a tool owns its own blurb. Built-ins were offered under their class name (`SwitchLLMTool`) while every other tool used its snake_case name (`terminal`, `task_tool_set`). They now use `ToolDefinition.name`, which is already snake_case, and `resolve_tool` accepts that name so a stored pick resolves. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
🚦 CI is currently failing on this PR's latest commit. Please fix the failing checks before OpenHands reviews it - this is re-checked automatically once you push a new commit. (A maintainer can also request This is an automated check - no AI was used to generate this comment. |
…y name Schema v3 pins `browser_tool_set` into a migrated list even on hosts with no chromium, and nothing downstream re-checks usability — that is only safe because `BrowserToolSet.create` degrades to no tools. Test it, so the migration cannot turn into a crash on such a host. Also resolve built-ins through a name-keyed dict rather than scanning, and fix a typo in the duplicate-registration warning. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… agent-profile-tool-catalog
|
🤖 OpenHands is reviewing this PR. Head commit: This comment was posted by an AI agent (OpenHands). |
`enable_switch_llm_tool` defaulted to true, so dropping it cost every existing profile the tool. Making `switch_llm` part of the set an unset `tools` resolves to removes that loss entirely, and the migration only has to pin a list where the stored config differed from that default: | stored v2 | migrated v3 `tools` | |----------------------------|--------------------------------| | defaults | unset (still tracks the default) | | switch off | standard set minus switch_llm | | sub-agents on | standard set + both | | explicit list | list + switch_llm | | explicit list, switch off | list, unchanged | The agent rejects duplicate tool names, so `switch_llm` now has exactly one delivery channel: `tools`. `create_agent` appends it for the legacy flag instead of routing it through `include_default_tools`, which keeps the two launch paths agreeing on the same toolset. `default_tool_specs` stays in lockstep with the openhands-tools preset, which does not carry this SDK built-in; only `resolve_tool_specs` — the settings/profile defaulting point — adds it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
all-hands-bot
left a comment
There was a problem hiding this comment.
This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.
Taste rating: needs improvement — the catalog/seal/migration design is clean and well tested, but I found two concrete problems on the legacy agent_settings launch path, both in create_agent.
Material findings
-
create_agentnow appendstask_tool_seton top of an explicittoolslist whenenable_sub_agents=True(openhands-sdk/openhands/sdk/settings/model.py:1409). Verified on this head:OpenHandsAgentSettings(llm=..., tools=[Tool(name='terminal')], enable_sub_agents=True).create_agent().tools→['terminal', 'task_tool_set'], andtools=[]with the switch →['task_tool_set']. At base, an explicit list was used as given (self.tools if self.tools is not None else default_tool_specs(...)). This silently changes what the legacy settings launch path builds, and it contradicts thetoolsfield's own description in the same file ("[] is an explicitly bare agent; a non-empty list is used exactly as given") and the docstring ontest_create_agent_empty_tools_stays_bare("no default injection … keeps its old meaning"). Your earlier PR comment said this flip belonged to #5158 and that this branch kept "added only when tools is unset" — after the merge, the flip is here but nothing documents it. Either gate the append ontools is None, or update the field description and call the legacy-path change out in the release notes. -
Selecting
switch_llmon the legacy settings path now crashes at agent init (openhands-sdk/openhands/sdk/settings/model.py:1416). Verified:OpenHandsAgentSettings(llm=..., tools=[Tool(name='switch_llm')])(defaultenable_switch_llm_tool=True) →Agent._initializeraisesValueError: Duplicate tool names found: {'switch_llm'}, becauseresolve_toolnow resolves the snake_case name (new in this PR) whileinclude_default_toolsalso appendsSwitchLLMTool. The profile path is safe because the resolver pins the flagFalse, but the catalog this PR ships actively offersswitch_llmas a pick, and any client that mirrors that pick intotoolson the still-default-Truesettings path hits the crash. Mirroring the dedup you already do for sub-agents (skip the append whenswitch_llmis already intools) fixes it.
Everything else I traced checks out: the v2→v3 migration matches the description's table (and the test_v2_* cases pin it), the seal correctly excludes post-startup registrations while built-ins stay offered, resolve_tool_specs re-resolution in create_agent is an idempotent pass-through, and the draft-materialize path keeps secrets redacted. CI on 501593e is fully green.
Eval-risk gate
This PR changes the toolset agents launch with (every profile loses switch_llm until re-selected; browser injection moved into the resolver; plus the legacy-path flips above). I found no eval-monitor link or human maintainer confirmation in the PR description or comments. Per the repo review policy I am not approving — this needs a human maintainer to weigh the eval impact (the description itself flags it) and decide on the two findings above.
[RISK ASSESSMENT]
MEDIUM-HIGH. Deliberately breaking profile-schema change with a documented one-time tool loss, plus the two verified legacy-path issues above (one silent behavior change, one crash). No security exposure found; the draft-materialize oracle is authenticated and redacted. Key architectural insight: making tools the single source of truth is the right direction, but the transition leaves the legacy settings model with two switches whose interaction with explicit tools is now both undocumented (sub-agents) and self-contradictory (switch_llm double-add) — the pinning the profile resolver does is exactly what the legacy path is missing.
Verdict: COMMENT — two material findings + eval-risk gate; deferring to a human maintainer.
Improve this review? If feedback seems incorrect or irrelevant, update the repository's
.agents/skills/custom-codereview-guide.md(with the/codereviewtrigger), then re-request review. The reviewer reads the guide from the PR head.Resolve with AI? Install the iterate skill and run
/iterate.Was this review helpful? React with 👍 or 👎.
A built-in can be named in both channels, and both mean "give the agent this
tool" — so the second mention is a no-op, not a duplicate-name error.
This is load-bearing for OpenHands Cloud. Enterprise attaches SwitchLLMTool
through `include_default_tools` after building the agent, while the settings
flag now puts `switch_llm` in `tools`; without this, every cloud conversation
with two or more saved LLM profiles raises
`Duplicate tool names found: {'switch_llm'}`.
A genuine duplicate inside `tools` is still rejected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks for the tool-selection work here, including the recent default-tool deduplication fix. We tested this together with the Canvas PR and prepared a small follow-up. It preserves the legacy Local full-suite runs passed 6,403 SDK tests and 2,184 agent-server tests using an isolated test environment. After a final assertion-only cleanup, all 94 conversation-router tests and scoped pre-commit checks passed again. Mock-model integration checks and a second local review also passed. Remote CI has not run on our commits yet. Would you prefer a small follow-up PR or commits to cherry-pick? |
…ectability Six expectations still described the old default set — `create_agent` and the profile resolver both hand back `switch_llm` now, and it arrives through `tools` rather than `include_default_tools`. `list_tool_catalog` also reads `user_selectable` off the built-in class instead of hardcoding True, so the flag has one source on both branches. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The switch only ever fed the default set — an explicit `tools` (`[]` included) was used exactly as given. Folding both switches through one loop quietly extended its reach, contradicting the field's own description and making the behaviour change that #5157 deliberately left out of this PR. `enable_switch_llm_tool` keeps its full reach, because it attached its tool to every agent regardless of `tools`. Both appends still skip a tool the selection already names, so picking `switch_llm` from the catalog cannot trip the duplicate-name guard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
🤖 OpenHands is reviewing this PR. Head commit: This comment was posted by an AI agent (OpenHands). |
all-hands-bot
left a comment
There was a problem hiding this comment.
This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.
Taste rating: needs improvement — the catalog/seal/draft-materialize design is clean and well tested, and both findings from the previous review round are properly fixed in ec301c8/fd3c3eb. But tracing the new switch-folding logic against the base behavior surfaced two regressions on this head, one of which @rajshah4's follow-up comment independently confirms.
Material findings
-
enable_switch_llm_tool=Falseis ignored on the legacy settings path whentoolsis unset (openhands-sdk/openhands/sdk/settings/model.py:1413).resolve_tool_specs(None)unconditionally includesswitch_llm, and the loop below only ever appends — it never removes. SoOpenHandsAgentSettings(llm=..., enable_switch_llm_tool=False)(tools unset) now launches withswitch_llm, where at base the flag gated theinclude_default_toolsappend and the agent had no such tool. This silently re-enables LLM switching for every stored legacyagent_settingspayload that opted out. The only flag-off test usestools=[], which is why it slips through. @rajshah4's comment above ("preserves the legacyenable_switch_llm_tool=falsesetting when tools are unset") describes the same defect and has a fix prepared — worth landing before merge. -
The v2→v3 migration (and the seed fold) is not behaviour-preserving for
enable_sub_agents=True+ explicittools(openhands-sdk/openhands/sdk/profiles/agent_profile.py:385). At v2, an explicittoolslist was used as given and the switch only fed the default set — the exact semanticsec301c8restored increate_agentand pinned intest_enable_sub_agents_does_not_reach_an_explicit_tools_list. Yetfold_tool_switches_into_toolsappendstask_tool_setto explicit lists, so a stored profile withenable_sub_agents: true, tools: [glob](launched as[glob, switch_llm]at v2) migrates to[glob, task_tool_set, switch_llm]— the agent gains sub-agent delegation it never had. That contradicts the PR's "behaviour-preserving" migration claim, the table's "the appends are free" rationale, and the deferral of honor-the-switch-with-explicit-tools to #5158.test_v2_sub_agents_switch_appends_to_an_explicit_listcurrently pins the changed behavior.
Everything else I traced checks out: the seal correctly excludes post-startup registrations while built-ins stay offered under snake_case names, the Agent._initialize dedup makes include_default_tools idempotent against tools (the cloud double-delivery crash is covered), the draft-materialize path keeps secrets redacted, browser availability as a resolver input matches launch vs. preview, and the rest of the migration table is genuinely behavior-preserving (including the tools: [] → [switch_llm] row and the pinned-browser degradation, which is now tested). CI on ec301c8 is fully green (58/58 checks).
Eval-risk gate
This PR changes the toolset agents launch with (default set gains switch_llm, browser injection moved into the resolver, profile tool switches retired). I found no eval-monitor link or human maintainer eval confirmation in the PR description or comments, so per the repository review policy this stays a COMMENT for a human maintainer to decide after lightweight evals — independent of the two findings above.
[RISK ASSESSMENT] HIGH — deliberate breaking change to the profile REST contract (old canvas clients get 422 on save; lockstep deploy with OpenHands/OpenHands#17516 required), plus a schema migration that rewrites every stored v2 profile's toolset on load. The migration and legacy-path semantics are exactly where the two findings sit, so human validation of the fold logic (and the eval gate) is needed before merge.
Verdict: COMMENT — two behavioral regressions in the switch-folding logic to fix (one already has a prepared follow-up from @rajshah4), plus the eval-risk gate requires human maintainer sign-off.
Key architectural insight: the two retired switches had different reach — enable_switch_llm_tool attached to every agent, enable_sub_agents only fed the default set — so folding them through one uniform code path (the append loop in create_agent, the fold in fold_tool_switches_into_tools) keeps getting the reach wrong in one direction or the other; each channel needs its own gate.
Improve this review? If feedback seems incorrect or irrelevant, update the repository's
.agents/skills/custom-codereview-guide.md(with the/codereviewtrigger), then re-request review. The reviewer reads the guide from the PR head.Resolve with AI? Install the
iterate skill
and run/iterate.Was this review helpful? React with 👍 or 👎.
| # ``enable_switch_llm_tool`` attached its tool to every agent. Both | ||
| # tools can also be selected in ``tools``, and the agent rejects a | ||
| # duplicate name, so neither is added twice. | ||
| tools = resolve_tool_specs(self.tools) |
There was a problem hiding this comment.
resolve_tool_specs(None) now always includes switch_llm, and the loop below only ever appends — so a legacy settings payload with enable_switch_llm_tool=False and tools unset still launches with switch_llm. At base this config had no switch_llm (the flag gated the include_default_tools append), so this silently re-enables LLM switching for users who explicitly opted out. The only flag-off test (test_agent_settings_omits_switch_llm_tool_when_disabled) uses tools=[], which is why this slips through. Suggest filtering it back out when self.tools is None and not self.enable_switch_llm_tool, plus a tools=None + flag-off test. (@rajshah4's follow-up comment on this PR describes the same fix.)
| else [Tool(name=name) for name in (*DEFAULT_EXEC_TOOL_NAMES, BROWSER_TOOL_NAME)] | ||
| ) | ||
| for enabled, name in ( | ||
| (enable_sub_agents, SUB_AGENT_TOOL_NAME), |
There was a problem hiding this comment.
This append gives enable_sub_agents a reach it never had: at schema v2 an explicit tools list was used as given and the switch only fed the default set — the same semantics ec301c8 restored for create_agent and pinned in test_enable_sub_agents_does_not_reach_an_explicit_tools_list. So a stored v2 profile with enable_sub_agents: true and tools: [glob] launched with [glob, switch_llm], but migrates to [glob, task_tool_set, switch_llm] — the agent gains sub-agent delegation it never had, contradicting the "behaviour-preserving" migration claim (and the deferral of that behavior change to #5158). Gate this append on tools is None, mirroring create_agent; the same applies to the build_seed_profile fold. Note test_v2_sub_agents_switch_appends_to_an_explicit_list currently pins the changed behavior.
|
This comment was posted by an AI agent (OpenHands). |
HUMAN:
I filed #4958 after the canvas profile editor forced us to hardcode a tool allow-list and a copy of the SDK defaults. This is the SDK half: the server answers what is pickable and what a profile resolves to. It also retires
enable_sub_agentsandenable_switch_llm_toolfrom the profile — a profile should have exactly one place that says which tools the agent gets, and that istools. Deliberately kept small otherwise: no per-tool provenance, no server-provided descriptions.AGENT:
Why
An Agent Profile editor has to answer two questions about tools, and the SDK could answer neither (#4958):
/api/tools/and/server_info.usable_toolsreturn the process's tool registry — presets no product path builds an agent from, and it grows as conversations register their own tools. feat(agent-profiles): expose custom instructions and tool selection in the editor OpenHands#17235 has to carry a hand-curated allow-list.materializepassedtoolsstraight through, so a profile that leaves it unset reportednullwhile the launch resolved four tools. The browser was appended after resolution and only on the launch path, so the preview could never show it.Underneath both sat a third problem:
toolswas not actually the answer to "which tools?".enable_sub_agentsandenable_switch_llm_toolwere two more controls over the same thing, invisible to any catalog and silently ignored oncetoolswas an explicit list.Summary
user_selectableonToolDefinition, served byGET /api/tools/catalogalong withusable. MarkedFalseon the built-ins that are always attached,ClientTool, the single-tasktask,planning_file_editor, the low-levelworkflow, and the Gemini file family. A third-party tool that declares nothing stays selectable.catalog_descriptionClassVar on the tool class, so a picker can say whattask_tool_setdoes instead of showing a bare name. It lives on the class (readable without instantiating) and is written for a user —descriptionis the model's prompt and runs to paragraphs.SwitchLLMTool) while everything else usedterminal/task_tool_set; they now useToolDefinition.name, which is already snake_case, andresolve_toolaccepts it so a stored pick resolves.--import-modules). Anything registered later belongs to one conversation and vanishes on restart, so client tools andtool_module_qualnamesimports are never offered.toolsis the only tool control on a profile.enable_sub_agentsandenable_switch_llm_toolare gone fromOpenHandsAgentProfile;task_tool_setandswitch_llmare ordinary selectable catalog entries. Both switches stay onAgentSettingsConfig, which the legacy settings launch path still honours.resolve_tool_specsis the single defaulting point — used bycreate_agentand by the profile resolver — and browser availability is an explicit resolver input instead of a post-launch injection.resolved_settings.toolstherefore reports what the launch really builds, on both paths.server_infoadvertisestool_catalog_v1andagent_profile_draft_materialize_v1, so clients feature-detect instead of guessing version numbers ([Agent Profile] The profile model isn't self-describing — clients hardcode version numbers to guess which fields a server accepts #4964).AgentProfileBaseisextra="forbid", and the profile save is a whole-profile overwrite. A canvas that still sendsenable_sub_agents/enable_switch_llm_toolgets the entire save rejected with 422. The migration does not cover this: it runs only whileschema_version < 3, so an old client saving an already-migrated v3 profile re-adds the retired keys at v3 and is refused.Deploy the two together. An old client against an old server, or a new client against a new server, are both fine; only old-client-against-new-server breaks, and it breaks loudly rather than silently.
Verified against OpenHands Cloud
Enterprise is pinned to
openhands-sdk==1.46.0, so its whole unit suite was run against this branch by installing it editable over the pin:mainSDK: 5746 passed, 2 failedBoth failures are the 1.46 → 1.49 version bump (
agent_settingsschema 5 → 6, and a sandbox-injector default), reproduced on plainmain; neither is from this PR.That run also caught a real break, now fixed in
fd3c3eb: cloud attachesSwitchLLMToolthroughinclude_default_toolsaftercreate_agent, while the settings flag now putsswitch_llmintools.Agentrejects duplicate tool names, so every cloud conversation with two or more saved LLM profiles would have raisedDuplicate tool names found: {'switch_llm'}.include_default_toolsis now idempotent againsttools— naming a built-in in both channels is a no-op, while a genuine duplicate insidetoolsis still rejected.One pre-existing gap this PR does not fix:
live_status_app_conversation_service.pyoverwritestoolsunconditionally when building a cloud launch, so a profile's tool selection never reaches a cloud conversation. The feature is inert on cloud until that reads the resolved tools. Tracked separately.Migration (profile schema v2 → v3), behaviour-preserving
switch_llmis part of the set an unsettoolsresolves to, so a profile that ran on the defaults keepstools: nulland stays free to follow future changes to that set. The migration pins a list only where the stored config differed from the default:toolsnull(unchanged)switch_llmenable_switch_llm_tool: false[terminal, file_editor, task_tracker, browser_tool_set]toolsunset[…standard…, task_tool_set, switch_llm]tools: [glob][glob, task_tool_set, switch_llm]tools: [glob][glob, switch_llm]switch_llmfrom the default-on switchtools: [][switch_llm]switch_llmtoolsfieldbrowser_tool_setrides along in a pinned list even on a host without chromium. That is safe becauseBrowserToolSet.createdegrades to no tools, whichtest_migrated_profile_with_pinned_browser_resolves_on_browserless_runtimenow pins so the degradation cannot be removed silently.Because
Agentrejects duplicate tool names,switch_llmhas exactly one delivery channel —tools.create_agentappends it for the legacyenable_switch_llm_toolflag rather than routing it throughinclude_default_tools, so the legacyagent_settingspath and the profile path agree on the same toolset for the same conceptual config.REST API contract changes
Compared with base OpenAPI
004c674a96d7for public/api/**paths.Issue Number
Closes #4958
Closes #5157
How to Test
Automated
test_model_features.py::test_reasoning_effort_support[openrouter/moonshotai/kimi-k2.5-False], which fails identically onmain— the test file is unchanged here and this PR touches no LLM code (verified by running it againstmain'sopenhands/sdk/llm/). Everything else passes, includingagent-server-tests,cross-tests,Persisted settings,Check OpenAPI Schema,REST API (OpenAPI)andcheck-docstrings.uv run pytest -n 8 tests/sdk tests/agent_server tests/cross→ 9013 passed. The handful of parallel failures on this laptop are contention, not the branch: each run fails a different set, all in files this PR does not touch, and every one of them passes when its file is run serially (test_truncateneeds a short tmp path, the live-server tests need a short tmux socket path, and the OpenAPI/restore clusters fail only under load).pre-commit run --from-ref HEAD~1 --to-ref HEAD(ruff, pycodestyle, pyright, import rules, tool-registration) passes.test_dry_run_tools_match_the_launched_agentasserts the preview equals whatresolve_agent_profile(...).create_agent()builds, acrosstools∈ {None, [], [glob]} × browser on/off;test_sealed_catalog_ignores_later_registrationscovers the seal; the fourtest_v2_*cases intests/sdk/profiles/test_agent_profile.pypin every row of the migration table above;tests/sdk/persisted_settings_baselines/v3/adds the required v3 fixture.End to end. Agent Canvas on an agent-server built from this branch (
OH_AGENT_SERVER_LOCAL_PATH=<worktree> npm run dev:minimal), with the canvas e2e mock LLM, which records every completion request.Catalog.
GET /api/tools/catalog→ selectable areterminal,file_editor,task_tracker,glob,grep,browser_tool_set,workflow_tool_set,ask_oracle,task_tool_set,switch_llm./server_infoadvertises both capability flags.The seal holds. Launching from the canvas home page (which sends
client_tools:canvas_ui_control,launch_child_conversation) and starting one API conversation withtool_module_qualnamesforapply_patchandtom_consultleft/api/tools/reporting 23 names while the catalog still reported its fixed set, none of them the newly registered ones.Preview vs. what the LLM received. For each stored profile:
materialize, then launch withagent_profile_id, then compare with thetoolsarray of the mock LLM request.toolsresolved_settings.tools[glob, grep]fetch[]fetchonly[terminal, glob]Every registry tool the model was given appears in the preview, and nothing in the preview was missing from the launch. The extras are the built-ins the agent always attaches and MCP tools, which
materializereports separately asresolved_mcp_config_keys.Draft preview.
POST /api/agent-profiles/code-explorer/materializewith{"profile": {"name": "code-explorer", "llm_profile_ref": "mock-llm", "tools": [{"name":"glob"},{"name":"grep"}]}}→valid: true,resolved_settings.tools=[glob, grep], andGET /api/agent-profiles/code-explorer→ 404 (nothing saved). A draft withoutname, or with an invalid field → 422. No body, or{}, materializes the stored profile.Type
Notes
enable_switch_llm_toolon loseswitch_llmuntil it is selected. The alternative — folding a default-trueswitch intotools— would pin an explicit list on nearly every stored profile, freezing them out of future default changes. See the migration section.browser_tool_seteven on a host with no chromium, and nothing downstream re-checks usability — it is harmless only becauseBrowserToolSet.createdegrades to no tools.test_migrated_profile_with_pinned_browser_resolves_on_browserless_runtimepins that, so the migration cannot become a crash if the degradation is ever removed.resolve_agent_profilenow returns a concrete list where it used to passtools: Nonethrough, so any consumer readingresolved_settings.tools is Noneas "server default" sees a list instead. See the cross-repo sweep below.llm_profile_refand trigger a dry-run resolve. Output stays redacted, so this is not secret exposure — noting the widened surface, not a defect.default/runtime/builtin/ …) and a description per tool. Both were dropped: the provenance needed two models, an eight-value literal and pulling the built-in rule out ofAgent._initialize, and a picker renders fine without either. Easy follow-ups if a UI wants them.enable_sub_agentsto be honoured alongside an explicittoolslist; retiring the switch answers it instead, and removes the conflict rather than defining one.resolve_agent_profileoutput change. It now returns the concrete tool list rather than passingtools: Nonethrough. The stored tri-state still lives on the profile, which clients already have.workflow_tool_setstays selectable, as [Agent Profile] Clients can't ask what tools an agent will get, or which tools are pickable #4958 proposed, but it runs sub-agent tasks, so it is a delegation path in the same sense as [Agent Profile] Sub-agent delegation bypasses a profile's tool and MCP restrictions #4953.canvas_uitool non-selectable.defaultprofile and named profiles build different agents — collapse launch into one pipeline #5141.🤖 Generated with Claude Code
🐳 Agent Server images for this PR — GHCR package, pull/run commands, and all pushed tags (click to expand)
• GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server
Variants & Base Images
eclipse-temurin:17-jdkpython-node-runtimepython-node-runtimepython-node-runtimegolang:1.21-bookwormPull (multi-arch manifest)
# Each variant is a multi-arch manifest supporting both amd64 and arm64 docker pull ghcr.io/openhands/agent-server:ec301c8-pythonRun
All tags pushed for this build
About Multi-Architecture Support
ec301c8-python) is a multi-arch manifest supporting both amd64 and arm64ec301c8-python-amd64) are also available if neededJev-Fast-Audit
⚡ Jev fast audit · estimates · 0.64s · commit ec301c8⚠️ reduced context — partial coverage; 21/129 hunks, 17/52 files (context budget: 21, file budget: 35, hunk budget: 108).
Strongest signal: Contract regression · 71% estimated likelihood.
Evidence: F008H004 · openhands-sdk/openhands/sdk/profiles/agent_profile.py:203–208.
Coverage:
All estimates and evidence