Skip to content

feat(sdk): enforce disabled_agents deny-list at sub-agent spawn time - #4557

Open
georgeglarson wants to merge 7 commits into
OpenHands:mainfrom
georgeglarson:feat-agent-context-disabled-agents
Open

feat(sdk): enforce disabled_agents deny-list at sub-agent spawn time#4557
georgeglarson wants to merge 7 commits into
OpenHands:mainfrom
georgeglarson:feat-agent-context-disabled-agents

Conversation

@georgeglarson

@georgeglarson georgeglarson commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

HUMAN:

Tested and verified by human. e2e logs attached


AGENT:

End-to-end evidence (the template asks for more than unit tests): two demo scripts in .pr/, run against the editable install of the checkout under test, no LLM calls involved. Real registry, TaskManager, TaskExecutor, and DelegateExecutor throughout.

uv run python .pr/demo_disabled_agents.py
uv run python .pr/demo_disabled_agents_2.py

Each script was run on this branch and on clean upstream/main (d98fd95); full output for all four runs is in .pr/demo-logs/.

On clean main: AgentContext has no disabled_agents (an agent_settings.agent_context.disabled_agents key in the start-conversation payload is silently dropped during validation), spawning and resuming the "disabled" type proceed, and delegate spawn reports "Successfully spawned".

On this branch: the field lands on the built agent, create and resume refuse with Sub-agent 'general-purpose' is disabled for this conversation (agent_context.disabled_agents). Choose another sub-agent type. (returned to the calling LLM as an ordinary, retryable tool error), delegate spawn returns an error observation and creates nothing, and types not on the list spawn and resume normally.

Why

The sub-agent side of the SDK has no per-conversation deny-list (skills have AgentContext.disabled_skills; sub-agents have nothing equivalent). The GUI Sub-Agents page (OpenHands/OpenHands#16662) persists a disabled_agents preference that today cannot even reach the agent: the key is silently dropped during validation. And because the sub-agent registry is process-global, an agent-server cannot unregister an agent for one conversation without removing it for all of them. Details in #4556.

Summary

  • Add AgentContext.disabled_agents: list[str], a deny-list mirroring disabled_skills; it rides the existing AgentContext plumbing (StartConversationRequest, settings schema).
  • Enforce at spawn time: TaskManager._create_task / _resume_task raise a ValueError naming the disabled type (surfaces as a retryable tool error); the delegate tool's spawn path returns an error observation.
  • Tests: field round-trip, create/refusal, resume/refusal, enabled-type control, delegate spawn refusal.

Issue Number

Fixes #4556

How to Test

Live demos (no API key needed):

uv run python .pr/demo_disabled_agents.py
uv run python .pr/demo_disabled_agents_2.py

Unit tests:

uv run pytest tests/sdk/context/test_agent_context.py tests/tools/task/test_task_manager.py tests/tools/delegate/test_delegation.py -q -k disabled_agent

Video/Screenshots

image image

Design Doc

Not included; the change is ~40 LOC plus tests, and .pr/ carries runnable demos with reference output instead.

Type

  • Feature

Notes

  • Enforcement lives at spawn time, not registration time, because the sub-agent registry is process-global and shared across conversations on agent-server (built-ins register at tool-router import). Registration-time filtering would disable the agent for every conversation in the process.
  • Deliberately out of scope: hiding disabled agents from the task tool's advertised list (prompt-side filtering). Complementary follow-up, not enforcement.
  • Companion pieces outside this diff: the companion docs PR docs(sdk): document AgentContext.disabled_agents deny-list docs#739 (repo policy for API additions), and separately one line in OpenHands/OpenHands agent-server-adapter.ts passing the GUI preference into the agent_context payload (can ride with feat(settings): add Sub-Agents page to Customize OpenHands#16662).
  • Full-repo test suites on the development machine: 8899 passed, 41 failed; the 41 also fail on clean upstream/main (terminal timing flakes and full-run state pollution, verified by re-running the identical test set on both refs plus repeated stability probes). CI is the arbiter for the rest.

georgeglarson and others added 2 commits August 19, 2026 20:35
Add AgentContext.disabled_agents (mirror of disabled_skills) and enforce
it where sub-agents are created: TaskManager._create_task/_resume_task
raise a ValueError naming the disabled type (surfaces to the LLM as a
retryable tool error), and the delegate tool's spawn path returns an
error observation. Enforcement lives at spawn time because the sub-agent
registry is process-global and shared across conversations, so
registration-time filtering cannot be per-conversation.

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
@github-actions

Copy link
Copy Markdown
Contributor

📁 PR Artifacts Notice

This PR contains a .pr/ directory with temporary PR-specific documents. Because this is a fork PR, the directory will be automatically removed from main immediately after merge.

@georgeglarson georgeglarson changed the title Feat agent context disabled agents feat(sdk): enforce disabled_agents deny-list at sub-agent spawn time Aug 20, 2026
georgeglarson and others added 5 commits August 20, 2026 07:47
…covery)

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
DisabledAgentError (a ValueError) is raised by the deny-list guard and
caught specifically in TaskExecutor: the model gets the same error
observation text, but a policy refusal no longer logs an ERROR-level
traceback (it rides the generic exception path no more). Evidence logs
refreshed: no Traceback/ERROR in the refusal path; live e2e still shows
refusal + recovery with a real model.

Co-authored-by: openhands <openhands@all-hands.dev>
@georgeglarson
georgeglarson marked this pull request as ready for review August 20, 2026 12:46
@all-hands-bot

Copy link
Copy Markdown
Collaborator

🤖 OpenHands is reviewing this PR.

Head commit: 3d6084c1a56d32ff0216a59a5adf50e0cd722f90
View the conversation: https://oss-agent-canvas.ngrok.dev/conversations/b95b6c56-5b8c-41d0-9444-b795cad97949

This comment was posted by an AI agent (OpenHands).

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.

Summary

This PR adds AgentContext.disabled_agents: list[str], a per-conversation sub-agent deny-list mirroring the existing disabled_skills field, and enforces it at spawn time in both the task tool (TaskManager._create_task/_resume_task) and the delegate tool (DelegateExecutor._spawn_agents).

Risk Assessment: Low

The change is additive and backward-compatible:

  • disabled_agents is a new AgentContext field with default_factory=list, so existing persisted settings payloads load unchanged — no schema-version bump is required (the change is purely additive, not incompatible). This mirrors how disabled_skills was introduced.
  • Enforcement at spawn time (rather than registration time) is the correct design given the sub-agent registry is process-global and shared across conversations on the agent-server. Registration-time filtering would disable the agent for every conversation in the process.
  • Both sub-agent spawn paths are covered. The task tool raises DisabledAgentError(ValueError), which TaskExecutor.__call__ catches before the generic Exception handler and logs at INFO (a policy outcome, not a crash). The delegate path early-returns an error observation before any sub-agent conversation is created, so no cleanup is needed.
  • _delegate_tasks only delegates to existing sub-agents (it errors on missing IDs rather than spawning), so there is no bypass path through delegation.
  • The deny-list check uses exact list membership (subagent_type in disabled), not substring matching, so disabling "general-purpose" cannot accidentally block "general".

Material Findings

None. No bugs, security issues, or design flaws found on the changed lines.

Minor observations (not blocking)

  • _check_agent_enabled is called inside _tasks_lock in _resume_task but outside the lock in _create_task. This is harmless since the check is a read of an effectively-immutable list, but the asymmetry is slightly surprising.
  • The two error messages differ slightly in wording (task: "Sub-agent 'X' is disabled..."; delegate: "Sub-agent type(s) disabled...: X"). Both are clear and the inconsistency is cosmetic.

Tests cover field round-trip, create/refusal, resume/refusal, an enabled-type control, delegate spawn refusal, and the clean-error (no ERROR-level log) behavior. The .pr/ demo scripts and logs provide end-to-end evidence consistent with the repo PR-artifacts convention.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Per-conversation sub-agent deny-list (disabled_agents), enforced at spawn time

2 participants