Skip to content

Commit 48a7584

Browse files
committed
Disable todowrite for subagent.
1 parent 549cba0 commit 48a7584

9 files changed

Lines changed: 60 additions & 188 deletions

File tree

python_agent_harness/agent.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ def _execute_tool_call(self, call: ToolCall) -> str:
190190
# registry — the spec was filtered, so refuse it here too
191191
return (
192192
f"Error: {call.name} is not available to sub-agents — "
193-
"one-shot/interactive tools are parent-only"
193+
"it is a parent-only tool"
194194
)
195195
args = call.arguments
196196
if isinstance(args, str):
@@ -336,7 +336,8 @@ def safe_delta(text: str) -> None:
336336
try:
337337
# sub-agents are one-shot tasks: they must not see (or
338338
# call) parent-only tools — Agent (no nesting), Question
339-
# and PlanExit (interactive/handoff) — filtered from the
339+
# and PlanExit (interactive/handoff), TodoWrite (the
340+
# parent's own progress tracking) — filtered from the
340341
# specs before sending
341342
tools = session.tool_specs(
342343
exclude=config.SUBAGENT_EXCLUDED_TOOLS if not self.top_level else ()

python_agent_harness/agent_session.py

Lines changed: 6 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -115,13 +115,6 @@ def __init__(
115115
self.context_ratio: float | None = None
116116
self.compacting = False
117117
self.todos: list[dict] = []
118-
# scoped todo lists: "main" is the top-level agent; each running
119-
# sub-agent gets its own scope so its TodoWrite calls don't
120-
# clobber the parent's list
121-
self._todo_scopes: dict[str, list[dict]] = {"main": []}
122-
self._todo_scope_stack: list[str] = ["main"]
123-
self._todo_scope_labels: dict[str, str] = {}
124-
self._subagent_seq = 0
125118
self.pending_user_prompts: list[str] = []
126119
self._pending_execute_prompt: str | None = None
127120
self.last_messages: list = []
@@ -293,45 +286,12 @@ def record_absent(self, path: str, tool: str) -> None:
293286
self.undo.record_absent(path, tool)
294287

295288
def update_todos(self, todos: list[dict]) -> None:
296-
"""Store TODOS into the currently active scope.
297-
298-
While a sub-agent runs, its TodoWrite calls land in the
299-
sub-agent's own scope; `self.todos` always mirrors the active
300-
scope so the pinned TUI panel shows the right list.
301-
"""
302-
scope = self._todo_scope_stack[-1]
303-
self._todo_scopes[scope] = list(todos)
289+
"""Store TODOS so the pinned TUI panel shows the current list."""
304290
self.todos = list(todos)
305291
self.notify("todos")
306292

307-
@property
308-
def todo_scope_label(self) -> str | None:
309-
"""Human-readable label of the active scope (None for main)."""
310-
scope = self._todo_scope_stack[-1]
311-
return self._todo_scope_labels.get(scope)
312-
313-
def push_todo_scope(self, scope_id: str, label: str | None = None) -> None:
314-
"""Switch the active todos scope (e.g. when a sub-agent starts)."""
315-
if scope_id not in self._todo_scopes:
316-
self._todo_scopes[scope_id] = []
317-
if label:
318-
self._todo_scope_labels[scope_id] = label
319-
self._todo_scope_stack.append(scope_id)
320-
self.todos = list(self._todo_scopes[scope_id])
321-
self.notify("todos")
322-
323-
def pop_todo_scope(self) -> None:
324-
"""Restore the previous todos scope (e.g. sub-agent finished)."""
325-
if len(self._todo_scope_stack) > 1:
326-
self._todo_scope_stack.pop()
327-
self.todos = list(self._todo_scopes[self._todo_scope_stack[-1]])
328-
self.notify("todos")
329-
330293
def clear_todos(self) -> None:
331-
"""Drop all todo scopes (e.g. session cleared or restored)."""
332-
self._todo_scopes = {"main": []}
333-
self._todo_scope_stack = ["main"]
334-
self._todo_scope_labels = {}
294+
"""Drop the todo list (e.g. session cleared or restored)."""
335295
self.todos = []
336296
self.notify("todos")
337297

@@ -353,19 +313,12 @@ def _find_skill_dir(self) -> str | None:
353313
return find_skill_dir(self.project_dir, self._configured_skill_path)
354314

355315
def run_subagent(self, subagent_type: str, description: str, prompt: str) -> str:
356-
"""Run a delegated sub-agent task with an isolated todos scope.
316+
"""Run a delegated sub-agent task.
357317
358-
The sub-agent's TodoWrite calls go to its own scope (visible in
359-
the pinned TUI panel with a `sub:` label); when it finishes the
360-
parent's todo list is restored automatically.
318+
The sub-agent has no TodoWrite (parent-only), so it can never
319+
touch the parent's todo list.
361320
"""
362-
self._subagent_seq += 1
363-
scope_id = f"sub:{self._subagent_seq}"
364-
self.push_todo_scope(scope_id, description)
365-
try:
366-
return run_subagent(self, description, prompt)
367-
finally:
368-
self.pop_todo_scope()
321+
return run_subagent(self, description, prompt)
369322

370323
def plan_exit(self) -> str:
371324
"""PlanExit tool implementation.

python_agent_harness/config.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,8 +154,10 @@
154154
# Tools a sub-agent must NOT see or call: it runs autonomously as a
155155
# one-shot task inside the parent's tool round, so it cannot spawn
156156
# further sub-agents (Agent), ask the user questions (Question), nor
157-
# end in a plan/build handoff (PlanExit).
158-
SUBAGENT_EXCLUDED_TOOLS = ("Agent", "Question", "PlanExit")
157+
# end in a plan/build handoff (PlanExit). TodoWrite is also parent-only:
158+
# a sub-agent is a single delegated task — progress tracking belongs to
159+
# the parent, and the sub-agent must never clobber the parent's list.
160+
SUBAGENT_EXCLUDED_TOOLS = ("Agent", "Question", "PlanExit", "TodoWrite")
159161

160162
# ---- TUI preview limits -------------------------------------------------------
161163
TOOL_RESULT_PREVIEW_LINES = 5 # max lines of a tool result shown in the TUI

python_agent_harness/prompts/subagent.txt

Lines changed: 0 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,6 @@ You are an autonomous subagent. Your role is to independently complete well-defi
1515
- If you lack information needed to proceed, make reasonable assumptions based on context
1616

1717
# Tool usage policy
18-
- You do NOT have access to the `Agent`, `Question`, and `PlanExit`
19-
tools — they are parent-only (one-shot/interactive). Run autonomously
20-
to completion; never delegate work to further sub-agents.
2118
- You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. When making multiple bash tool calls, you MUST send a single message with multiple tools calls to run the calls in parallel. For example, if you need to run "git status" and "git diff", send a single message with two tool calls to run the calls in parallel.
2219

2320
You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.
@@ -45,54 +42,6 @@ IMPORTANT: Before you begin work, think about what the code you're editing is su
4542
- Write files → Use `Write` (NOT echo >/cat <<EOF)
4643
- System operations → Use `Bash` (for git, npm, docker, etc.)
4744

48-
<tool name="TodoWrite">
49-
You MUST create a todo list immediately when:
50-
- Task has 3+ distinct steps or phases
51-
- Task is non-trivial and benefits from planning
52-
- Task will span multiple responses or tool calls
53-
- The user provides multiple tasks (numbered or comma-separated) or explicitly asks for a todo list
54-
- New instructions arrive - capture them as todos
55-
- You start a task - mark it `in_progress` (only one at a time) before working
56-
- You finish a task - mark it `completed` and add any follow-ups discovered during the work
57-
58-
When NOT to use `TodoWrite`:
59-
- Single, straightforward tasks (or <3 trivial steps)
60-
- The request is purely informational or conversational
61-
- Tracking adds no organizational value
62-
63-
Task States:
64-
- `pending`: Task not yet started
65-
- `in_progress`: Currently working on (exactly one at a time)
66-
- `completed`: Task finished successfully
67-
68-
Rules:
69-
- Update status in real time; don't batch completions
70-
- Mark `completed` only after the required work is actually done, including any required verification. Never based on intent.
71-
- If blocked or partial, keep it `in_progress` and add a follow-up todo describing the blocker
72-
- Preserve user-provided commands verbatim (flags, args, order)
73-
- Items should be specific and actionable; break large work into smaller steps
74-
75-
How to use `TodoWrite`:
76-
- Always provide both `content` (imperative: "Run tests") and `activeForm` (present continuous: "Running tests")
77-
- Exactly ONE task must be in_progress at any time when you're executing tasks yourself
78-
- Complete current tasks before starting new ones
79-
- Send entire todo list with each call (not just changed items)
80-
- Remove tasks that are no longer relevant
81-
82-
Examples:
83-
**Use it:**
84-
- "Add a dark mode toggle and run the tests" -> multi-step feature + explicit verification
85-
- "Rename getCwd -> getCurrentWorkingDirectory across the repo" -> grep reveals 15 occurrences in 8 files
86-
- "Implement registration, catalog, cart, checkout" -> multiple complex features
87-
88-
**Skip it:**
89-
- "How do I print Hello World in Python?" -> informational
90-
- "Add a comment to calculateTotal" -> single edit
91-
- "Run npm install and tell me what happened" -> one command
92-
93-
When in doubt, use it.
94-
</tool>
95-
9645
<tool name="Glob">
9746
**When to use `Glob`:**
9847
- Searching for files by name patterns or extensions

python_agent_harness/tools/agent_tool.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,7 @@
1515
"Launch a specialized sub-agent to handle complex, multi-step tasks "
1616
"autonomously. Sub-agents run independently and return results in one "
1717
"message. Use for open-ended searches, complex research, or when "
18-
"uncertain about finding results in the first few tries.\n\n"
19-
"For multi-step sub-agent tasks (3+ steps), instruct the sub-agent in "
20-
"the prompt to use TodoWrite to report progress: keep the list to at "
21-
"most 5 items and update statuses as it works. The sub-agent's todo "
22-
"list is shown in the UI with a `sub:` label and is automatically "
23-
"scoped, so it never overwrites your own todo list."
18+
"uncertain about finding results in the first few tries."
2419
)
2520

2621
PARAMETERS = {

python_agent_harness/tui.py

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -555,17 +555,10 @@ def _build_history_rows(self) -> list[Any]:
555555

556556
def _todos_panel(self) -> Panel | None:
557557
"""Todos panel — rebuilt every frame (not cached), so a
558-
TodoWrite call shows up immediately even mid-run. When a
559-
sub-agent is running, its scoped list is shown with a `sub:`
560-
label so the parent's list isn't mistaken for the sub's."""
558+
TodoWrite call shows up immediately even mid-run."""
561559
if not self.session.todos:
562560
return None
563561
title = "Todos"
564-
label = self.session.todo_scope_label
565-
if label:
566-
# parentheses, not markup brackets: rich parses panel titles
567-
# as markup and `[sub: ...]` would be eaten as a style tag
568-
title = f"Todos (sub: {_tail_chars(label, 40)})"
569562
t = Table.grid(padding=(0, 1))
570563
for todo in self.session.todos[-8:]:
571564
status = todo.get("status", "")

tests/test_subagent_isolation.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,9 @@ def test_subagent_does_not_touch_shared_context_accounting(self):
146146
self.assertIsNone(s.calibrator.last_raw_estimate)
147147

148148
def test_subagent_does_not_get_parent_only_specs(self):
149-
"""Parent-only tools (Agent, Question, PlanExit) are excluded
150-
from the sub-agent's request specs, while the parent keeps them."""
149+
"""Parent-only tools (Agent, Question, PlanExit, TodoWrite) are
150+
excluded from the sub-agent's request specs, while the parent
151+
keeps them."""
151152
from python_agent_harness.tools import PlanExit
152153

153154
client = RecClient([
@@ -165,17 +166,19 @@ def test_subagent_does_not_get_parent_only_specs(self):
165166
self.assertIn("Agent", parent_tools)
166167
self.assertIn("Question", parent_tools)
167168
self.assertIn("PlanExit", parent_tools)
169+
self.assertIn("TodoWrite", parent_tools)
168170
self.assertNotIn("Agent", sub_tools)
169171
self.assertNotIn("Question", sub_tools)
170172
self.assertNotIn("PlanExit", sub_tools)
173+
self.assertNotIn("TodoWrite", sub_tools)
171174
# the sub-agent keeps its working tools
172175
self.assertIn("Read", sub_tools)
173176
self.assertIn("Bash", sub_tools)
174177

175178
def test_subagent_parent_only_call_refused_at_execution(self):
176179
"""Defense in depth: even a hallucinated parent-only call (Agent,
177-
Question, PlanExit) from a sub-agent must be refused at execution
178-
time, not silently run."""
180+
Question, PlanExit, TodoWrite) from a sub-agent must be refused
181+
at execution time, not silently run."""
179182
from python_agent_harness.agent import AgentLoop
180183
from python_agent_harness.tools import PlanExit
181184

tests/test_todos_scope.py

Lines changed: 38 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1-
"""Scoped todos: a sub-agent's TodoWrite must not clobber the parent list."""
1+
"""TodoWrite is parent-only: a sub-agent must not see the tool spec and a
2+
hallucinated TodoWrite call must be refused without touching the parent's
3+
todo list.
4+
"""
25

36
import json
47
import unittest
58

9+
from python_agent_harness.agent import AgentLoop
610
from python_agent_harness.agent_session import AgentSession
711
from python_agent_harness.models import Message, ToolCall, Usage
812
from python_agent_harness.tools import default_registry
@@ -14,10 +18,12 @@ class FakeClient:
1418
def __init__(self, sub_todos):
1519
self.sub_todos = sub_todos
1620
self.n = 0
21+
self.sent_tools = []
1722

1823
def chat(self, messages, tools=None, system=None, temperature=None,
1924
max_tokens=None, reasoning_effort=None, on_delta=None, stream=True):
2025
self.n += 1
26+
self.sent_tools.append([t.name for t in tools] if tools else None)
2127
if self.n == 1:
2228
tc = ToolCall(
2329
id="call_1", name="TodoWrite",
@@ -42,56 +48,51 @@ def make_session() -> AgentSession:
4248
)
4349

4450

45-
class TestTodoScoping(unittest.TestCase):
46-
def test_update_todos_writes_active_scope(self):
51+
class TestSubagentTodoIsolation(unittest.TestCase):
52+
def test_update_todos_writes_parent_list(self):
4753
s = make_session()
4854
s.update_todos([{"content": "parent task", "status": "in_progress"}])
4955
self.assertEqual(s.todos[0]["content"], "parent task")
50-
self.assertEqual(s._todo_scopes["main"][0]["content"], "parent task")
56+
s.clear_todos()
57+
self.assertEqual(s.todos, [])
5158

52-
def test_push_pop_restores_parent(self):
59+
def test_subagent_spec_excludes_todowrite(self):
60+
"""The TodoWrite spec is filtered from the sub-agent's request."""
5361
s = make_session()
54-
s.update_todos([{"content": "parent task", "status": "pending"}])
55-
s.push_todo_scope("sub:1", "explore code")
56-
self.assertEqual(s.todos, []) # sub scope starts empty
57-
self.assertEqual(s.todo_scope_label, "explore code")
58-
s.update_todos([{"content": "sub task", "status": "in_progress"}])
59-
self.assertEqual(s.todos[0]["content"], "sub task")
60-
# parent list untouched in its own scope
61-
self.assertEqual(s._todo_scopes["main"][0]["content"], "parent task")
62-
s.pop_todo_scope()
63-
self.assertEqual(s.todos[0]["content"], "parent task") # restored
64-
self.assertIsNone(s.todo_scope_label)
65-
66-
def test_nested_scopes(self):
67-
s = make_session()
68-
s.update_todos([{"content": "parent", "status": "pending"}])
69-
s.push_todo_scope("sub:1", "outer")
70-
s.update_todos([{"content": "outer sub", "status": "pending"}])
71-
s.push_todo_scope("sub:2", "inner")
72-
s.update_todos([{"content": "inner sub", "status": "pending"}])
73-
s.pop_todo_scope()
74-
self.assertEqual(s.todos[0]["content"], "outer sub")
75-
s.pop_todo_scope()
76-
self.assertEqual(s.todos[0]["content"], "parent")
77-
78-
def test_run_subagent_isolates_and_restores(self):
62+
s.run_subagent("subagent", "find the bug", "do it")
63+
self.assertTrue(s.client.sent_tools)
64+
sub_tools = s.client.sent_tools[0]
65+
self.assertNotIn("TodoWrite", sub_tools)
66+
# the sub-agent keeps its working tools
67+
self.assertIn("Read", sub_tools)
68+
self.assertIn("Bash", sub_tools)
69+
70+
def test_subagent_todowrite_call_refused(self):
71+
"""Defense in depth: even a hallucinated TodoWrite call from a
72+
sub-agent is refused and never reaches the registry, so the
73+
parent's todo list is untouched."""
7974
sub_todos = [
8075
{"content": "search", "status": "in_progress"},
8176
{"content": "read", "status": "pending"},
8277
]
8378
s = make_session()
8479
s.client = FakeClient(sub_todos)
8580
s.update_todos([{"content": "parent task", "status": "in_progress"}])
86-
result = s.run_subagent("subagent", "find the bug", "do it")
81+
loop = AgentLoop(
82+
s,
83+
messages=[Message(role="user", content="do it")],
84+
top_level=False,
85+
system="SUB",
86+
)
87+
result = loop.run()
8788
self.assertIn("sub done", result)
88-
# parent's todo list restored after the sub-agent finished
89+
tool_rows = [m for m in loop.messages if m.role == "tool"]
90+
self.assertTrue(tool_rows)
91+
self.assertIn("not available to sub-agents", tool_rows[0].text())
92+
# the parent's todo list was never modified
8993
self.assertEqual(s.todos[0]["content"], "parent task")
90-
# sub-agent's list is preserved in its own scope
91-
self.assertEqual(s._todo_scopes["sub:1"][0]["content"], "search")
92-
self.assertEqual(s.todo_scope_label, None) # back on main
9394

94-
def test_run_subagent_exception_restores(self):
95+
def test_run_subagent_exception_contained(self):
9596
s = make_session()
9697

9798
class BoomClient(FakeClient):
@@ -102,8 +103,7 @@ def chat(self, *a, **k):
102103
s.update_todos([{"content": "parent task", "status": "in_progress"}])
103104
result = s.run_subagent("subagent", "boom", "do it")
104105
self.assertIn("Error", result)
105-
self.assertEqual(s.todos[0]["content"], "parent task") # restored
106-
self.assertEqual(s.todo_scope_label, None)
106+
self.assertEqual(s.todos[0]["content"], "parent task") # untouched
107107

108108

109109
if __name__ == "__main__":

0 commit comments

Comments
 (0)