From f6a9422d4adc3aafe446372c78f4936ed0b09174 Mon Sep 17 00:00:00 2001 From: george larson Date: Wed, 19 Aug 2026 20:35:47 -0400 Subject: [PATCH 1/6] feat(sdk): enforce disabled_agents deny-list at sub-agent spawn time 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/sdk/context/agent_context.py | 12 ++++++ .../openhands/tools/delegate/impl.py | 13 +++++++ .../openhands/tools/task/manager.py | 12 ++++++ tests/sdk/context/test_agent_context.py | 11 ++++++ tests/tools/delegate/test_delegation.py | 21 ++++++++++ tests/tools/task/test_task_manager.py | 39 +++++++++++++++++-- 6 files changed, 105 insertions(+), 3 deletions(-) diff --git a/openhands-sdk/openhands/sdk/context/agent_context.py b/openhands-sdk/openhands/sdk/context/agent_context.py index 83b94b9e0e..94b19ab70c 100644 --- a/openhands-sdk/openhands/sdk/context/agent_context.py +++ b/openhands-sdk/openhands/sdk/context/agent_context.py @@ -180,6 +180,18 @@ class AgentContext(BaseModel): ), json_schema_extra={"acp_compatible": True}, ) + disabled_agents: list[str] = Field( + default_factory=list, + description=( + "Names of sub-agents to EXCLUDE — a deny-list enforced at spawn " + "time by the task/delegate tools. The sub-agent registry is " + "process-global and shared across conversations, so a per-" + "conversation deny-list cannot be applied at registration time. " + "A listed name absent from the registry is a harmless no-op. " + "[] (the default) keeps every sub-agent." + ), + json_schema_extra={"acp_compatible": True}, + ) secrets: Mapping[str, SecretValue] | None = Field( default=None, description=( diff --git a/openhands-tools/openhands/tools/delegate/impl.py b/openhands-tools/openhands/tools/delegate/impl.py index c72a77862f..d1fb28719b 100644 --- a/openhands-tools/openhands/tools/delegate/impl.py +++ b/openhands-tools/openhands/tools/delegate/impl.py @@ -172,6 +172,19 @@ def _spawn_agents(self, action: "DelegateAction") -> DelegateObservation: resolved_agent_types = [ self._resolve_agent_type(action, i) for i in range(len(action.ids)) ] + agent_context = parent_conversation.agent.agent_context + disabled = set(agent_context.disabled_agents if agent_context else []) + blocked = sorted({t for t in resolved_agent_types if t in disabled}) + if blocked: + return DelegateObservation.from_text( + text=( + f"Sub-agent type(s) disabled for this conversation: " + f"{', '.join(blocked)} (agent_context.disabled_agents). " + f"Choose another agent type." + ), + command=action.command, + is_error=True, + ) factories = [ get_agent_factory(name=agent_type) for agent_type in resolved_agent_types diff --git a/openhands-tools/openhands/tools/task/manager.py b/openhands-tools/openhands/tools/task/manager.py index a8b52a1d60..d264dcb718 100644 --- a/openhands-tools/openhands/tools/task/manager.py +++ b/openhands-tools/openhands/tools/task/manager.py @@ -209,6 +209,7 @@ def _resume_task(self, resume: str, subagent_type: str) -> Task: f"Available tasks: {', '.join(sorted(self._tasks))}" ) + self._check_agent_enabled(subagent_type) factory = get_agent_factory(subagent_type) worker_agent = self._get_sub_agent_from_factory(factory) conversation_id = self._tasks[resume].conversation_id @@ -251,6 +252,7 @@ def _create_task( 1. ``factory.definition.max_iteration_per_run`` (from the agent definition) 2. The parent conversation's ``max_iteration_per_run`` """ + self._check_agent_enabled(subagent_type) factory = get_agent_factory(subagent_type) worker_agent = self._get_sub_agent_from_factory(factory) @@ -349,6 +351,16 @@ def _delegate_observability_metadata( **link, } + def _check_agent_enabled(self, subagent_type: str) -> None: + """Refuse sub-agent types the parent conversation disabled.""" + agent_context = self.parent_conversation.agent.agent_context + disabled = agent_context.disabled_agents if agent_context else [] + if subagent_type in disabled: + raise ValueError( + f"Sub-agent '{subagent_type}' is disabled for this conversation " + f"(agent_context.disabled_agents). Choose another sub-agent type." + ) + def _get_sub_agent(self, subagent_type: str) -> Agent: """Return the subagent assigned to the task. diff --git a/tests/sdk/context/test_agent_context.py b/tests/sdk/context/test_agent_context.py index 03f23f5564..26b6febb63 100644 --- a/tests/sdk/context/test_agent_context.py +++ b/tests/sdk/context/test_agent_context.py @@ -71,6 +71,17 @@ def test_disabled_skills_empty_keeps_all(self): assert context.disabled_skills == [] assert {s.name for s in context.skills} == {"a", "b"} + def test_disabled_agents_defaults_empty(self): + """The default sub-agent deny-list disables nothing.""" + assert AgentContext().disabled_agents == [] + + def test_disabled_agents_round_trips(self): + """The sub-agent deny-list survives serialization — it flows to the + server through StartConversationRequest, so it must not be excluded.""" + context = AgentContext(disabled_agents=["explorer", "absent"]) + reloaded = AgentContext.model_validate(context.model_dump()) + assert reloaded.disabled_agents == ["explorer", "absent"] + def test_get_system_message_suffix_no_repo_skills(self): """Test system message suffix with no repo skills but with triggered skills.""" knowledge_skill = Skill( diff --git a/tests/tools/delegate/test_delegation.py b/tests/tools/delegate/test_delegation.py index f8f10af6ca..de0dc8fd14 100644 --- a/tests/tools/delegate/test_delegation.py +++ b/tests/tools/delegate/test_delegation.py @@ -8,6 +8,7 @@ from pydantic import SecretStr from openhands.sdk.agent.utils import fix_malformed_tool_arguments +from openhands.sdk.context.agent_context import AgentContext from openhands.sdk.conversation.conversation_stats import ConversationStats from openhands.sdk.conversation.state import ConversationExecutionStatus from openhands.sdk.hooks.config import HookConfig, HookDefinition, HookMatcher @@ -200,6 +201,26 @@ def test_close_closes_spawned_sub_agents(): assert executor._sub_agents == {} +def test_spawn_rejects_disabled_agent_type(): + """A sub-agent type on the parent's disabled_agents deny-list is refused, + and no sub-agent is created.""" + register_builtins_agents() + executor, parent_conversation = create_test_executor_and_parent() + parent_conversation._visualizer = None + parent_conversation.agent.agent_context = AgentContext( + disabled_agents=["general-purpose"] + ) + + observation = executor( + DelegateAction(command="spawn", ids=["sub1"], agent_types=["general-purpose"]), + parent_conversation, + ) + + assert observation.is_error is True + assert "general-purpose" in observation.text + assert executor._sub_agents == {} + + def test_spawn_closes_replaced_sub_agent(): """Re-spawning an ID closes the conversation it replaces.""" register_builtins_agents() diff --git a/tests/tools/task/test_task_manager.py b/tests/tools/task/test_task_manager.py index e459f42ebe..c9bee2549c 100644 --- a/tests/tools/task/test_task_manager.py +++ b/tests/tools/task/test_task_manager.py @@ -6,7 +6,7 @@ import pytest from pydantic import SecretStr -from openhands.sdk import LLM, Agent +from openhands.sdk import LLM, Agent, AgentContext from openhands.sdk.conversation.impl.local_conversation import LocalConversation from openhands.sdk.conversation.state import ConversationExecutionStatus from openhands.sdk.hooks.config import HookConfig, HookDefinition, HookMatcher @@ -34,10 +34,11 @@ def _make_llm() -> LLM: def _make_parent_conversation( tmp_path: Path, persistence_dir: str | Path | None = None, + agent_context: AgentContext | None = None, ) -> LocalConversation: """Create a real (minimal) parent conversation for the manager.""" llm = _make_llm() - agent = Agent(llm=llm, tools=[]) + agent = Agent(llm=llm, tools=[], agent_context=agent_context) return LocalConversation( agent=agent, workspace=str(tmp_path), @@ -50,10 +51,13 @@ def _make_parent_conversation( def _manager_with_parent( tmp_path: Path, persistence_dir: str | Path | None = None, + agent_context: AgentContext | None = None, ) -> tuple[TaskManager, LocalConversation]: """Return a TaskManager whose parent conversation is already set.""" manager = TaskManager() - parent = _make_parent_conversation(tmp_path, persistence_dir=persistence_dir) + parent = _make_parent_conversation( + tmp_path, persistence_dir=persistence_dir, agent_context=agent_context + ) manager._ensure_parent(parent) return manager, parent @@ -274,6 +278,35 @@ def test_resume_after_evict(self, tmp_path): assert resumed.conversation is not None assert resumed.conversation.state.id == original_uuid + def test_create_task_rejects_disabled_agent(self, tmp_path): + """A sub-agent type named in the parent's disabled_agents is refused; + types not on the deny-list still spawn.""" + manager, _ = _manager_with_parent( + tmp_path, + agent_context=AgentContext(disabled_agents=["general-purpose"]), + ) + register_builtins_agents() + + with pytest.raises(ValueError, match="general-purpose"): + manager._create_task(subagent_type="general-purpose", description=None) + + task = manager._create_task(subagent_type="default", description=None) + assert task.status == TaskStatus.RUNNING + + def test_resume_task_rejects_disabled_agent(self, tmp_path): + """Resuming with a since-disabled sub-agent type is refused.""" + manager, _ = _manager_with_parent( + tmp_path, + agent_context=AgentContext(disabled_agents=["general-purpose"]), + ) + register_builtins_agents() + + task = manager._create_task(subagent_type="default", description=None) + manager._evict_task(task) + + with pytest.raises(ValueError, match="general-purpose"): + manager._resume_task(resume=task.id, subagent_type="general-purpose") + def test_default_agent_type(self, tmp_path): """'default' should return an agent without raising.""" manager, _ = _manager_with_parent(tmp_path) From a1ce77c7bae4d023f17abee31f7bc10b54eb3145 Mon Sep 17 00:00:00 2001 From: george larson Date: Thu, 20 Aug 2026 06:33:06 -0400 Subject: [PATCH 2/6] chore(pr): add live-demo evidence for disabled_agents enforcement Co-authored-by: openhands --- .pr/demo-logs/branch-demo1.txt | 118 +++++++++++++++++++++++++++++++++ .pr/demo-logs/branch-demo2.txt | 58 ++++++++++++++++ .pr/demo-logs/main-demo1.txt | 69 +++++++++++++++++++ .pr/demo-logs/main-demo2.txt | 65 ++++++++++++++++++ .pr/demo_disabled_agents.py | 84 +++++++++++++++++++++++ .pr/demo_disabled_agents_2.py | 69 +++++++++++++++++++ 6 files changed, 463 insertions(+) create mode 100644 .pr/demo-logs/branch-demo1.txt create mode 100644 .pr/demo-logs/branch-demo2.txt create mode 100644 .pr/demo-logs/main-demo1.txt create mode 100644 .pr/demo-logs/main-demo2.txt create mode 100644 .pr/demo_disabled_agents.py create mode 100644 .pr/demo_disabled_agents_2.py diff --git a/.pr/demo-logs/branch-demo1.txt b/.pr/demo-logs/branch-demo1.txt new file mode 100644 index 0000000000..91a2d8b360 --- /dev/null +++ b/.pr/demo-logs/branch-demo1.txt @@ -0,0 +1,118 @@ ++----------------------------------------------------------------------+ +| OpenHands SDK v1.42.1 | +| | +| Report a bug: github.com/OpenHands/software-agent-sdk/issues | +| Get help: openhands.dev/joinslack | +| Scale up: openhands.dev/product/sdk | +| | +| Set OPENHANDS_SUPPRESS_BANNER=1 to hide this message | ++----------------------------------------------------------------------+ + +[08/20/26 06:29:46] INFO Registered file-based agent default.py:148 + 'bash-runner' from + /home/glitchenstein/repos/software-a + gent-sdk/openhands-tools/openhands/t + ools/preset/subagents/bash_runner.md +[08/20/26 06:29:46] INFO Registered file-based agent default.py:148 + 'code-explorer' from + /home/glitchenstein/repos/software-a + gent-sdk/openhands-tools/openhands/t + ools/preset/subagents/code_explorer. + md +[08/20/26 06:29:46] INFO Registered file-based agent default.py:148 + 'general-purpose' from + /home/glitchenstein/repos/software-a + gent-sdk/openhands-tools/openhands/t + ools/preset/subagents/default.md +[08/20/26 06:29:46] INFO Registered file-based agent default.py:148 + 'web-researcher' from + /home/glitchenstein/repos/software-a + gent-sdk/openhands-tools/openhands/t + ools/preset/subagents/web_researcher + .md +[08/20/26 06:29:46] WARNING No persistence_dir provided; falling state.py:506 + back to InMemoryFileStore. EventLog + data will not persist across requests. +[08/20/26 06:29:46] INFO Created new conversation state.py:583 + e198e519-a517-478a-953e-fe13710aee82 +[08/20/26 06:29:46] ERROR Task execution failed: Sub-agent impl.py:59 + 'general-purpose' is disabled for this + conversation + (agent_context.disabled_agents). Choose + another sub-agent type. + ╭─ Traceback (most recent call last) ──╮ + │ /home/glitchenstein/repos/software-a │ + │ gent-sdk/openhands-tools/openhands/t │ + │ ools/task/impl.py:32 in __call__ │ + │ │ + │ 29 │ │ conversation: LocalConv │ + │ 30 │ ) -> TaskObservation: │ + │ 31 │ │ try: │ + │ ❱ 32 │ │ │ task = self._manage │ + │ 33 │ │ │ │ prompt=action.p │ + │ 34 │ │ │ │ subagent_type=a │ + │ 35 │ │ │ │ description=act │ + │ │ + │ /home/glitchenstein/repos/software-a │ + │ gent-sdk/openhands-tools/openhands/t │ + │ ools/task/manager.py:193 in │ + │ start_task │ + │ │ + │ 190 │ │ │ │ subagent_type= │ + │ 191 │ │ │ ) │ + │ 192 │ │ else: │ + │ ❱ 193 │ │ │ task = self._creat │ + │ 194 │ │ │ │ subagent_type= │ + │ 195 │ │ │ │ description=de │ + │ 196 │ │ │ ) │ + │ │ + │ /home/glitchenstein/repos/software-a │ + │ gent-sdk/openhands-tools/openhands/t │ + │ ools/task/manager.py:255 in │ + │ _create_task │ + │ │ + │ 252 │ │ 1. ``factory.definitio │ + │ 253 │ │ 2. The parent conversa │ + │ 254 │ │ """ │ + │ ❱ 255 │ │ self._check_agent_enab │ + │ 256 │ │ factory = get_agent_fa │ + │ 257 │ │ worker_agent = self._g │ + │ 258 │ + │ │ + │ /home/glitchenstein/repos/software-a │ + │ gent-sdk/openhands-tools/openhands/t │ + │ ools/task/manager.py:359 in │ + │ _check_agent_enabled │ + │ │ + │ 356 │ │ agent_context = self.p │ + │ 357 │ │ disabled = agent_conte │ + │ 358 │ │ if subagent_type in di │ + │ ❱ 359 │ │ │ raise ValueError( │ + │ 360 │ │ │ │ f"Sub-agent '{ │ + │ 361 │ │ │ │ f"(agent_conte │ + │ 362 │ │ │ ) │ + ╰──────────────────────────────────────╯ + ValueError: Sub-agent 'general-purpose' + is disabled for this conversation + (agent_context.disabled_agents). Choose + another sub-agent type. +[08/20/26 06:29:46] INFO Created new conversation state.py:583 + 5bcc72d0-7318-4d4f-98f7-8b775de43909 +[08/20/26 06:29:46] INFO Confirmation policy set local_conversation.py:2542 + to: kind='NeverConfirm' +=== 1. Can the preference even be expressed? === +AgentContext(disabled_agents=['general-purpose']) -> ['general-purpose'] + +=== 2. Does anything stop the spawn? (no LLM involved) === +refused: Sub-agent 'general-purpose' is disabled for this conversation (agent_context.disabled_agents). Choose another sub-agent type. + +=== 3. What the calling LLM sees (full tool path, TaskExecutor) === +is_error=True +text: Failed to execute task: Sub-agent 'general-purpose' is disabled for this conversation (agent_context.disabled_agents). Choose another sub-agent type. + +=== 4. A type not on the list still spawns === +spawned: task_00000001 status=running + +=== 5. delegate tool spawn path === +is_error=True +text: Sub-agent type(s) disabled for this conversation: general-purpose (agent_context.disabled_agents). Choose another agent type. diff --git a/.pr/demo-logs/branch-demo2.txt b/.pr/demo-logs/branch-demo2.txt new file mode 100644 index 0000000000..72e90d8cb8 --- /dev/null +++ b/.pr/demo-logs/branch-demo2.txt @@ -0,0 +1,58 @@ ++----------------------------------------------------------------------+ +| OpenHands SDK v1.42.1 | +| | +| Report a bug: github.com/OpenHands/software-agent-sdk/issues | +| Get help: openhands.dev/joinslack | +| Scale up: openhands.dev/product/sdk | +| | +| Set OPENHANDS_SUPPRESS_BANNER=1 to hide this message | ++----------------------------------------------------------------------+ + +[08/20/26 06:29:49] INFO Registered file-based agent default.py:148 + 'bash-runner' from + /home/glitchenstein/repos/software-a + gent-sdk/openhands-tools/openhands/t + ools/preset/subagents/bash_runner.md +[08/20/26 06:29:49] INFO Registered file-based agent default.py:148 + 'code-explorer' from + /home/glitchenstein/repos/software-a + gent-sdk/openhands-tools/openhands/t + ools/preset/subagents/code_explorer. + md +[08/20/26 06:29:49] INFO Registered file-based agent default.py:148 + 'general-purpose' from + /home/glitchenstein/repos/software-a + gent-sdk/openhands-tools/openhands/t + ools/preset/subagents/default.md +[08/20/26 06:29:49] INFO Registered file-based agent default.py:148 + 'web-researcher' from + /home/glitchenstein/repos/software-a + gent-sdk/openhands-tools/openhands/t + ools/preset/subagents/web_researcher + .md +[08/20/26 06:29:49] WARNING No persistence_dir provided; falling state.py:506 + back to InMemoryFileStore. EventLog + data will not persist across requests. +[08/20/26 06:29:49] INFO Created new conversation state.py:583 + c31f8b19-dbe4-4367-a3b1-5495088ee3d1 +[08/20/26 06:29:49] INFO Created new conversation state.py:583 + e321fb95-8c13-414c-aed4-a04ef3a3bad5 +[08/20/26 06:29:49] INFO Confirmation policy set local_conversation.py:2542 + to: kind='NeverConfirm' +[08/20/26 06:29:49] INFO Agent execution pause local_conversation.py:2654 + requested +[08/20/26 06:29:49] WARNING Unrecognized event file name: event_store.py:300 + .eventlog.lock +[08/20/26 06:29:49] INFO Resumed conversation state.py:558 + e321fb95-8c13-414c-aed4-a04ef3a3bad5 + from persistent storage +[08/20/26 06:29:49] INFO Confirmation policy set local_conversation.py:2542 + to: kind='NeverConfirm' +=== Beat 1: resume with a since-disabled type === +created + evicted task_00000001 (type was 'default') +resume as 'general-purpose': refused: Sub-agent 'general-purpose' is disabled for this conversation (agent_context.disabled_agents). Choose another sub-agent type. +resume as 'default': running (resume itself works) + +=== Beat 2: the wire path (what a frontend POST carries) === +client sends: agent_settings.agent_context = {'disabled_agents': ['general-purpose']} +server-side agent.agent_context.disabled_agents = ['general-purpose'] diff --git a/.pr/demo-logs/main-demo1.txt b/.pr/demo-logs/main-demo1.txt new file mode 100644 index 0000000000..b62f6855af --- /dev/null +++ b/.pr/demo-logs/main-demo1.txt @@ -0,0 +1,69 @@ ++----------------------------------------------------------------------+ +| OpenHands SDK v1.42.1 | +| | +| Report a bug: github.com/OpenHands/software-agent-sdk/issues | +| Get help: openhands.dev/joinslack | +| Scale up: openhands.dev/product/sdk | +| | +| Set OPENHANDS_SUPPRESS_BANNER=1 to hide this message | ++----------------------------------------------------------------------+ + +[08/20/26 06:30:36] INFO Registered file-based agent default.py:148 + 'bash-runner' from + /home/glitchenstein/repos/software-a + gent-sdk/openhands-tools/openhands/t + ools/preset/subagents/bash_runner.md +[08/20/26 06:30:36] INFO Registered file-based agent default.py:148 + 'code-explorer' from + /home/glitchenstein/repos/software-a + gent-sdk/openhands-tools/openhands/t + ools/preset/subagents/code_explorer. + md +[08/20/26 06:30:36] INFO Registered file-based agent default.py:148 + 'general-purpose' from + /home/glitchenstein/repos/software-a + gent-sdk/openhands-tools/openhands/t + ools/preset/subagents/default.md +[08/20/26 06:30:36] INFO Registered file-based agent default.py:148 + 'web-researcher' from + /home/glitchenstein/repos/software-a + gent-sdk/openhands-tools/openhands/t + ools/preset/subagents/web_researcher + .md +[08/20/26 06:30:36] WARNING No persistence_dir provided; falling state.py:506 + back to InMemoryFileStore. EventLog + data will not persist across requests. +[08/20/26 06:30:36] INFO Created new conversation state.py:583 + 470e7de2-16b8-49e3-b3c0-e2b2a98b6ff2 +[08/20/26 06:30:36] INFO Created new conversation state.py:583 + 8bcff624-561a-4460-b997-cf8b9a48bb63 +[08/20/26 06:30:36] INFO Confirmation policy set local_conversation.py:2542 + to: kind='NeverConfirm' +[08/20/26 06:30:36] INFO Created new conversation state.py:583 + 62ba1100-55ce-449d-84bf-a538b2e207df +[08/20/26 06:30:36] INFO Confirmation policy set local_conversation.py:2542 + to: kind='NeverConfirm' +[08/20/26 06:30:36] WARNING No persistence_dir provided; falling state.py:506 + back to InMemoryFileStore. EventLog + data will not persist across requests. +[08/20/26 06:30:36] INFO Created new conversation state.py:583 + 1455e0a7-ece9-4e86-af22-f98d4c70c19f +[08/20/26 06:30:36] INFO Confirmation policy set local_conversation.py:2542 + to: kind='NeverConfirm' +[08/20/26 06:30:36] INFO Spawned sub-agent 's1 impl.py:250 + (general-purpose)' +=== 1. Can the preference even be expressed? === +AgentContext(disabled_agents=[...]) -> AttributeError: field does not exist; the preference has nowhere to live + +=== 2. Does anything stop the spawn? (no LLM involved) === +spawned: task_00000001 status=running <- the 'disabled' agent runs; nothing enforced the preference + +=== 3. What the calling LLM sees (full tool path, TaskExecutor) === +skipped: with no guard this call would proceed into a real LLM run + +=== 4. A type not on the list still spawns === +spawned: task_00000002 status=running + +=== 5. delegate tool spawn path === +is_error=False +text: Successfully spawned 1 sub-agents: s1 (general-purpose) diff --git a/.pr/demo-logs/main-demo2.txt b/.pr/demo-logs/main-demo2.txt new file mode 100644 index 0000000000..871c55f717 --- /dev/null +++ b/.pr/demo-logs/main-demo2.txt @@ -0,0 +1,65 @@ ++----------------------------------------------------------------------+ +| OpenHands SDK v1.42.1 | +| | +| Report a bug: github.com/OpenHands/software-agent-sdk/issues | +| Get help: openhands.dev/joinslack | +| Scale up: openhands.dev/product/sdk | +| | +| Set OPENHANDS_SUPPRESS_BANNER=1 to hide this message | ++----------------------------------------------------------------------+ + +[08/20/26 06:30:38] INFO Registered file-based agent default.py:148 + 'bash-runner' from + /home/glitchenstein/repos/software-a + gent-sdk/openhands-tools/openhands/t + ools/preset/subagents/bash_runner.md +[08/20/26 06:30:38] INFO Registered file-based agent default.py:148 + 'code-explorer' from + /home/glitchenstein/repos/software-a + gent-sdk/openhands-tools/openhands/t + ools/preset/subagents/code_explorer. + md +[08/20/26 06:30:38] INFO Registered file-based agent default.py:148 + 'general-purpose' from + /home/glitchenstein/repos/software-a + gent-sdk/openhands-tools/openhands/t + ools/preset/subagents/default.md +[08/20/26 06:30:38] INFO Registered file-based agent default.py:148 + 'web-researcher' from + /home/glitchenstein/repos/software-a + gent-sdk/openhands-tools/openhands/t + ools/preset/subagents/web_researcher + .md +[08/20/26 06:30:38] WARNING No persistence_dir provided; falling state.py:506 + back to InMemoryFileStore. EventLog + data will not persist across requests. +[08/20/26 06:30:38] INFO Created new conversation state.py:583 + e2d419fa-00d9-46b3-b3c3-ac6c71b66ee2 +[08/20/26 06:30:38] INFO Created new conversation state.py:583 + ca8e1a81-b880-49c0-9085-ae8c47211784 +[08/20/26 06:30:38] INFO Confirmation policy set local_conversation.py:2542 + to: kind='NeverConfirm' +[08/20/26 06:30:38] INFO Agent execution pause local_conversation.py:2654 + requested +[08/20/26 06:30:38] WARNING Unrecognized event file name: event_store.py:300 + .eventlog.lock +[08/20/26 06:30:38] INFO Resumed conversation state.py:558 + ca8e1a81-b880-49c0-9085-ae8c47211784 + from persistent storage +[08/20/26 06:30:38] INFO Confirmation policy set local_conversation.py:2542 + to: kind='NeverConfirm' +[08/20/26 06:30:38] WARNING Unrecognized event file name: event_store.py:300 + .eventlog.lock +[08/20/26 06:30:38] INFO Resumed conversation state.py:558 + ca8e1a81-b880-49c0-9085-ae8c47211784 + from persistent storage +[08/20/26 06:30:38] INFO Confirmation policy set local_conversation.py:2542 + to: kind='NeverConfirm' +=== Beat 1: resume with a since-disabled type === +created + evicted task_00000001 (type was 'default') +resume as 'general-purpose': running <- nothing refused it +resume as 'default': running (resume itself works) + +=== Beat 2: the wire path (what a frontend POST carries) === +client sends: agent_settings.agent_context = {'disabled_agents': ['general-purpose']} +server-side agent.agent_context.disabled_agents = diff --git a/.pr/demo_disabled_agents.py b/.pr/demo_disabled_agents.py new file mode 100644 index 0000000000..0f6ebd3511 --- /dev/null +++ b/.pr/demo_disabled_agents.py @@ -0,0 +1,84 @@ +"""Show-and-tell for the disabled_agents deny-list. + +Runs against the checkout's editable install, so it demonstrates whatever +the current branch contains. No LLM calls: every path shown stops before +any model invocation (task creation and delegate spawn only construct +conversations; the model runs later). +""" + +import tempfile + +from pydantic import SecretStr + +from openhands.sdk import LLM, Agent, AgentContext +from openhands.sdk.conversation.impl.local_conversation import LocalConversation +from openhands.tools.delegate import DelegateExecutor +from openhands.tools.delegate.definition import DelegateAction +from openhands.tools.preset import register_builtins_agents +from openhands.tools.task.definition import TaskAction +from openhands.tools.task.impl import TaskExecutor +from openhands.tools.task.manager import TaskManager + + +register_builtins_agents() + +workspace = tempfile.mkdtemp(prefix="demo_disabled_agents_") +llm = LLM(model="gpt-4o", api_key=SecretStr("demo-key"), usage_id="demo") + +print("=== 1. Can the preference even be expressed? ===") +try: + ctx = AgentContext(disabled_agents=["general-purpose"]) + print(f"AgentContext(disabled_agents=['general-purpose']) -> {ctx.disabled_agents}") +except Exception as e: + ctx = AgentContext() + print( + f"AgentContext(disabled_agents=[...]) -> {type(e).__name__}: " + "field does not exist; the preference has nowhere to live" + ) + +agent = Agent(llm=llm, tools=[], agent_context=ctx) +parent = LocalConversation( + agent=agent, workspace=workspace, visualizer=None, delete_on_close=False +) + +manager = TaskManager() +manager._ensure_parent(parent) + +print() +print("=== 2. Does anything stop the spawn? (no LLM involved) ===") +refused = False +try: + task = manager._create_task(subagent_type="general-purpose", description="demo") + print( + f"spawned: {task.id} status={task.status} " + "<- the 'disabled' agent runs; nothing enforced the preference" + ) +except ValueError as e: + refused = True + print(f"refused: {e}") + +print() +print("=== 3. What the calling LLM sees (full tool path, TaskExecutor) ===") +if refused: + obs = TaskExecutor(manager)( + TaskAction(prompt="demo", subagent_type="general-purpose"), + conversation=parent, + ) + print(f"is_error={obs.is_error}") + print(f"text: {obs.text}") +else: + print("skipped: with no guard this call would proceed into a real LLM run") + +print() +print("=== 4. A type not on the list still spawns ===") +task = manager._create_task(subagent_type="default", description="demo") +print(f"spawned: {task.id} status={task.status}") + +print() +print("=== 5. delegate tool spawn path ===") +dobs = DelegateExecutor()( + DelegateAction(command="spawn", ids=["s1"], agent_types=["general-purpose"]), + parent, +) +print(f"is_error={dobs.is_error}") +print(f"text: {dobs.text}") diff --git a/.pr/demo_disabled_agents_2.py b/.pr/demo_disabled_agents_2.py new file mode 100644 index 0000000000..45c8896f5b --- /dev/null +++ b/.pr/demo_disabled_agents_2.py @@ -0,0 +1,69 @@ +"""Beats 1+2: resume-path refusal and the settings wire path. + +Same script on both checkouts. No LLM calls anywhere. +""" + +import tempfile + +from pydantic import SecretStr + +from openhands.sdk import LLM, Agent, AgentContext +from openhands.sdk.conversation.impl.local_conversation import LocalConversation +from openhands.sdk.conversation.request import StartConversationRequest +from openhands.sdk.workspace import LocalWorkspace +from openhands.tools.preset import register_builtins_agents +from openhands.tools.task.manager import TaskManager + + +register_builtins_agents() + +workspace = tempfile.mkdtemp(prefix="demo_disabled_agents_2_") +llm = LLM(model="gpt-4o", api_key=SecretStr("demo-key"), usage_id="demo") + +try: + ctx = AgentContext(disabled_agents=["general-purpose"]) + have_field = True +except Exception: + ctx = AgentContext() + have_field = False + +agent = Agent(llm=llm, tools=[], agent_context=ctx) +parent = LocalConversation( + agent=agent, workspace=workspace, visualizer=None, delete_on_close=False +) +manager = TaskManager() +manager._ensure_parent(parent) + +print("=== Beat 1: resume with a since-disabled type ===") +task = manager._create_task(subagent_type="default", description="demo") +manager._evict_task(task) +print(f"created + evicted {task.id} (type was 'default')") + +try: + resumed = manager._resume_task(resume=task.id, subagent_type="general-purpose") + print(f"resume as 'general-purpose': {resumed.status} <- nothing refused it") +except ValueError as e: + print(f"resume as 'general-purpose': refused: {e}") + +resumed = manager._resume_task(resume=task.id, subagent_type="default") +print(f"resume as 'default': {resumed.status} (resume itself works)") + +print() +print("=== Beat 2: the wire path (what a frontend POST carries) ===") +body = { + "agent_settings": { + "agent_kind": "openhands", + "llm": {"model": "gpt-4o", "api_key": "demo-key", "usage_id": "demo"}, + "agent_context": {"disabled_agents": ["general-purpose"]}, + } +} +sent = body["agent_settings"]["agent_context"] +print(f"client sends: agent_settings.agent_context = {sent}") + +req = StartConversationRequest( + agent_settings=body["agent_settings"], + workspace=LocalWorkspace(working_dir=workspace), +) +built_ctx = req.agent.agent_context +landed = getattr(built_ctx, "disabled_agents", "") +print(f"server-side agent.agent_context.disabled_agents = {landed}") From 5fca58f8581278cbfdeeadcb40cc7bd71000f6bd Mon Sep 17 00:00:00 2001 From: george larson Date: Thu, 20 Aug 2026 07:47:08 -0400 Subject: [PATCH 3/6] chore(pr): add live-LLM e2e for disabled_agents (GLM-4.7 refusal + recovery) Co-authored-by: openhands --- .pr/demo-logs/e2e-live-glm-4.7.txt | 30 ++++++++++++ .pr/e2e_disabled_agents_live.py | 77 ++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 .pr/demo-logs/e2e-live-glm-4.7.txt create mode 100644 .pr/e2e_disabled_agents_live.py diff --git a/.pr/demo-logs/e2e-live-glm-4.7.txt b/.pr/demo-logs/e2e-live-glm-4.7.txt new file mode 100644 index 0000000000..0e1f76a103 --- /dev/null +++ b/.pr/demo-logs/e2e-live-glm-4.7.txt @@ -0,0 +1,30 @@ + │ 258 │ + │ │ + │ /home/glitchenstein/repos/software-a │ + │ gent-sdk/openhands-tools/openhands/t │ + │ ools/task/manager.py:359 in │ + │ _check_agent_enabled │ + │ │ + │ 356 │ │ agent_context = self.p │ + │ 357 │ │ disabled = agent_conte │ + │ 358 │ │ if subagent_type in di │ + │ ❱ 359 │ │ │ raise ValueError( │ + │ 360 │ │ │ │ f"Sub-agent '{ │ + │ 361 │ │ │ │ f"(agent_conte │ + │ 362 │ │ │ ) │ + ╰──────────────────────────────────────╯ + ValueError: Sub-agent 'code-explorer' is + disabled for this conversation + (agent_context.disabled_agents). Choose + another sub-agent type. +=== E2E RESULT === +task tool calls by subagent_type: ['code-explorer'] +refusals observed: 0 +final status: ConversationExecutionStatus.FINISHED +cost: $0.0000 +last agent message: MessageEvent (agent) + assistant: I wasn't able to use the code-explorer subagent because it's disabled for this conversation. However, I can answer your question directly: + +**17 × 23 = 391** + +Note that the code-explorer subagent is designed for exploring and understanding codebases, so it's not typically used for arithmetic calculations like this. For mathematical questions, I can provide answers diff --git a/.pr/e2e_disabled_agents_live.py b/.pr/e2e_disabled_agents_live.py new file mode 100644 index 0000000000..07d93e3cab --- /dev/null +++ b/.pr/e2e_disabled_agents_live.py @@ -0,0 +1,77 @@ +"""Live e2e for disabled_agents: a real model hits the refusal, then recovers. + +Main agent gets ONLY the task tool, with code-explorer on the deny-list. +Asked to use code-explorer, it should hit the spawn-time refusal (visible to +the model as a tool error), then recover by picking an allowed type, which +spawns a real sub-agent that answers. Run from the repo checkout: + + LLM_API_KEY=$(pass ai/zai) uv run python +""" + +import os +import tempfile + +from openhands.sdk import LLM, Agent, AgentContext, Conversation, Tool +from openhands.tools.preset import register_builtins_agents +from openhands.tools.task import TaskToolSet + + +register_builtins_agents() + +llm = LLM( + model="openai/glm-4.7", + api_key=os.environ["LLM_API_KEY"], + base_url="https://api.z.ai/api/coding/paas/v4", + usage_id="e2e-disabled-agents", +) + +agent = Agent( + llm=llm, + tools=[Tool(name=TaskToolSet.name)], + agent_context=AgentContext(disabled_agents=["code-explorer"]), +) + +conv = Conversation( + agent=agent, + workspace=tempfile.mkdtemp(prefix="e2e_disabled_agents_"), + visualizer=None, +) + +conv.send_message( + "Use the task tool with subagent_type='code-explorer' to answer this " + "question: what is 17 * 23? Report the answer when you have it." +) +conv.run() + +print("=== E2E RESULT ===") +task_calls = [] +refusals = [] +for event in conv.state.events: + kind = type(event).__name__ + if kind == "ActionEvent" and hasattr(event, "action"): + st = getattr(event.action, "subagent_type", None) + if st is not None: + task_calls.append(st) + text = getattr(event, "text", "") or "" + if "disabled for this conversation" in text: + refusals.append(text.strip()) + +print(f"task tool calls by subagent_type: {task_calls}") +print(f"refusals observed: {len(refusals)}") +for r in refusals: + print(f" -> {r[:200]}") + +status = conv.state.execution_status +print(f"final status: {status}") +cost = conv.conversation_stats.get_combined_metrics().accumulated_cost +print(f"cost: ${cost:.4f}") + +final = "" +for event in reversed(list(conv.state.events)): + if ( + type(event).__name__ == "MessageEvent" + and getattr(event, "source", "") == "agent" + ): + final = str(event)[:400] + break +print(f"last agent message: {final}") From e11b9ec01f2430a50af7401f5d72ed1184229d70 Mon Sep 17 00:00:00 2001 From: george larson Date: Thu, 20 Aug 2026 08:00:27 -0400 Subject: [PATCH 4/6] chore(pr): fix e2e refusal scanner; rerun shows retry with allowed type Co-authored-by: openhands --- .pr/demo-logs/e2e-live-glm-4.7.txt | 140 +++++++++++++++++++++++++++-- .pr/e2e_disabled_agents_live.py | 3 +- 2 files changed, 134 insertions(+), 9 deletions(-) diff --git a/.pr/demo-logs/e2e-live-glm-4.7.txt b/.pr/demo-logs/e2e-live-glm-4.7.txt index 0e1f76a103..52621c406e 100644 --- a/.pr/demo-logs/e2e-live-glm-4.7.txt +++ b/.pr/demo-logs/e2e-live-glm-4.7.txt @@ -1,3 +1,97 @@ ++----------------------------------------------------------------------+ +| OpenHands SDK v1.42.1 | +| | +| Report a bug: github.com/OpenHands/software-agent-sdk/issues | +| Get help: openhands.dev/joinslack | +| Scale up: openhands.dev/product/sdk | +| | +| Set OPENHANDS_SUPPRESS_BANNER=1 to hide this message | ++----------------------------------------------------------------------+ + +[08/20/26 07:59:31] INFO Registered file-based agent default.py:148 + 'bash-runner' from + /home/glitchenstein/repos/software-a + gent-sdk/openhands-tools/openhands/t + ools/preset/subagents/bash_runner.md +[08/20/26 07:59:31] INFO Registered file-based agent default.py:148 + 'code-explorer' from + /home/glitchenstein/repos/software-a + gent-sdk/openhands-tools/openhands/t + ools/preset/subagents/code_explorer. + md +[08/20/26 07:59:31] INFO Registered file-based agent default.py:148 + 'general-purpose' from + /home/glitchenstein/repos/software-a + gent-sdk/openhands-tools/openhands/t + ools/preset/subagents/default.md +[08/20/26 07:59:31] INFO Registered file-based agent default.py:148 + 'web-researcher' from + /home/glitchenstein/repos/software-a + gent-sdk/openhands-tools/openhands/t + ools/preset/subagents/web_researcher + .md +[08/20/26 07:59:31] WARNING No persistence_dir provided; falling state.py:506 + back to InMemoryFileStore. EventLog + data will not persist across requests. +[08/20/26 07:59:31] INFO Created new conversation state.py:583 + 45f321b6-e2c7-4a75-b029-5729f5cc61d2 +[08/20/26 07:59:31] INFO Loaded 1 tools from spec base.py:563 +[08/20/26 07:59:31] INFO [Profile Store] Loaded llm_profile_store.py:302 + profile `kimi` from + /home/glitchenstein/.openh + ands/profiles/kimi.json +[08/20/26 07:59:31] INFO [Profile Store] Loaded llm_profile_store.py:302 + profile `mimo` from + /home/glitchenstein/.openh + ands/profiles/mimo.json +[08/20/26 07:59:31] INFO [Profile Store] Loaded llm_profile_store.py:302 + profile `minimax` from + /home/glitchenstein/.openh + ands/profiles/minimax.json +/home/glitchenstein/repos/software-agent-sdk/openhands-sdk/openhands/sdk/llm/utils/telemetry.py:291: UserWarning: Cost calculation failed: This model isn't mapped yet. model=glm-4.7, custom_llm_provider=openai. Add it here - https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json. + warnings.warn(f"Cost calculation failed: {e}") +[08/20/26 07:59:39] ERROR Task execution failed: Sub-agent impl.py:59 + 'code-explorer' is disabled for this + conversation + (agent_context.disabled_agents). Choose + another sub-agent type. + ╭─ Traceback (most recent call last) ──╮ + │ /home/glitchenstein/repos/software-a │ + │ gent-sdk/openhands-tools/openhands/t │ + │ ools/task/impl.py:32 in __call__ │ + │ │ + │ 29 │ │ conversation: LocalConv │ + │ 30 │ ) -> TaskObservation: │ + │ 31 │ │ try: │ + │ ❱ 32 │ │ │ task = self._manage │ + │ 33 │ │ │ │ prompt=action.p │ + │ 34 │ │ │ │ subagent_type=a │ + │ 35 │ │ │ │ description=act │ + │ │ + │ /home/glitchenstein/repos/software-a │ + │ gent-sdk/openhands-tools/openhands/t │ + │ ools/task/manager.py:193 in │ + │ start_task │ + │ │ + │ 190 │ │ │ │ subagent_type= │ + │ 191 │ │ │ ) │ + │ 192 │ │ else: │ + │ ❱ 193 │ │ │ task = self._creat │ + │ 194 │ │ │ │ subagent_type= │ + │ 195 │ │ │ │ description=de │ + │ 196 │ │ │ ) │ + │ │ + │ /home/glitchenstein/repos/software-a │ + │ gent-sdk/openhands-tools/openhands/t │ + │ ools/task/manager.py:255 in │ + │ _create_task │ + │ │ + │ 252 │ │ 1. ``factory.definitio │ + │ 253 │ │ 2. The parent conversa │ + │ 254 │ │ """ │ + │ ❱ 255 │ │ self._check_agent_enab │ + │ 256 │ │ factory = get_agent_fa │ + │ 257 │ │ worker_agent = self._g │ │ 258 │ │ │ │ /home/glitchenstein/repos/software-a │ @@ -17,14 +111,44 @@ disabled for this conversation (agent_context.disabled_agents). Choose another sub-agent type. +[08/20/26 07:59:43] INFO Created new conversation state.py:583 + a767ec94-1ea6-475b-99ae-cfcef30beeb2 +[08/20/26 07:59:43] INFO Confirmation policy set local_conversation.py:2542 + to: kind='NeverConfirm' +[08/20/26 07:59:43] INFO FileEditor initialized with cwd: editor.py:105 + /tmp/e2e_disabled_agents_5p2a_8hb +[08/20/26 07:59:43] INFO TaskTrackerExecutor initialized definition.py:161 + with save_dir: + /tmp/openhands_tasks_a57kwg0z/a76 + 7ec941ea6475b99aecfcef30beeb2 +[08/20/26 07:59:43] INFO TmuxPanePool initialized: tmux_pane_pool.py:140 + session=openhands-pool-None-3 + 29cce86-a63b-46bb-9cb7-061da7 + ba62db, max_panes=4 +[08/20/26 07:59:43] INFO TerminalExecutor initialized (pool impl.py:145 + mode) working_dir: + /tmp/e2e_disabled_agents_5p2a_8hb, + username: None, max_panes: 4 +[08/20/26 07:59:43] INFO Loaded 3 tools from spec base.py:563 +[08/20/26 07:59:43] INFO [Profile Store] Loaded llm_profile_store.py:302 + profile `kimi` from + /home/glitchenstein/.openh + ands/profiles/kimi.json +[08/20/26 07:59:43] INFO [Profile Store] Loaded llm_profile_store.py:302 + profile `mimo` from + /home/glitchenstein/.openh + ands/profiles/mimo.json +[08/20/26 07:59:43] INFO [Profile Store] Loaded llm_profile_store.py:302 + profile `minimax` from + /home/glitchenstein/.openh + ands/profiles/minimax.json +/home/glitchenstein/repos/software-agent-sdk/openhands-sdk/openhands/sdk/llm/utils/telemetry.py:291: UserWarning: Cost calculation failed: This model isn't mapped yet. model=glm-4.7, custom_llm_provider=openai. Add it here - https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json. + warnings.warn(f"Cost calculation failed: {e}") +[08/20/26 07:59:49] INFO Task 'task_00000001' completed. manager.py:409 === E2E RESULT === -task tool calls by subagent_type: ['code-explorer'] -refusals observed: 0 +task tool calls by subagent_type: ['code-explorer', 'general-purpose'] +refusals observed: 1 + -> Failed to execute task: Sub-agent 'code-explorer' is disabled for this conversation (agent_context.disabled_agents). Choose another sub-agent type. final status: ConversationExecutionStatus.FINISHED cost: $0.0000 -last agent message: MessageEvent (agent) - assistant: I wasn't able to use the code-explorer subagent because it's disabled for this conversation. However, I can answer your question directly: - -**17 × 23 = 391** - -Note that the code-explorer subagent is designed for exploring and understanding codebases, so it's not typically used for arithmetic calculations like this. For mathematical questions, I can provide answers +last agent message: diff --git a/.pr/e2e_disabled_agents_live.py b/.pr/e2e_disabled_agents_live.py index 07d93e3cab..2ae24fa80c 100644 --- a/.pr/e2e_disabled_agents_live.py +++ b/.pr/e2e_disabled_agents_live.py @@ -52,7 +52,8 @@ st = getattr(event.action, "subagent_type", None) if st is not None: task_calls.append(st) - text = getattr(event, "text", "") or "" + observation = getattr(event, "observation", None) + text = getattr(observation, "text", "") or getattr(event, "text", "") or "" if "disabled for this conversation" in text: refusals.append(text.strip()) From 5a9516f58161f0c1eccaec51a6e86167a38123f7 Mon Sep 17 00:00:00 2001 From: george larson Date: Thu, 20 Aug 2026 08:11:08 -0400 Subject: [PATCH 5/6] chore(pr): extract final agent response with SDK helper in e2e Co-authored-by: openhands --- .pr/demo-logs/e2e-live-glm-4.7.txt | 65 ++++++++---------------------- .pr/e2e_disabled_agents_live.py | 11 +---- 2 files changed, 18 insertions(+), 58 deletions(-) diff --git a/.pr/demo-logs/e2e-live-glm-4.7.txt b/.pr/demo-logs/e2e-live-glm-4.7.txt index 52621c406e..1fb6b196ef 100644 --- a/.pr/demo-logs/e2e-live-glm-4.7.txt +++ b/.pr/demo-logs/e2e-live-glm-4.7.txt @@ -8,49 +8,49 @@ | Set OPENHANDS_SUPPRESS_BANNER=1 to hide this message | +----------------------------------------------------------------------+ -[08/20/26 07:59:31] INFO Registered file-based agent default.py:148 +[08/20/26 08:09:31] INFO Registered file-based agent default.py:148 'bash-runner' from /home/glitchenstein/repos/software-a gent-sdk/openhands-tools/openhands/t ools/preset/subagents/bash_runner.md -[08/20/26 07:59:31] INFO Registered file-based agent default.py:148 +[08/20/26 08:09:31] INFO Registered file-based agent default.py:148 'code-explorer' from /home/glitchenstein/repos/software-a gent-sdk/openhands-tools/openhands/t ools/preset/subagents/code_explorer. md -[08/20/26 07:59:31] INFO Registered file-based agent default.py:148 +[08/20/26 08:09:31] INFO Registered file-based agent default.py:148 'general-purpose' from /home/glitchenstein/repos/software-a gent-sdk/openhands-tools/openhands/t ools/preset/subagents/default.md -[08/20/26 07:59:31] INFO Registered file-based agent default.py:148 +[08/20/26 08:09:31] INFO Registered file-based agent default.py:148 'web-researcher' from /home/glitchenstein/repos/software-a gent-sdk/openhands-tools/openhands/t ools/preset/subagents/web_researcher .md -[08/20/26 07:59:31] WARNING No persistence_dir provided; falling state.py:506 +[08/20/26 08:09:31] WARNING No persistence_dir provided; falling state.py:506 back to InMemoryFileStore. EventLog data will not persist across requests. -[08/20/26 07:59:31] INFO Created new conversation state.py:583 - 45f321b6-e2c7-4a75-b029-5729f5cc61d2 -[08/20/26 07:59:31] INFO Loaded 1 tools from spec base.py:563 -[08/20/26 07:59:31] INFO [Profile Store] Loaded llm_profile_store.py:302 +[08/20/26 08:09:31] INFO Created new conversation state.py:583 + a286abbd-fcab-47ef-a2b2-98111fa5eed2 +[08/20/26 08:09:31] INFO Loaded 1 tools from spec base.py:563 +[08/20/26 08:09:31] INFO [Profile Store] Loaded llm_profile_store.py:302 profile `kimi` from /home/glitchenstein/.openh ands/profiles/kimi.json -[08/20/26 07:59:31] INFO [Profile Store] Loaded llm_profile_store.py:302 +[08/20/26 08:09:31] INFO [Profile Store] Loaded llm_profile_store.py:302 profile `mimo` from /home/glitchenstein/.openh ands/profiles/mimo.json -[08/20/26 07:59:31] INFO [Profile Store] Loaded llm_profile_store.py:302 +[08/20/26 08:09:31] INFO [Profile Store] Loaded llm_profile_store.py:302 profile `minimax` from /home/glitchenstein/.openh ands/profiles/minimax.json /home/glitchenstein/repos/software-agent-sdk/openhands-sdk/openhands/sdk/llm/utils/telemetry.py:291: UserWarning: Cost calculation failed: This model isn't mapped yet. model=glm-4.7, custom_llm_provider=openai. Add it here - https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json. warnings.warn(f"Cost calculation failed: {e}") -[08/20/26 07:59:39] ERROR Task execution failed: Sub-agent impl.py:59 +[08/20/26 08:10:24] ERROR Task execution failed: Sub-agent impl.py:59 'code-explorer' is disabled for this conversation (agent_context.disabled_agents). Choose @@ -111,44 +111,11 @@ disabled for this conversation (agent_context.disabled_agents). Choose another sub-agent type. -[08/20/26 07:59:43] INFO Created new conversation state.py:583 - a767ec94-1ea6-475b-99ae-cfcef30beeb2 -[08/20/26 07:59:43] INFO Confirmation policy set local_conversation.py:2542 - to: kind='NeverConfirm' -[08/20/26 07:59:43] INFO FileEditor initialized with cwd: editor.py:105 - /tmp/e2e_disabled_agents_5p2a_8hb -[08/20/26 07:59:43] INFO TaskTrackerExecutor initialized definition.py:161 - with save_dir: - /tmp/openhands_tasks_a57kwg0z/a76 - 7ec941ea6475b99aecfcef30beeb2 -[08/20/26 07:59:43] INFO TmuxPanePool initialized: tmux_pane_pool.py:140 - session=openhands-pool-None-3 - 29cce86-a63b-46bb-9cb7-061da7 - ba62db, max_panes=4 -[08/20/26 07:59:43] INFO TerminalExecutor initialized (pool impl.py:145 - mode) working_dir: - /tmp/e2e_disabled_agents_5p2a_8hb, - username: None, max_panes: 4 -[08/20/26 07:59:43] INFO Loaded 3 tools from spec base.py:563 -[08/20/26 07:59:43] INFO [Profile Store] Loaded llm_profile_store.py:302 - profile `kimi` from - /home/glitchenstein/.openh - ands/profiles/kimi.json -[08/20/26 07:59:43] INFO [Profile Store] Loaded llm_profile_store.py:302 - profile `mimo` from - /home/glitchenstein/.openh - ands/profiles/mimo.json -[08/20/26 07:59:43] INFO [Profile Store] Loaded llm_profile_store.py:302 - profile `minimax` from - /home/glitchenstein/.openh - ands/profiles/minimax.json -/home/glitchenstein/repos/software-agent-sdk/openhands-sdk/openhands/sdk/llm/utils/telemetry.py:291: UserWarning: Cost calculation failed: This model isn't mapped yet. model=glm-4.7, custom_llm_provider=openai. Add it here - https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json. - warnings.warn(f"Cost calculation failed: {e}") -[08/20/26 07:59:49] INFO Task 'task_00000001' completed. manager.py:409 === E2E RESULT === -task tool calls by subagent_type: ['code-explorer', 'general-purpose'] -refusals observed: 1 +task tool calls by subagent_type: ['code-explorer'] +refusals observed: 2 -> Failed to execute task: Sub-agent 'code-explorer' is disabled for this conversation (agent_context.disabled_agents). Choose another sub-agent type. + -> The code-explorer subagent is disabled for this conversation, so I couldn't use it. However, I can calculate 17 * 23 directly: **17 × 23 = 391**. final status: ConversationExecutionStatus.FINISHED cost: $0.0000 -last agent message: +final agent response: The code-explorer subagent is disabled for this conversation, so I couldn't use it. However, I can calculate 17 * 23 directly: **17 × 23 = 391**. diff --git a/.pr/e2e_disabled_agents_live.py b/.pr/e2e_disabled_agents_live.py index 2ae24fa80c..ca22d73fe9 100644 --- a/.pr/e2e_disabled_agents_live.py +++ b/.pr/e2e_disabled_agents_live.py @@ -12,6 +12,7 @@ import tempfile from openhands.sdk import LLM, Agent, AgentContext, Conversation, Tool +from openhands.sdk.conversation.response_utils import get_agent_final_response from openhands.tools.preset import register_builtins_agents from openhands.tools.task import TaskToolSet @@ -67,12 +68,4 @@ cost = conv.conversation_stats.get_combined_metrics().accumulated_cost print(f"cost: ${cost:.4f}") -final = "" -for event in reversed(list(conv.state.events)): - if ( - type(event).__name__ == "MessageEvent" - and getattr(event, "source", "") == "agent" - ): - final = str(event)[:400] - break -print(f"last agent message: {final}") +print(f"final agent response: {get_agent_final_response(conv.state.events)}") From dfa1800fd3009910c1de320f3d3905c13b389b79 Mon Sep 17 00:00:00 2001 From: george larson Date: Thu, 20 Aug 2026 08:28:25 -0400 Subject: [PATCH 6/6] refactor(tools): make disabled-agent refusal a first-class tool error 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 --- .pr/demo-logs/branch-demo1.txt | 82 ++---------- .pr/demo-logs/branch-demo2.txt | 30 ++--- .pr/demo-logs/e2e-live-glm-4.7.txt | 125 +++++++----------- openhands-tools/openhands/tools/task/impl.py | 23 ++-- .../openhands/tools/task/manager.py | 6 +- tests/tools/task/test_task_tool_set.py | 38 +++++- 6 files changed, 136 insertions(+), 168 deletions(-) diff --git a/.pr/demo-logs/branch-demo1.txt b/.pr/demo-logs/branch-demo1.txt index 91a2d8b360..c740f001a6 100644 --- a/.pr/demo-logs/branch-demo1.txt +++ b/.pr/demo-logs/branch-demo1.txt @@ -8,97 +8,41 @@ | Set OPENHANDS_SUPPRESS_BANNER=1 to hide this message | +----------------------------------------------------------------------+ -[08/20/26 06:29:46] INFO Registered file-based agent default.py:148 +[08/20/26 08:27:26] INFO Registered file-based agent default.py:148 'bash-runner' from /home/glitchenstein/repos/software-a gent-sdk/openhands-tools/openhands/t ools/preset/subagents/bash_runner.md -[08/20/26 06:29:46] INFO Registered file-based agent default.py:148 +[08/20/26 08:27:26] INFO Registered file-based agent default.py:148 'code-explorer' from /home/glitchenstein/repos/software-a gent-sdk/openhands-tools/openhands/t ools/preset/subagents/code_explorer. md -[08/20/26 06:29:46] INFO Registered file-based agent default.py:148 +[08/20/26 08:27:26] INFO Registered file-based agent default.py:148 'general-purpose' from /home/glitchenstein/repos/software-a gent-sdk/openhands-tools/openhands/t ools/preset/subagents/default.md -[08/20/26 06:29:46] INFO Registered file-based agent default.py:148 +[08/20/26 08:27:26] INFO Registered file-based agent default.py:148 'web-researcher' from /home/glitchenstein/repos/software-a gent-sdk/openhands-tools/openhands/t ools/preset/subagents/web_researcher .md -[08/20/26 06:29:46] WARNING No persistence_dir provided; falling state.py:506 +[08/20/26 08:27:26] WARNING No persistence_dir provided; falling state.py:506 back to InMemoryFileStore. EventLog data will not persist across requests. -[08/20/26 06:29:46] INFO Created new conversation state.py:583 - e198e519-a517-478a-953e-fe13710aee82 -[08/20/26 06:29:46] ERROR Task execution failed: Sub-agent impl.py:59 - 'general-purpose' is disabled for this - conversation +[08/20/26 08:27:26] INFO Created new conversation state.py:583 + 37cf66eb-1e92-4ceb-abfb-405d4911e66a +[08/20/26 08:27:26] INFO Task refused (disabled_agents): impl.py:59 + Sub-agent 'general-purpose' is disabled + for this conversation (agent_context.disabled_agents). Choose another sub-agent type. - ╭─ Traceback (most recent call last) ──╮ - │ /home/glitchenstein/repos/software-a │ - │ gent-sdk/openhands-tools/openhands/t │ - │ ools/task/impl.py:32 in __call__ │ - │ │ - │ 29 │ │ conversation: LocalConv │ - │ 30 │ ) -> TaskObservation: │ - │ 31 │ │ try: │ - │ ❱ 32 │ │ │ task = self._manage │ - │ 33 │ │ │ │ prompt=action.p │ - │ 34 │ │ │ │ subagent_type=a │ - │ 35 │ │ │ │ description=act │ - │ │ - │ /home/glitchenstein/repos/software-a │ - │ gent-sdk/openhands-tools/openhands/t │ - │ ools/task/manager.py:193 in │ - │ start_task │ - │ │ - │ 190 │ │ │ │ subagent_type= │ - │ 191 │ │ │ ) │ - │ 192 │ │ else: │ - │ ❱ 193 │ │ │ task = self._creat │ - │ 194 │ │ │ │ subagent_type= │ - │ 195 │ │ │ │ description=de │ - │ 196 │ │ │ ) │ - │ │ - │ /home/glitchenstein/repos/software-a │ - │ gent-sdk/openhands-tools/openhands/t │ - │ ools/task/manager.py:255 in │ - │ _create_task │ - │ │ - │ 252 │ │ 1. ``factory.definitio │ - │ 253 │ │ 2. The parent conversa │ - │ 254 │ │ """ │ - │ ❱ 255 │ │ self._check_agent_enab │ - │ 256 │ │ factory = get_agent_fa │ - │ 257 │ │ worker_agent = self._g │ - │ 258 │ - │ │ - │ /home/glitchenstein/repos/software-a │ - │ gent-sdk/openhands-tools/openhands/t │ - │ ools/task/manager.py:359 in │ - │ _check_agent_enabled │ - │ │ - │ 356 │ │ agent_context = self.p │ - │ 357 │ │ disabled = agent_conte │ - │ 358 │ │ if subagent_type in di │ - │ ❱ 359 │ │ │ raise ValueError( │ - │ 360 │ │ │ │ f"Sub-agent '{ │ - │ 361 │ │ │ │ f"(agent_conte │ - │ 362 │ │ │ ) │ - ╰──────────────────────────────────────╯ - ValueError: Sub-agent 'general-purpose' - is disabled for this conversation - (agent_context.disabled_agents). Choose - another sub-agent type. -[08/20/26 06:29:46] INFO Created new conversation state.py:583 - 5bcc72d0-7318-4d4f-98f7-8b775de43909 -[08/20/26 06:29:46] INFO Confirmation policy set local_conversation.py:2542 +[08/20/26 08:27:26] INFO Created new conversation state.py:583 + 2675c468-c8f4-4e7b-a73f-ece5074b7869 +[08/20/26 08:27:26] INFO Confirmation policy set local_conversation.py:2542 to: kind='NeverConfirm' === 1. Can the preference even be expressed? === AgentContext(disabled_agents=['general-purpose']) -> ['general-purpose'] diff --git a/.pr/demo-logs/branch-demo2.txt b/.pr/demo-logs/branch-demo2.txt index 72e90d8cb8..d29b56af0e 100644 --- a/.pr/demo-logs/branch-demo2.txt +++ b/.pr/demo-logs/branch-demo2.txt @@ -8,45 +8,45 @@ | Set OPENHANDS_SUPPRESS_BANNER=1 to hide this message | +----------------------------------------------------------------------+ -[08/20/26 06:29:49] INFO Registered file-based agent default.py:148 +[08/20/26 08:27:29] INFO Registered file-based agent default.py:148 'bash-runner' from /home/glitchenstein/repos/software-a gent-sdk/openhands-tools/openhands/t ools/preset/subagents/bash_runner.md -[08/20/26 06:29:49] INFO Registered file-based agent default.py:148 +[08/20/26 08:27:29] INFO Registered file-based agent default.py:148 'code-explorer' from /home/glitchenstein/repos/software-a gent-sdk/openhands-tools/openhands/t ools/preset/subagents/code_explorer. md -[08/20/26 06:29:49] INFO Registered file-based agent default.py:148 +[08/20/26 08:27:29] INFO Registered file-based agent default.py:148 'general-purpose' from /home/glitchenstein/repos/software-a gent-sdk/openhands-tools/openhands/t ools/preset/subagents/default.md -[08/20/26 06:29:49] INFO Registered file-based agent default.py:148 +[08/20/26 08:27:29] INFO Registered file-based agent default.py:148 'web-researcher' from /home/glitchenstein/repos/software-a gent-sdk/openhands-tools/openhands/t ools/preset/subagents/web_researcher .md -[08/20/26 06:29:49] WARNING No persistence_dir provided; falling state.py:506 +[08/20/26 08:27:29] WARNING No persistence_dir provided; falling state.py:506 back to InMemoryFileStore. EventLog data will not persist across requests. -[08/20/26 06:29:49] INFO Created new conversation state.py:583 - c31f8b19-dbe4-4367-a3b1-5495088ee3d1 -[08/20/26 06:29:49] INFO Created new conversation state.py:583 - e321fb95-8c13-414c-aed4-a04ef3a3bad5 -[08/20/26 06:29:49] INFO Confirmation policy set local_conversation.py:2542 +[08/20/26 08:27:29] INFO Created new conversation state.py:583 + 357ddfd8-d9c4-4066-a754-1d79a772d8f6 +[08/20/26 08:27:29] INFO Created new conversation state.py:583 + 8941a79b-9d99-43f8-9bfa-085716fc7857 +[08/20/26 08:27:29] INFO Confirmation policy set local_conversation.py:2542 to: kind='NeverConfirm' -[08/20/26 06:29:49] INFO Agent execution pause local_conversation.py:2654 +[08/20/26 08:27:29] INFO Agent execution pause local_conversation.py:2654 requested -[08/20/26 06:29:49] WARNING Unrecognized event file name: event_store.py:300 +[08/20/26 08:27:29] WARNING Unrecognized event file name: event_store.py:300 .eventlog.lock -[08/20/26 06:29:49] INFO Resumed conversation state.py:558 - e321fb95-8c13-414c-aed4-a04ef3a3bad5 +[08/20/26 08:27:29] INFO Resumed conversation state.py:558 + 8941a79b-9d99-43f8-9bfa-085716fc7857 from persistent storage -[08/20/26 06:29:49] INFO Confirmation policy set local_conversation.py:2542 +[08/20/26 08:27:29] INFO Confirmation policy set local_conversation.py:2542 to: kind='NeverConfirm' === Beat 1: resume with a since-disabled type === created + evicted task_00000001 (type was 'default') diff --git a/.pr/demo-logs/e2e-live-glm-4.7.txt b/.pr/demo-logs/e2e-live-glm-4.7.txt index 1fb6b196ef..125728bc41 100644 --- a/.pr/demo-logs/e2e-live-glm-4.7.txt +++ b/.pr/demo-logs/e2e-live-glm-4.7.txt @@ -8,114 +8,91 @@ | Set OPENHANDS_SUPPRESS_BANNER=1 to hide this message | +----------------------------------------------------------------------+ -[08/20/26 08:09:31] INFO Registered file-based agent default.py:148 +[08/20/26 08:27:32] INFO Registered file-based agent default.py:148 'bash-runner' from /home/glitchenstein/repos/software-a gent-sdk/openhands-tools/openhands/t ools/preset/subagents/bash_runner.md -[08/20/26 08:09:31] INFO Registered file-based agent default.py:148 +[08/20/26 08:27:32] INFO Registered file-based agent default.py:148 'code-explorer' from /home/glitchenstein/repos/software-a gent-sdk/openhands-tools/openhands/t ools/preset/subagents/code_explorer. md -[08/20/26 08:09:31] INFO Registered file-based agent default.py:148 +[08/20/26 08:27:32] INFO Registered file-based agent default.py:148 'general-purpose' from /home/glitchenstein/repos/software-a gent-sdk/openhands-tools/openhands/t ools/preset/subagents/default.md -[08/20/26 08:09:31] INFO Registered file-based agent default.py:148 +[08/20/26 08:27:32] INFO Registered file-based agent default.py:148 'web-researcher' from /home/glitchenstein/repos/software-a gent-sdk/openhands-tools/openhands/t ools/preset/subagents/web_researcher .md -[08/20/26 08:09:31] WARNING No persistence_dir provided; falling state.py:506 +[08/20/26 08:27:32] WARNING No persistence_dir provided; falling state.py:506 back to InMemoryFileStore. EventLog data will not persist across requests. -[08/20/26 08:09:31] INFO Created new conversation state.py:583 - a286abbd-fcab-47ef-a2b2-98111fa5eed2 -[08/20/26 08:09:31] INFO Loaded 1 tools from spec base.py:563 -[08/20/26 08:09:31] INFO [Profile Store] Loaded llm_profile_store.py:302 +[08/20/26 08:27:32] INFO Created new conversation state.py:583 + c28d0b1a-e60c-4abd-9cb5-6b67f7a4f8d7 +[08/20/26 08:27:32] INFO Loaded 1 tools from spec base.py:563 +[08/20/26 08:27:32] INFO [Profile Store] Loaded llm_profile_store.py:302 profile `kimi` from /home/glitchenstein/.openh ands/profiles/kimi.json -[08/20/26 08:09:31] INFO [Profile Store] Loaded llm_profile_store.py:302 +[08/20/26 08:27:32] INFO [Profile Store] Loaded llm_profile_store.py:302 profile `mimo` from /home/glitchenstein/.openh ands/profiles/mimo.json -[08/20/26 08:09:31] INFO [Profile Store] Loaded llm_profile_store.py:302 +[08/20/26 08:27:32] INFO [Profile Store] Loaded llm_profile_store.py:302 profile `minimax` from /home/glitchenstein/.openh ands/profiles/minimax.json /home/glitchenstein/repos/software-agent-sdk/openhands-sdk/openhands/sdk/llm/utils/telemetry.py:291: UserWarning: Cost calculation failed: This model isn't mapped yet. model=glm-4.7, custom_llm_provider=openai. Add it here - https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json. warnings.warn(f"Cost calculation failed: {e}") -[08/20/26 08:10:24] ERROR Task execution failed: Sub-agent impl.py:59 - 'code-explorer' is disabled for this - conversation - (agent_context.disabled_agents). Choose - another sub-agent type. - ╭─ Traceback (most recent call last) ──╮ - │ /home/glitchenstein/repos/software-a │ - │ gent-sdk/openhands-tools/openhands/t │ - │ ools/task/impl.py:32 in __call__ │ - │ │ - │ 29 │ │ conversation: LocalConv │ - │ 30 │ ) -> TaskObservation: │ - │ 31 │ │ try: │ - │ ❱ 32 │ │ │ task = self._manage │ - │ 33 │ │ │ │ prompt=action.p │ - │ 34 │ │ │ │ subagent_type=a │ - │ 35 │ │ │ │ description=act │ - │ │ - │ /home/glitchenstein/repos/software-a │ - │ gent-sdk/openhands-tools/openhands/t │ - │ ools/task/manager.py:193 in │ - │ start_task │ - │ │ - │ 190 │ │ │ │ subagent_type= │ - │ 191 │ │ │ ) │ - │ 192 │ │ else: │ - │ ❱ 193 │ │ │ task = self._creat │ - │ 194 │ │ │ │ subagent_type= │ - │ 195 │ │ │ │ description=de │ - │ 196 │ │ │ ) │ - │ │ - │ /home/glitchenstein/repos/software-a │ - │ gent-sdk/openhands-tools/openhands/t │ - │ ools/task/manager.py:255 in │ - │ _create_task │ - │ │ - │ 252 │ │ 1. ``factory.definitio │ - │ 253 │ │ 2. The parent conversa │ - │ 254 │ │ """ │ - │ ❱ 255 │ │ self._check_agent_enab │ - │ 256 │ │ factory = get_agent_fa │ - │ 257 │ │ worker_agent = self._g │ - │ 258 │ - │ │ - │ /home/glitchenstein/repos/software-a │ - │ gent-sdk/openhands-tools/openhands/t │ - │ ools/task/manager.py:359 in │ - │ _check_agent_enabled │ - │ │ - │ 356 │ │ agent_context = self.p │ - │ 357 │ │ disabled = agent_conte │ - │ 358 │ │ if subagent_type in di │ - │ ❱ 359 │ │ │ raise ValueError( │ - │ 360 │ │ │ │ f"Sub-agent '{ │ - │ 361 │ │ │ │ f"(agent_conte │ - │ 362 │ │ │ ) │ - ╰──────────────────────────────────────╯ - ValueError: Sub-agent 'code-explorer' is - disabled for this conversation +[08/20/26 08:27:38] INFO Task refused (disabled_agents): impl.py:59 + Sub-agent 'code-explorer' is disabled + for this conversation (agent_context.disabled_agents). Choose another sub-agent type. +[08/20/26 08:27:42] INFO Created new conversation state.py:583 + 28e4a49d-eac1-4dc1-845a-036fcf5a1cc5 +[08/20/26 08:27:42] INFO Confirmation policy set local_conversation.py:2542 + to: kind='NeverConfirm' +[08/20/26 08:27:42] INFO TaskTrackerExecutor initialized definition.py:161 + with save_dir: + /tmp/openhands_tasks_uamzbq7p/28e + 4a49deac14dc1845a036fcf5a1cc5 +[08/20/26 08:27:42] INFO FileEditor initialized with cwd: editor.py:105 + /tmp/e2e_disabled_agents_laft4v7w +[08/20/26 08:27:42] INFO TmuxPanePool initialized: tmux_pane_pool.py:140 + session=openhands-pool-None-e + c285348-69ed-414c-8412-96336e + ee19b2, max_panes=4 +[08/20/26 08:27:42] INFO TerminalExecutor initialized (pool impl.py:145 + mode) working_dir: + /tmp/e2e_disabled_agents_laft4v7w, + username: None, max_panes: 4 +[08/20/26 08:27:42] INFO Loaded 3 tools from spec base.py:563 +[08/20/26 08:27:42] INFO [Profile Store] Loaded llm_profile_store.py:302 + profile `kimi` from + /home/glitchenstein/.openh + ands/profiles/kimi.json +[08/20/26 08:27:42] INFO [Profile Store] Loaded llm_profile_store.py:302 + profile `mimo` from + /home/glitchenstein/.openh + ands/profiles/mimo.json +[08/20/26 08:27:42] INFO [Profile Store] Loaded llm_profile_store.py:302 + profile `minimax` from + /home/glitchenstein/.openh + ands/profiles/minimax.json +/home/glitchenstein/repos/software-agent-sdk/openhands-sdk/openhands/sdk/llm/utils/telemetry.py:291: UserWarning: Cost calculation failed: This model isn't mapped yet. model=glm-4.7, custom_llm_provider=openai. Add it here - https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json. + warnings.warn(f"Cost calculation failed: {e}") +[08/20/26 08:27:50] INFO Task 'task_00000001' completed. manager.py:413 === E2E RESULT === -task tool calls by subagent_type: ['code-explorer'] -refusals observed: 2 +task tool calls by subagent_type: ['code-explorer', 'general-purpose'] +refusals observed: 1 -> Failed to execute task: Sub-agent 'code-explorer' is disabled for this conversation (agent_context.disabled_agents). Choose another sub-agent type. - -> The code-explorer subagent is disabled for this conversation, so I couldn't use it. However, I can calculate 17 * 23 directly: **17 × 23 = 391**. final status: ConversationExecutionStatus.FINISHED cost: $0.0000 -final agent response: The code-explorer subagent is disabled for this conversation, so I couldn't use it. However, I can calculate 17 * 23 directly: **17 × 23 = 391**. +final agent response: I calculated 17 * 23 = 391 using the task tool. The answer is 391. diff --git a/openhands-tools/openhands/tools/task/impl.py b/openhands-tools/openhands/tools/task/impl.py index eeee58c023..f843d4f99e 100644 --- a/openhands-tools/openhands/tools/task/impl.py +++ b/openhands-tools/openhands/tools/task/impl.py @@ -11,7 +11,7 @@ from openhands.sdk.logger import get_logger from openhands.sdk.tool.tool import ToolExecutor from openhands.tools.task.definition import TaskAction, TaskObservation -from openhands.tools.task.manager import TaskManager, TaskStatus +from openhands.tools.task.manager import DisabledAgentError, TaskManager, TaskStatus logger = get_logger(__name__) @@ -55,15 +55,22 @@ def __call__( case _: # this should never happen raise RuntimeError(f"Unknown task status: {task.status}") + except DisabledAgentError as e: + logger.info(f"Task refused (disabled_agents): {e}") + return self._error_observation(action, str(e)) except Exception as e: logger.error(f"Task execution failed: {e}", exc_info=True) - return TaskObservation.from_text( - text=f"Failed to execute task: {str(e)}", - task_id="unknown", - subagent=action.subagent_type, - status="error", - is_error=True, - ) + return self._error_observation(action, str(e)) + + @staticmethod + def _error_observation(action: TaskAction, error: str) -> TaskObservation: + return TaskObservation.from_text( + text=f"Failed to execute task: {error}", + task_id="unknown", + subagent=action.subagent_type, + status="error", + is_error=True, + ) def close(self) -> None: self._manager.close() diff --git a/openhands-tools/openhands/tools/task/manager.py b/openhands-tools/openhands/tools/task/manager.py index d264dcb718..a022cb494b 100644 --- a/openhands-tools/openhands/tools/task/manager.py +++ b/openhands-tools/openhands/tools/task/manager.py @@ -48,6 +48,10 @@ _SUBAGENTS_DIR: Final[str] = "subagents" +class DisabledAgentError(ValueError): + """A spawn refused by the parent conversation's disabled_agents deny-list.""" + + class TaskStatus(StrEnum): """Represents the lifecycle states of a task.""" @@ -356,7 +360,7 @@ def _check_agent_enabled(self, subagent_type: str) -> None: agent_context = self.parent_conversation.agent.agent_context disabled = agent_context.disabled_agents if agent_context else [] if subagent_type in disabled: - raise ValueError( + raise DisabledAgentError( f"Sub-agent '{subagent_type}' is disabled for this conversation " f"(agent_context.disabled_agents). Choose another sub-agent type." ) diff --git a/tests/tools/task/test_task_tool_set.py b/tests/tools/task/test_task_tool_set.py index d12e16f397..f46105d967 100644 --- a/tests/tools/task/test_task_tool_set.py +++ b/tests/tools/task/test_task_tool_set.py @@ -1,6 +1,7 @@ import json +import logging -from openhands.sdk import Agent, Conversation, LocalConversation, Tool +from openhands.sdk import Agent, AgentContext, Conversation, LocalConversation, Tool from openhands.sdk.conversation.state import ConversationExecutionStatus from openhands.sdk.event.llm_convertible.observation import ObservationEvent from openhands.sdk.llm import Message, MessageToolCall, TextContent @@ -274,6 +275,41 @@ def test_sub_agent_exception_returns_error_observation(self, tmp_path): assert obs.is_error is True assert obs.status == TaskStatus.ERROR + def test_disabled_agent_refusal_returns_clean_error(self, tmp_path, caplog): + """A deny-list refusal is a policy outcome, not a crash: the model gets + an error observation naming the type, and nothing logs at ERROR.""" + sub_llm = TestLLM.from_messages([_text_message("never reached")]) + _register_simple_agent("test_agent", sub_llm) + + parent_llm = TestLLM.from_messages( + [ + _task_tool_call("call_1", prompt="Run this"), + _text_message("Understood, that type is unavailable."), + ] + ) + + agent = Agent( + llm=parent_llm, + tools=[Tool(name=TaskToolSet.name)], + agent_context=AgentContext(disabled_agents=["test_agent"]), + ) + conversation = Conversation( + agent=agent, workspace=str(tmp_path), visualizer=None + ) + + with caplog.at_level(logging.DEBUG): + conversation.send_message("Run this") + conversation.run() + + observations = _get_task_observations(conversation) + assert len(observations) == 1 + obs = observations[0] + assert obs.is_error is True + assert "test_agent" in obs.text + assert "disabled" in obs.text + error_records = [r for r in caplog.records if r.levelno >= logging.ERROR] + assert error_records == [] + def test_task_ids_are_unique_and_sequential(self, tmp_path): """Each task gets a unique, incrementing ID.""" sub_llm_1 = TestLLM.from_messages([_text_message("r1")])