ripperdoc 0.2.8__py3-none-any.whl → 0.2.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +257 -123
  3. ripperdoc/cli/commands/__init__.py +2 -1
  4. ripperdoc/cli/commands/agents_cmd.py +138 -8
  5. ripperdoc/cli/commands/clear_cmd.py +9 -4
  6. ripperdoc/cli/commands/config_cmd.py +1 -1
  7. ripperdoc/cli/commands/context_cmd.py +3 -2
  8. ripperdoc/cli/commands/doctor_cmd.py +18 -4
  9. ripperdoc/cli/commands/exit_cmd.py +1 -0
  10. ripperdoc/cli/commands/hooks_cmd.py +27 -53
  11. ripperdoc/cli/commands/models_cmd.py +27 -10
  12. ripperdoc/cli/commands/permissions_cmd.py +27 -9
  13. ripperdoc/cli/commands/resume_cmd.py +9 -3
  14. ripperdoc/cli/commands/stats_cmd.py +244 -0
  15. ripperdoc/cli/commands/status_cmd.py +4 -4
  16. ripperdoc/cli/commands/tasks_cmd.py +8 -4
  17. ripperdoc/cli/ui/file_mention_completer.py +2 -1
  18. ripperdoc/cli/ui/interrupt_handler.py +2 -3
  19. ripperdoc/cli/ui/message_display.py +4 -2
  20. ripperdoc/cli/ui/panels.py +1 -0
  21. ripperdoc/cli/ui/provider_options.py +247 -0
  22. ripperdoc/cli/ui/rich_ui.py +403 -81
  23. ripperdoc/cli/ui/spinner.py +54 -18
  24. ripperdoc/cli/ui/thinking_spinner.py +1 -2
  25. ripperdoc/cli/ui/tool_renderers.py +8 -2
  26. ripperdoc/cli/ui/wizard.py +213 -0
  27. ripperdoc/core/agents.py +19 -6
  28. ripperdoc/core/config.py +51 -17
  29. ripperdoc/core/custom_commands.py +7 -6
  30. ripperdoc/core/default_tools.py +101 -12
  31. ripperdoc/core/hooks/config.py +1 -3
  32. ripperdoc/core/hooks/events.py +27 -28
  33. ripperdoc/core/hooks/executor.py +4 -6
  34. ripperdoc/core/hooks/integration.py +12 -21
  35. ripperdoc/core/hooks/llm_callback.py +59 -0
  36. ripperdoc/core/hooks/manager.py +40 -15
  37. ripperdoc/core/permissions.py +118 -12
  38. ripperdoc/core/providers/anthropic.py +109 -36
  39. ripperdoc/core/providers/gemini.py +70 -5
  40. ripperdoc/core/providers/openai.py +89 -24
  41. ripperdoc/core/query.py +273 -68
  42. ripperdoc/core/query_utils.py +2 -0
  43. ripperdoc/core/skills.py +9 -3
  44. ripperdoc/core/system_prompt.py +4 -2
  45. ripperdoc/core/tool.py +17 -8
  46. ripperdoc/sdk/client.py +79 -4
  47. ripperdoc/tools/ask_user_question_tool.py +5 -3
  48. ripperdoc/tools/background_shell.py +307 -135
  49. ripperdoc/tools/bash_output_tool.py +1 -1
  50. ripperdoc/tools/bash_tool.py +63 -24
  51. ripperdoc/tools/dynamic_mcp_tool.py +29 -8
  52. ripperdoc/tools/enter_plan_mode_tool.py +1 -1
  53. ripperdoc/tools/exit_plan_mode_tool.py +1 -1
  54. ripperdoc/tools/file_edit_tool.py +167 -54
  55. ripperdoc/tools/file_read_tool.py +28 -4
  56. ripperdoc/tools/file_write_tool.py +13 -10
  57. ripperdoc/tools/glob_tool.py +3 -2
  58. ripperdoc/tools/grep_tool.py +3 -2
  59. ripperdoc/tools/kill_bash_tool.py +1 -1
  60. ripperdoc/tools/ls_tool.py +1 -1
  61. ripperdoc/tools/lsp_tool.py +615 -0
  62. ripperdoc/tools/mcp_tools.py +13 -10
  63. ripperdoc/tools/multi_edit_tool.py +8 -7
  64. ripperdoc/tools/notebook_edit_tool.py +7 -4
  65. ripperdoc/tools/skill_tool.py +1 -1
  66. ripperdoc/tools/task_tool.py +519 -69
  67. ripperdoc/tools/todo_tool.py +2 -2
  68. ripperdoc/tools/tool_search_tool.py +3 -2
  69. ripperdoc/utils/conversation_compaction.py +9 -5
  70. ripperdoc/utils/file_watch.py +214 -5
  71. ripperdoc/utils/json_utils.py +2 -1
  72. ripperdoc/utils/lsp.py +806 -0
  73. ripperdoc/utils/mcp.py +11 -3
  74. ripperdoc/utils/memory.py +4 -2
  75. ripperdoc/utils/message_compaction.py +21 -7
  76. ripperdoc/utils/message_formatting.py +14 -7
  77. ripperdoc/utils/messages.py +126 -67
  78. ripperdoc/utils/path_ignore.py +35 -8
  79. ripperdoc/utils/permissions/path_validation_utils.py +2 -1
  80. ripperdoc/utils/permissions/shell_command_validation.py +427 -91
  81. ripperdoc/utils/permissions/tool_permission_utils.py +174 -15
  82. ripperdoc/utils/safe_get_cwd.py +2 -1
  83. ripperdoc/utils/session_heatmap.py +244 -0
  84. ripperdoc/utils/session_history.py +13 -6
  85. ripperdoc/utils/session_stats.py +293 -0
  86. ripperdoc/utils/todo.py +2 -1
  87. ripperdoc/utils/token_estimation.py +6 -1
  88. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/METADATA +8 -2
  89. ripperdoc-0.2.10.dist-info/RECORD +129 -0
  90. ripperdoc-0.2.8.dist-info/RECORD +0 -121
  91. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/WHEEL +0 -0
  92. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/entry_points.txt +0 -0
  93. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/licenses/LICENSE +0 -0
  94. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/top_level.txt +0 -0
@@ -2,8 +2,13 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import asyncio
6
+ import os
7
+ import threading
5
8
  import time
6
- from typing import Any, AsyncGenerator, Callable, Dict, Iterable, List, Optional
9
+ from dataclasses import dataclass
10
+ from typing import Any, AsyncGenerator, Callable, Dict, Iterable, List, Optional, Sequence
11
+ from uuid import uuid4
7
12
 
8
13
  from pydantic import BaseModel, Field
9
14
 
@@ -20,29 +25,172 @@ from ripperdoc.core.agents import (
20
25
  )
21
26
  from ripperdoc.core.query import QueryContext, query
22
27
  from ripperdoc.core.system_prompt import build_environment_prompt
23
- from ripperdoc.core.tool import Tool, ToolOutput, ToolProgress, ToolResult, ToolUseContext
24
- from ripperdoc.utils.messages import AssistantMessage, create_user_message
28
+ from ripperdoc.core.tool import (
29
+ Tool,
30
+ ToolOutput,
31
+ ToolProgress,
32
+ ToolResult,
33
+ ToolUseContext,
34
+ ValidationResult,
35
+ )
36
+ from ripperdoc.utils.messages import AssistantMessage, UserMessage, create_user_message
25
37
  from ripperdoc.utils.log import get_logger
26
38
 
27
39
  logger = get_logger()
28
40
 
29
41
 
42
+ MessageType = UserMessage | AssistantMessage
43
+
44
+
45
+ @dataclass
46
+ class AgentRunRecord:
47
+ """In-memory record for a subagent run (foreground or background)."""
48
+
49
+ agent_id: str
50
+ agent_type: str
51
+ tools: List[Tool[Any, Any]]
52
+ system_prompt: str
53
+ history: List[MessageType]
54
+ missing_tools: List[str]
55
+ model_used: Optional[str]
56
+ start_time: float
57
+ duration_ms: float = 0.0
58
+ tool_use_count: int = 0
59
+ status: str = "running"
60
+ result_text: Optional[str] = None
61
+ error: Optional[str] = None
62
+ task: Optional[asyncio.Task] = None
63
+ is_background: bool = False
64
+
65
+
66
+ _AGENT_RUNS: Dict[str, AgentRunRecord] = {}
67
+ _AGENT_RUNS_LOCK = threading.Lock()
68
+ DEFAULT_AGENT_RUN_TTL_SEC = float(os.getenv("RIPPERDOC_AGENT_RUN_TTL_SEC", "3600"))
69
+
70
+
71
+ def _new_agent_id() -> str:
72
+ return f"agent_{uuid4().hex[:8]}"
73
+
74
+
75
+ def _register_agent_run(record: AgentRunRecord) -> None:
76
+ with _AGENT_RUNS_LOCK:
77
+ _AGENT_RUNS[record.agent_id] = record
78
+ prune_agent_runs()
79
+
80
+
81
+ def _get_agent_run(agent_id: str) -> Optional[AgentRunRecord]:
82
+ with _AGENT_RUNS_LOCK:
83
+ return _AGENT_RUNS.get(agent_id)
84
+
85
+
86
+ def _snapshot_agent_run(record: AgentRunRecord) -> dict:
87
+ duration_ms = (
88
+ record.duration_ms
89
+ if record.duration_ms
90
+ else max((time.time() - record.start_time) * 1000.0, 0.0)
91
+ )
92
+ return {
93
+ "id": record.agent_id,
94
+ "agent_type": record.agent_type,
95
+ "status": record.status,
96
+ "duration_ms": duration_ms,
97
+ "tool_use_count": record.tool_use_count,
98
+ "missing_tools": list(record.missing_tools),
99
+ "model_used": record.model_used,
100
+ "result_text": record.result_text,
101
+ "error": record.error,
102
+ "is_background": record.is_background,
103
+ }
104
+
105
+
106
+ def list_agent_runs() -> List[str]:
107
+ """Return known subagent run ids."""
108
+ prune_agent_runs()
109
+ with _AGENT_RUNS_LOCK:
110
+ return list(_AGENT_RUNS.keys())
111
+
112
+
113
+ def get_agent_run_snapshot(agent_id: str) -> Optional[dict]:
114
+ """Return a snapshot of a subagent run by id."""
115
+ record = _get_agent_run(agent_id)
116
+ if not record:
117
+ return None
118
+ return _snapshot_agent_run(record)
119
+
120
+
121
+ def prune_agent_runs(max_age_seconds: Optional[float] = None) -> int:
122
+ """Remove finished subagent runs older than the TTL."""
123
+ ttl = DEFAULT_AGENT_RUN_TTL_SEC if max_age_seconds is None else max_age_seconds
124
+ if ttl is None or ttl <= 0:
125
+ return 0
126
+ now = time.time()
127
+ removed = 0
128
+ with _AGENT_RUNS_LOCK:
129
+ for agent_id, record in list(_AGENT_RUNS.items()):
130
+ if record.status == "running" or record.task:
131
+ continue
132
+ age = now - record.start_time
133
+ if age > ttl:
134
+ _AGENT_RUNS.pop(agent_id, None)
135
+ removed += 1
136
+ return removed
137
+
138
+
139
+ async def cancel_agent_run(agent_id: str) -> bool:
140
+ """Cancel a running subagent, if possible."""
141
+ record = _get_agent_run(agent_id)
142
+ if not record or not record.task or record.task.done():
143
+ return False
144
+ record.task.cancel()
145
+ try:
146
+ await record.task
147
+ except asyncio.CancelledError:
148
+ pass
149
+ record.status = "cancelled"
150
+ record.error = record.error or "Cancelled by user."
151
+ record.duration_ms = (time.time() - record.start_time) * 1000
152
+ record.task = None
153
+ return True
154
+
30
155
  class TaskToolInput(BaseModel):
31
156
  """Input schema for delegating to a subagent."""
32
157
 
33
- prompt: str = Field(description="Detailed task description for the subagent to perform")
34
- subagent_type: str = Field(description="Agent type to run (matches agent frontmatter name)")
158
+ prompt: Optional[str] = Field(
159
+ default=None,
160
+ description="Detailed task description for the subagent to perform.",
161
+ )
162
+ subagent_type: Optional[str] = Field(
163
+ default=None,
164
+ description="Agent type to run (matches agent frontmatter name). Required for new runs.",
165
+ )
166
+ run_in_background: bool = Field(
167
+ default=False,
168
+ description="If true, start the agent in the background and return immediately.",
169
+ )
170
+ resume: Optional[str] = Field(
171
+ default=None,
172
+ description="Agent id to resume or fetch results for a background run.",
173
+ )
174
+ wait: bool = Field(
175
+ default=True,
176
+ description="When resuming a background agent, wait for completion before returning.",
177
+ )
35
178
 
36
179
 
37
180
  class TaskToolOutput(BaseModel):
38
181
  """Summary of a completed subagent run."""
39
182
 
183
+ agent_id: Optional[str] = None
40
184
  agent_type: str
41
185
  result_text: str
42
186
  duration_ms: float
43
187
  tool_use_count: int
44
188
  missing_tools: List[str] = Field(default_factory=list)
45
189
  model_used: Optional[str] = None
190
+ status: str = "completed"
191
+ is_background: bool = False
192
+ is_resumed: bool = False
193
+ error: Optional[str] = None
46
194
 
47
195
 
48
196
  class TaskTool(Tool[TaskToolInput, TaskToolOutput]):
@@ -69,8 +217,8 @@ class TaskTool(Tool[TaskToolInput, TaskToolOutput]):
69
217
  def input_schema(self) -> type[TaskToolInput]:
70
218
  return TaskToolInput
71
219
 
72
- async def prompt(self, safe_mode: bool = False) -> str:
73
- del safe_mode
220
+ async def prompt(self, yolo_mode: bool = False) -> str:
221
+ del yolo_mode
74
222
  clear_agent_cache()
75
223
  agents: AgentLoadResult = load_agent_definitions()
76
224
 
@@ -101,7 +249,7 @@ class TaskTool(Tool[TaskToolInput, TaskToolOutput]):
101
249
  f"The {task_tool_name} tool launches specialized agents (subprocesses) that autonomously handle complex tasks. Each agent type has specific capabilities and tools available to it.\n\n"
102
250
  f"Available agent types and the tools they have access to:\n"
103
251
  f"{agent_block}\n\n"
104
- f"When using the {task_tool_name} tool, you must specify a subagent_type parameter to select which agent type to use.\n\n"
252
+ f"When starting a new agent with the {task_tool_name} tool, you must specify a subagent_type parameter to select which agent type to use.\n\n"
105
253
  f"When NOT to use the {task_tool_name} tool:\n"
106
254
  f"- If you want to read a specific file path, use the {file_read_tool_name} or {search_tool_name} tool instead of the {task_tool_name} tool, to find the match more quickly\n"
107
255
  f'- If you are searching for a specific class definition like "class Foo", use the {search_tool_name} tool instead, to find the match more quickly\n'
@@ -112,11 +260,11 @@ class TaskTool(Tool[TaskToolInput, TaskToolOutput]):
112
260
  "Usage notes:\n"
113
261
  "- Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses\n"
114
262
  "- When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.\n"
115
- f"- You can optionally run agents in the background using the run_in_background parameter. When an agent runs in the background, you will need to use {background_fetch_tool_name} to retrieve its results once it's done. You can continue to work while background agents run - When you need their results to continue you can use {background_fetch_tool_name} in blocking mode to pause and wait for their results.\n"
116
- "- Agents can be resumed using the `resume` parameter by passing the agent ID from a previous invocation. When resumed, the agent continues with its full previous context preserved. When NOT resuming, each invocation starts fresh and you should provide a detailed task description with all necessary context.\n"
117
- "- When the agent is done, it will return a single message back to you along with its agent ID. You can use this ID to resume the agent later if needed for follow-up work.\n"
263
+ f"- Use run_in_background=true to launch an agent asynchronously. The tool will return an agent_id immediately for later retrieval.\n"
264
+ f"- Fetch background results by calling {background_fetch_tool_name} with resume=<agent_id>. If the agent is still running, set wait=true to block or wait=false to get status only.\n"
265
+ "- To continue a completed agent, call Task with resume=<agent_id> and a new prompt.\n"
118
266
  "- Provide clear, detailed prompts so the agent can work autonomously and return exactly the information you need.\n"
119
- '- Agents with "access to current context" can see the full conversation history before the tool call. When using these agents, you can write concise prompts that reference earlier context (e.g., "investigate the error discussed above") instead of repeating information. The agent will receive all prior messages and understand the context.\n'
267
+ '- Agents can opt into parent context by setting fork_context: true in their frontmatter. When enabled, they receive the full conversation history before the tool call.\n'
120
268
  "- The agent's outputs should generally be trusted\n"
121
269
  "- Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent\n"
122
270
  "- If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement.\n"
@@ -125,7 +273,7 @@ class TaskTool(Tool[TaskToolInput, TaskToolOutput]):
125
273
  "Example usage:\n"
126
274
  "\n"
127
275
  "<example_agent_descriptions>\n"
128
- '"code-reviewer": use this agent after you are done writing a signficant piece of code\n'
276
+ '"code-reviewer": use this agent after you are done writing a significant piece of code\n'
129
277
  '"greeting-responder": use this agent when to respond to user greetings with a friendly joke\n'
130
278
  "</example_agent_description>\n"
131
279
  "\n"
@@ -144,7 +292,7 @@ class TaskTool(Tool[TaskToolInput, TaskToolOutput]):
144
292
  "}\n"
145
293
  "</code>\n"
146
294
  "<commentary>\n"
147
- "Since a signficant piece of code was written and the task was completed, now use the code-reviewer agent to review the code\n"
295
+ "Since a significant piece of code was written and the task was completed, now use the code-reviewer agent to review the code\n"
148
296
  "</commentary>\n"
149
297
  "assistant: Now let me use the code-reviewer agent to review the code\n"
150
298
  f"assistant: Uses the {task_tool_name} tool to launch the code-reviewer agent \n"
@@ -165,20 +313,60 @@ class TaskTool(Tool[TaskToolInput, TaskToolOutput]):
165
313
  def is_concurrency_safe(self) -> bool:
166
314
  return True
167
315
 
316
+ async def validate_input(
317
+ self, input_data: TaskToolInput, context: Optional[ToolUseContext] = None
318
+ ) -> ValidationResult:
319
+ del context
320
+ if input_data.resume and input_data.run_in_background:
321
+ return ValidationResult(
322
+ result=False,
323
+ message="run_in_background cannot be used when resuming an agent.",
324
+ )
325
+ if input_data.resume:
326
+ if input_data.prompt is not None and not input_data.prompt.strip():
327
+ return ValidationResult(
328
+ result=False,
329
+ message="prompt cannot be empty when resuming with new work.",
330
+ )
331
+ return ValidationResult(result=True)
332
+
333
+ if not input_data.subagent_type:
334
+ return ValidationResult(
335
+ result=False,
336
+ message="subagent_type is required when starting a new agent.",
337
+ )
338
+ if not input_data.prompt or not input_data.prompt.strip():
339
+ return ValidationResult(
340
+ result=False,
341
+ message="prompt is required when starting a new agent.",
342
+ )
343
+ return ValidationResult(result=True)
344
+
168
345
  def render_result_for_assistant(self, output: TaskToolOutput) -> str:
169
346
  details: List[str] = []
347
+ if output.agent_id:
348
+ details.append(f"id={output.agent_id}")
349
+ if output.status and output.status != "completed":
350
+ details.append(output.status)
170
351
  if output.tool_use_count:
171
352
  details.append(f"{output.tool_use_count} tool uses")
172
353
  details.append(f"{output.duration_ms / 1000:.1f}s")
173
354
  if output.missing_tools:
174
355
  details.append(f"missing tools: {', '.join(output.missing_tools)}")
356
+ if output.error:
357
+ details.append(f"error: {output.error}")
175
358
 
176
359
  suffix = f" ({'; '.join(details)})" if details else ""
177
360
  return f"[subagent:{output.agent_type}] {output.result_text}{suffix}"
178
361
 
179
362
  def render_tool_use_message(self, input_data: TaskToolInput, verbose: bool = False) -> str:
180
363
  del verbose
181
- return f"Task via {input_data.subagent_type}: {input_data.prompt}"
364
+ if input_data.resume:
365
+ return f"Resume subagent {input_data.resume}"
366
+ label = f"Task via {input_data.subagent_type}: {input_data.prompt}"
367
+ if input_data.run_in_background:
368
+ label += " (background)"
369
+ return label
182
370
 
183
371
  async def call(
184
372
  self,
@@ -187,6 +375,111 @@ class TaskTool(Tool[TaskToolInput, TaskToolOutput]):
187
375
  ) -> AsyncGenerator[ToolOutput, None]:
188
376
  clear_agent_cache()
189
377
  agents = load_agent_definitions()
378
+
379
+ if input_data.resume:
380
+ record = _get_agent_run(input_data.resume)
381
+ if not record:
382
+ raise ValueError(
383
+ f"Agent id '{input_data.resume}' not found. "
384
+ "Start a new agent to obtain a valid agent_id."
385
+ )
386
+
387
+ if record.task and not record.task.done():
388
+ if not input_data.wait:
389
+ output = self._output_from_record(
390
+ record,
391
+ status_override="running",
392
+ result_text_override="Agent is still running in the background.",
393
+ is_background=True,
394
+ is_resumed=True,
395
+ )
396
+ yield ToolResult(
397
+ data=output, result_for_assistant=self.render_result_for_assistant(output)
398
+ )
399
+ return
400
+
401
+ yield ToolProgress(
402
+ content=f"Waiting for subagent '{record.agent_type}' ({record.agent_id})"
403
+ )
404
+ try:
405
+ await record.task
406
+ except asyncio.CancelledError:
407
+ raise
408
+ except Exception as exc:
409
+ record.status = "failed"
410
+ record.error = str(exc)
411
+
412
+ if not input_data.prompt:
413
+ output = self._output_from_record(
414
+ record,
415
+ is_background=bool(record.task),
416
+ is_resumed=True,
417
+ )
418
+ yield ToolResult(
419
+ data=output, result_for_assistant=self.render_result_for_assistant(output)
420
+ )
421
+ return
422
+
423
+ record.history.append(create_user_message(input_data.prompt))
424
+ record.start_time = time.time()
425
+ record.duration_ms = 0.0
426
+ record.tool_use_count = 0
427
+ record.status = "running"
428
+ record.result_text = None
429
+ record.error = None
430
+ record.task = None
431
+
432
+ subagent_context = QueryContext(
433
+ tools=record.tools,
434
+ yolo_mode=context.yolo_mode,
435
+ verbose=context.verbose,
436
+ model=record.model_used or "main",
437
+ stop_hook="subagent",
438
+ )
439
+
440
+ yield ToolProgress(content=f"Resuming subagent '{record.agent_type}'")
441
+
442
+ assistant_messages: List[AssistantMessage] = []
443
+ tool_use_count = 0
444
+ async for message in query(
445
+ record.history, # type: ignore[arg-type]
446
+ record.system_prompt,
447
+ {},
448
+ subagent_context,
449
+ context.permission_checker,
450
+ ):
451
+ if getattr(message, "type", "") == "progress":
452
+ continue
453
+ tool_use_count, updates = self._track_subagent_message(
454
+ message,
455
+ record.history,
456
+ assistant_messages,
457
+ tool_use_count,
458
+ )
459
+ for update in updates:
460
+ yield ToolProgress(content=update)
461
+
462
+ duration_ms = (time.time() - record.start_time) * 1000
463
+ result_text = (
464
+ self._extract_text(assistant_messages[-1])
465
+ if assistant_messages
466
+ else "Agent returned no response."
467
+ )
468
+ record.duration_ms = duration_ms
469
+ record.tool_use_count = tool_use_count
470
+ record.result_text = result_text.strip()
471
+ record.status = "completed"
472
+
473
+ output = self._output_from_record(
474
+ record,
475
+ result_text_override=result_text.strip(),
476
+ is_resumed=True,
477
+ )
478
+ yield ToolResult(
479
+ data=output, result_for_assistant=self.render_result_for_assistant(output)
480
+ )
481
+ return
482
+
190
483
  target_agent = next(
191
484
  (
192
485
  agent
@@ -217,83 +510,239 @@ class TaskTool(Tool[TaskToolInput, TaskToolOutput]):
217
510
  ]
218
511
 
219
512
  agent_system_prompt = self._build_agent_prompt(target_agent, typed_agent_tools)
220
- subagent_messages = [create_user_message(input_data.prompt)]
513
+ parent_history = (
514
+ self._coerce_parent_history(getattr(context, "conversation_messages", None))
515
+ if target_agent.fork_context
516
+ else []
517
+ )
518
+ subagent_messages = [
519
+ *parent_history,
520
+ create_user_message(input_data.prompt or ""),
521
+ ]
522
+
523
+ agent_id = _new_agent_id()
524
+ record = AgentRunRecord(
525
+ agent_id=agent_id,
526
+ agent_type=target_agent.agent_type,
527
+ tools=typed_agent_tools,
528
+ system_prompt=agent_system_prompt,
529
+ history=subagent_messages,
530
+ missing_tools=missing_tools,
531
+ model_used=target_agent.model or "main",
532
+ start_time=time.time(),
533
+ is_background=bool(input_data.run_in_background),
534
+ )
535
+ _register_agent_run(record)
221
536
 
222
537
  subagent_context = QueryContext(
223
538
  tools=typed_agent_tools,
224
- safe_mode=context.safe_mode,
539
+ yolo_mode=context.yolo_mode,
225
540
  verbose=context.verbose,
226
- model=target_agent.model or "task",
541
+ model=target_agent.model or "main",
542
+ stop_hook="subagent",
227
543
  )
228
544
 
229
- start = time.time()
230
- assistant_messages: List[AssistantMessage] = []
231
- tool_use_count = 0
545
+ if input_data.run_in_background:
546
+ record.task = asyncio.create_task(
547
+ self._run_subagent_background(
548
+ record,
549
+ subagent_context,
550
+ context.permission_checker,
551
+ )
552
+ )
553
+ output = self._output_from_record(
554
+ record,
555
+ status_override="running",
556
+ result_text_override="Agent started in the background.",
557
+ is_background=True,
558
+ )
559
+ yield ToolResult(
560
+ data=output, result_for_assistant=self.render_result_for_assistant(output)
561
+ )
562
+ return
232
563
 
233
564
  yield ToolProgress(content=f"Launching subagent '{target_agent.agent_type}'")
234
565
 
566
+ assistant_messages = []
567
+ tool_use_count = 0
235
568
  async for message in query(
236
- subagent_messages, # type: ignore[arg-type]
569
+ record.history, # type: ignore[arg-type]
237
570
  agent_system_prompt,
238
571
  {},
239
572
  subagent_context,
240
573
  context.permission_checker,
241
574
  ):
242
- if getattr(message, "type", "") == "assistant":
243
- # Surface subagent tool requests as progress so the user sees activity.
244
- msg_content = getattr(message, "message", None)
245
- blocks = getattr(msg_content, "content", []) if msg_content else []
246
- if isinstance(blocks, list):
247
- for block in blocks:
248
- block_type = getattr(block, "type", None) or (
249
- block.get("type") if isinstance(block, Dict) else None
250
- )
251
- if block_type == "tool_use":
252
- tool_name = getattr(block, "name", None) or (
253
- block.get("name") if isinstance(block, Dict) else "unknown tool"
254
- )
255
- block_input = (
256
- getattr(block, "input", None)
257
- if hasattr(block, "input")
258
- else (block.get("input") if isinstance(block, Dict) else None)
259
- )
260
- summary = self._summarize_tool_input(block_input)
261
- label = f"Subagent requesting {tool_name}"
262
- if summary:
263
- label += f" — {summary}"
264
- yield ToolProgress(content=label)
265
- if block_type == "text":
266
- text_val = getattr(block, "text", None) or (
267
- block.get("text") if isinstance(block, Dict) else ""
268
- )
269
- if text_val:
270
- snippet = str(text_val).strip()
271
- if snippet:
272
- short = (
273
- snippet if len(snippet) <= 200 else snippet[:197] + "..."
274
- )
275
- yield ToolProgress(content=f"Subagent: {short}")
276
- assistant_messages.append(message) # type: ignore[arg-type]
277
- if isinstance(message, AssistantMessage):
278
- tool_use_count += self._count_tool_uses(message)
279
-
280
- duration_ms = (time.time() - start) * 1000
575
+ if getattr(message, "type", "") == "progress":
576
+ continue
577
+ tool_use_count, updates = self._track_subagent_message(
578
+ message,
579
+ record.history,
580
+ assistant_messages,
581
+ tool_use_count,
582
+ )
583
+ for update in updates:
584
+ yield ToolProgress(content=update)
585
+
586
+ duration_ms = (time.time() - record.start_time) * 1000
281
587
  result_text = (
282
588
  self._extract_text(assistant_messages[-1])
283
589
  if assistant_messages
284
590
  else "Agent returned no response."
285
591
  )
286
592
 
287
- output = TaskToolOutput(
288
- agent_type=target_agent.agent_type,
289
- result_text=result_text.strip(),
593
+ record.duration_ms = duration_ms
594
+ record.tool_use_count = tool_use_count
595
+ record.result_text = result_text.strip()
596
+ record.status = "completed"
597
+
598
+ output = self._output_from_record(record, result_text_override=result_text.strip())
599
+
600
+ yield ToolResult(data=output, result_for_assistant=self.render_result_for_assistant(output))
601
+
602
+ def _output_from_record(
603
+ self,
604
+ record: AgentRunRecord,
605
+ *,
606
+ status_override: Optional[str] = None,
607
+ result_text_override: Optional[str] = None,
608
+ is_background: bool = False,
609
+ is_resumed: bool = False,
610
+ error_override: Optional[str] = None,
611
+ ) -> TaskToolOutput:
612
+ status = status_override or record.status
613
+ duration_ms = (
614
+ record.duration_ms
615
+ if record.duration_ms
616
+ else max((time.time() - record.start_time) * 1000, 0.0)
617
+ )
618
+ result_text = (
619
+ result_text_override
620
+ or record.result_text
621
+ or ("Agent is still running." if status == "running" else "Agent returned no response.")
622
+ )
623
+ return TaskToolOutput(
624
+ agent_id=record.agent_id,
625
+ agent_type=record.agent_type,
626
+ result_text=result_text,
290
627
  duration_ms=duration_ms,
291
- tool_use_count=tool_use_count,
292
- missing_tools=missing_tools,
293
- model_used=target_agent.model or "task",
628
+ tool_use_count=record.tool_use_count,
629
+ missing_tools=record.missing_tools,
630
+ model_used=record.model_used,
631
+ status=status,
632
+ is_background=is_background,
633
+ is_resumed=is_resumed,
634
+ error=error_override or record.error,
294
635
  )
295
636
 
296
- yield ToolResult(data=output, result_for_assistant=self.render_result_for_assistant(output))
637
+ def _coerce_parent_history(
638
+ self, messages: Optional[Sequence[object]]
639
+ ) -> List[MessageType]:
640
+ if not messages:
641
+ return []
642
+ history: List[MessageType] = []
643
+ for msg in messages:
644
+ msg_type = getattr(msg, "type", None)
645
+ if msg_type in ("user", "assistant"):
646
+ history.append(msg) # type: ignore[arg-type]
647
+ return history
648
+
649
+ def _track_subagent_message(
650
+ self,
651
+ message: object,
652
+ history: List[MessageType],
653
+ assistant_messages: List[AssistantMessage],
654
+ tool_use_count: int,
655
+ ) -> tuple[int, List[str]]:
656
+ updates: List[str] = []
657
+ msg_type = getattr(message, "type", "")
658
+ if msg_type in ("assistant", "user"):
659
+ history.append(message) # type: ignore[arg-type]
660
+
661
+ if msg_type == "assistant":
662
+ if isinstance(message, AssistantMessage):
663
+ tool_use_count += self._count_tool_uses(message)
664
+ updates = self._extract_progress_updates(message)
665
+ assistant_messages.append(message) # type: ignore[arg-type]
666
+
667
+ return tool_use_count, updates
668
+
669
+ def _extract_progress_updates(self, message: object) -> List[str]:
670
+ msg_content = getattr(message, "message", None)
671
+ blocks = getattr(msg_content, "content", []) if msg_content else []
672
+ if not isinstance(blocks, list):
673
+ return []
674
+
675
+ updates: List[str] = []
676
+ for block in blocks:
677
+ block_type = getattr(block, "type", None) or (
678
+ block.get("type") if isinstance(block, Dict) else None
679
+ )
680
+ if block_type == "tool_use":
681
+ tool_name = getattr(block, "name", None) or (
682
+ block.get("name") if isinstance(block, Dict) else "unknown tool"
683
+ )
684
+ block_input = (
685
+ getattr(block, "input", None)
686
+ if hasattr(block, "input")
687
+ else (block.get("input") if isinstance(block, Dict) else None)
688
+ )
689
+ summary = self._summarize_tool_input(block_input)
690
+ label = f"Subagent requesting {tool_name}"
691
+ if summary:
692
+ label += f" — {summary}"
693
+ updates.append(label)
694
+ if block_type == "text":
695
+ text_val = getattr(block, "text", None) or (
696
+ block.get("text") if isinstance(block, Dict) else ""
697
+ )
698
+ if text_val:
699
+ snippet = str(text_val).strip()
700
+ if snippet:
701
+ short = snippet if len(snippet) <= 200 else snippet[:197] + "..."
702
+ updates.append(f"Subagent: {short}")
703
+ return updates
704
+
705
+ async def _run_subagent_background(
706
+ self,
707
+ record: AgentRunRecord,
708
+ subagent_context: QueryContext,
709
+ permission_checker: Any,
710
+ ) -> None:
711
+ assistant_messages: List[AssistantMessage] = []
712
+ tool_use_count = 0
713
+ try:
714
+ async for message in query(
715
+ record.history, # type: ignore[arg-type]
716
+ record.system_prompt,
717
+ {},
718
+ subagent_context,
719
+ permission_checker,
720
+ ):
721
+ if getattr(message, "type", "") == "progress":
722
+ continue
723
+ tool_use_count, _ = self._track_subagent_message(
724
+ message,
725
+ record.history,
726
+ assistant_messages,
727
+ tool_use_count,
728
+ )
729
+ except asyncio.CancelledError:
730
+ raise
731
+ except Exception as exc:
732
+ record.status = "failed"
733
+ record.error = str(exc)
734
+ finally:
735
+ record.duration_ms = (time.time() - record.start_time) * 1000
736
+ record.tool_use_count = tool_use_count
737
+ if record.status != "failed":
738
+ result_text = (
739
+ self._extract_text(assistant_messages[-1])
740
+ if assistant_messages
741
+ else "Agent returned no response."
742
+ )
743
+ record.result_text = result_text.strip()
744
+ record.status = "completed"
745
+ record.task = None
297
746
 
298
747
  def _build_agent_prompt(self, agent: AgentDefinition, tools: List[Tool[Any, Any]]) -> str:
299
748
  tool_names = ", ".join(tool.name for tool in tools if getattr(tool, "name", None))
@@ -370,7 +819,8 @@ class TaskTool(Tool[TaskToolInput, TaskToolOutput]):
370
819
  except (TypeError, ValueError) as exc:
371
820
  logger.warning(
372
821
  "[task_tool] Failed to serialize tool_use input: %s: %s",
373
- type(exc).__name__, exc,
822
+ type(exc).__name__,
823
+ exc,
374
824
  extra={"tool_use_input": str(inp)[:200]},
375
825
  )
376
826
  serialized = str(inp)