diff --git a/.pr/demo-logs/branch-demo1.txt b/.pr/demo-logs/branch-demo1.txt new file mode 100644 index 0000000000..c740f001a6 --- /dev/null +++ b/.pr/demo-logs/branch-demo1.txt @@ -0,0 +1,62 @@ ++----------------------------------------------------------------------+ +| 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 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 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 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 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 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 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. +[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'] + +=== 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..d29b56af0e --- /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 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 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 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 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 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 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 08:27:29] INFO Agent execution pause local_conversation.py:2654 + requested +[08/20/26 08:27:29] WARNING Unrecognized event file name: event_store.py:300 + .eventlog.lock +[08/20/26 08:27:29] INFO Resumed conversation state.py:558 + 8941a79b-9d99-43f8-9bfa-085716fc7857 + from persistent storage +[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') +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/e2e-live-glm-4.7.txt b/.pr/demo-logs/e2e-live-glm-4.7.txt new file mode 100644 index 0000000000..125728bc41 --- /dev/null +++ b/.pr/demo-logs/e2e-live-glm-4.7.txt @@ -0,0 +1,98 @@ ++----------------------------------------------------------------------+ +| 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 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: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: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: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: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: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: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: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: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', '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 +final agent response: I calculated 17 * 23 = 391 using the task tool. The answer is 391. 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}") diff --git a/.pr/e2e_disabled_agents_live.py b/.pr/e2e_disabled_agents_live.py new file mode 100644 index 0000000000..ca22d73fe9 --- /dev/null +++ b/.pr/e2e_disabled_agents_live.py @@ -0,0 +1,71 @@ +"""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.sdk.conversation.response_utils import get_agent_final_response +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) + 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()) + +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}") + +print(f"final agent response: {get_agent_final_response(conv.state.events)}") 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/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 a8b52a1d60..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.""" @@ -209,6 +213,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 +256,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 +355,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 DisabledAgentError( + 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) 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")])