ripperdoc 0.2.9__py3-none-any.whl → 0.3.0__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 (76) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +379 -51
  3. ripperdoc/cli/commands/__init__.py +6 -0
  4. ripperdoc/cli/commands/agents_cmd.py +128 -5
  5. ripperdoc/cli/commands/clear_cmd.py +8 -0
  6. ripperdoc/cli/commands/doctor_cmd.py +29 -0
  7. ripperdoc/cli/commands/exit_cmd.py +1 -0
  8. ripperdoc/cli/commands/memory_cmd.py +2 -1
  9. ripperdoc/cli/commands/models_cmd.py +63 -7
  10. ripperdoc/cli/commands/resume_cmd.py +5 -0
  11. ripperdoc/cli/commands/skills_cmd.py +103 -0
  12. ripperdoc/cli/commands/stats_cmd.py +244 -0
  13. ripperdoc/cli/commands/status_cmd.py +10 -0
  14. ripperdoc/cli/commands/tasks_cmd.py +6 -3
  15. ripperdoc/cli/commands/themes_cmd.py +139 -0
  16. ripperdoc/cli/ui/file_mention_completer.py +63 -13
  17. ripperdoc/cli/ui/helpers.py +6 -3
  18. ripperdoc/cli/ui/interrupt_handler.py +34 -0
  19. ripperdoc/cli/ui/panels.py +14 -8
  20. ripperdoc/cli/ui/rich_ui.py +737 -47
  21. ripperdoc/cli/ui/spinner.py +93 -18
  22. ripperdoc/cli/ui/thinking_spinner.py +1 -2
  23. ripperdoc/cli/ui/tool_renderers.py +10 -9
  24. ripperdoc/cli/ui/wizard.py +24 -19
  25. ripperdoc/core/agents.py +14 -3
  26. ripperdoc/core/config.py +238 -6
  27. ripperdoc/core/default_tools.py +91 -10
  28. ripperdoc/core/hooks/events.py +4 -0
  29. ripperdoc/core/hooks/llm_callback.py +58 -0
  30. ripperdoc/core/hooks/manager.py +6 -0
  31. ripperdoc/core/permissions.py +160 -9
  32. ripperdoc/core/providers/openai.py +84 -28
  33. ripperdoc/core/query.py +489 -87
  34. ripperdoc/core/query_utils.py +17 -14
  35. ripperdoc/core/skills.py +1 -0
  36. ripperdoc/core/theme.py +298 -0
  37. ripperdoc/core/tool.py +15 -5
  38. ripperdoc/protocol/__init__.py +14 -0
  39. ripperdoc/protocol/models.py +300 -0
  40. ripperdoc/protocol/stdio.py +1453 -0
  41. ripperdoc/tools/background_shell.py +354 -139
  42. ripperdoc/tools/bash_tool.py +117 -22
  43. ripperdoc/tools/file_edit_tool.py +228 -50
  44. ripperdoc/tools/file_read_tool.py +154 -3
  45. ripperdoc/tools/file_write_tool.py +53 -11
  46. ripperdoc/tools/grep_tool.py +98 -8
  47. ripperdoc/tools/lsp_tool.py +609 -0
  48. ripperdoc/tools/multi_edit_tool.py +26 -3
  49. ripperdoc/tools/skill_tool.py +52 -1
  50. ripperdoc/tools/task_tool.py +539 -65
  51. ripperdoc/utils/conversation_compaction.py +1 -1
  52. ripperdoc/utils/file_watch.py +216 -7
  53. ripperdoc/utils/image_utils.py +125 -0
  54. ripperdoc/utils/log.py +30 -3
  55. ripperdoc/utils/lsp.py +812 -0
  56. ripperdoc/utils/mcp.py +80 -18
  57. ripperdoc/utils/message_formatting.py +7 -4
  58. ripperdoc/utils/messages.py +198 -33
  59. ripperdoc/utils/pending_messages.py +50 -0
  60. ripperdoc/utils/permissions/shell_command_validation.py +3 -3
  61. ripperdoc/utils/permissions/tool_permission_utils.py +180 -15
  62. ripperdoc/utils/platform.py +198 -0
  63. ripperdoc/utils/session_heatmap.py +242 -0
  64. ripperdoc/utils/session_history.py +2 -2
  65. ripperdoc/utils/session_stats.py +294 -0
  66. ripperdoc/utils/shell_utils.py +8 -5
  67. ripperdoc/utils/todo.py +0 -6
  68. {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/METADATA +55 -17
  69. ripperdoc-0.3.0.dist-info/RECORD +136 -0
  70. {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/WHEEL +1 -1
  71. ripperdoc/sdk/__init__.py +0 -9
  72. ripperdoc/sdk/client.py +0 -333
  73. ripperdoc-0.2.9.dist-info/RECORD +0 -123
  74. {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/entry_points.txt +0 -0
  75. {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/licenses/LICENSE +0 -0
  76. {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.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, Union
11
+ from uuid import uuid4
7
12
 
8
13
  from pydantic import BaseModel, Field
9
14
 
@@ -20,29 +25,173 @@ 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 = Union[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
+
155
+
30
156
  class TaskToolInput(BaseModel):
31
157
  """Input schema for delegating to a subagent."""
32
158
 
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)")
159
+ prompt: Optional[str] = Field(
160
+ default=None,
161
+ description="Detailed task description for the subagent to perform.",
162
+ )
163
+ subagent_type: Optional[str] = Field(
164
+ default=None,
165
+ description="Agent type to run (matches agent frontmatter name). Required for new runs.",
166
+ )
167
+ run_in_background: bool = Field(
168
+ default=False,
169
+ description="If true, start the agent in the background and return immediately.",
170
+ )
171
+ resume: Optional[str] = Field(
172
+ default=None,
173
+ description="Agent id to resume or fetch results for a background run.",
174
+ )
175
+ wait: bool = Field(
176
+ default=True,
177
+ description="When resuming a background agent, wait for completion before returning.",
178
+ )
35
179
 
36
180
 
37
181
  class TaskToolOutput(BaseModel):
38
182
  """Summary of a completed subagent run."""
39
183
 
184
+ agent_id: Optional[str] = None
40
185
  agent_type: str
41
186
  result_text: str
42
187
  duration_ms: float
43
188
  tool_use_count: int
44
189
  missing_tools: List[str] = Field(default_factory=list)
45
190
  model_used: Optional[str] = None
191
+ status: str = "completed"
192
+ is_background: bool = False
193
+ is_resumed: bool = False
194
+ error: Optional[str] = None
46
195
 
47
196
 
48
197
  class TaskTool(Tool[TaskToolInput, TaskToolOutput]):
@@ -101,7 +250,7 @@ class TaskTool(Tool[TaskToolInput, TaskToolOutput]):
101
250
  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
251
  f"Available agent types and the tools they have access to:\n"
103
252
  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"
253
+ 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
254
  f"When NOT to use the {task_tool_name} tool:\n"
106
255
  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
256
  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 +261,11 @@ class TaskTool(Tool[TaskToolInput, TaskToolOutput]):
112
261
  "Usage notes:\n"
113
262
  "- Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses\n"
114
263
  "- 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"
264
+ f"- Use run_in_background=true to launch an agent asynchronously. The tool will return an agent_id immediately for later retrieval.\n"
265
+ 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"
266
+ "- To continue a completed agent, call Task with resume=<agent_id> and a new prompt.\n"
118
267
  "- 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'
268
+ "- 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
269
  "- The agent's outputs should generally be trusted\n"
121
270
  "- 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
271
  "- 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 +274,7 @@ class TaskTool(Tool[TaskToolInput, TaskToolOutput]):
125
274
  "Example usage:\n"
126
275
  "\n"
127
276
  "<example_agent_descriptions>\n"
128
- '"code-reviewer": use this agent after you are done writing a signficant piece of code\n'
277
+ '"code-reviewer": use this agent after you are done writing a significant piece of code\n'
129
278
  '"greeting-responder": use this agent when to respond to user greetings with a friendly joke\n'
130
279
  "</example_agent_description>\n"
131
280
  "\n"
@@ -144,7 +293,7 @@ class TaskTool(Tool[TaskToolInput, TaskToolOutput]):
144
293
  "}\n"
145
294
  "</code>\n"
146
295
  "<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"
296
+ "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
297
  "</commentary>\n"
149
298
  "assistant: Now let me use the code-reviewer agent to review the code\n"
150
299
  f"assistant: Uses the {task_tool_name} tool to launch the code-reviewer agent \n"
@@ -165,20 +314,60 @@ class TaskTool(Tool[TaskToolInput, TaskToolOutput]):
165
314
  def is_concurrency_safe(self) -> bool:
166
315
  return True
167
316
 
317
+ async def validate_input(
318
+ self, input_data: TaskToolInput, context: Optional[ToolUseContext] = None
319
+ ) -> ValidationResult:
320
+ del context
321
+ if input_data.resume and input_data.run_in_background:
322
+ return ValidationResult(
323
+ result=False,
324
+ message="run_in_background cannot be used when resuming an agent.",
325
+ )
326
+ if input_data.resume:
327
+ if input_data.prompt is not None and not input_data.prompt.strip():
328
+ return ValidationResult(
329
+ result=False,
330
+ message="prompt cannot be empty when resuming with new work.",
331
+ )
332
+ return ValidationResult(result=True)
333
+
334
+ if not input_data.subagent_type:
335
+ return ValidationResult(
336
+ result=False,
337
+ message="subagent_type is required when starting a new agent.",
338
+ )
339
+ if not input_data.prompt or not input_data.prompt.strip():
340
+ return ValidationResult(
341
+ result=False,
342
+ message="prompt is required when starting a new agent.",
343
+ )
344
+ return ValidationResult(result=True)
345
+
168
346
  def render_result_for_assistant(self, output: TaskToolOutput) -> str:
169
347
  details: List[str] = []
348
+ if output.agent_id:
349
+ details.append(f"id={output.agent_id}")
350
+ if output.status and output.status != "completed":
351
+ details.append(output.status)
170
352
  if output.tool_use_count:
171
353
  details.append(f"{output.tool_use_count} tool uses")
172
354
  details.append(f"{output.duration_ms / 1000:.1f}s")
173
355
  if output.missing_tools:
174
356
  details.append(f"missing tools: {', '.join(output.missing_tools)}")
357
+ if output.error:
358
+ details.append(f"error: {output.error}")
175
359
 
176
360
  suffix = f" ({'; '.join(details)})" if details else ""
177
361
  return f"[subagent:{output.agent_type}] {output.result_text}{suffix}"
178
362
 
179
363
  def render_tool_use_message(self, input_data: TaskToolInput, verbose: bool = False) -> str:
180
364
  del verbose
181
- return f"Task via {input_data.subagent_type}: {input_data.prompt}"
365
+ if input_data.resume:
366
+ return f"Resume subagent {input_data.resume}"
367
+ label = f"Task via {input_data.subagent_type}: {input_data.prompt}"
368
+ if input_data.run_in_background:
369
+ label += " (background)"
370
+ return label
182
371
 
183
372
  async def call(
184
373
  self,
@@ -187,6 +376,123 @@ class TaskTool(Tool[TaskToolInput, TaskToolOutput]):
187
376
  ) -> AsyncGenerator[ToolOutput, None]:
188
377
  clear_agent_cache()
189
378
  agents = load_agent_definitions()
379
+
380
+ if input_data.resume:
381
+ record = _get_agent_run(input_data.resume)
382
+ if not record:
383
+ raise ValueError(
384
+ f"Agent id '{input_data.resume}' not found. "
385
+ "Start a new agent to obtain a valid agent_id."
386
+ )
387
+
388
+ if record.task and not record.task.done():
389
+ if not input_data.wait:
390
+ output = self._output_from_record(
391
+ record,
392
+ status_override="running",
393
+ result_text_override="Agent is still running in the background.",
394
+ is_background=True,
395
+ is_resumed=True,
396
+ )
397
+ yield ToolResult(
398
+ data=output, result_for_assistant=self.render_result_for_assistant(output)
399
+ )
400
+ return
401
+
402
+ yield ToolProgress(
403
+ content=f"Waiting for subagent '{record.agent_type}' ({record.agent_id})"
404
+ )
405
+ try:
406
+ await record.task
407
+ except asyncio.CancelledError:
408
+ raise
409
+ except Exception as exc:
410
+ record.status = "failed"
411
+ record.error = str(exc)
412
+
413
+ if not input_data.prompt:
414
+ output = self._output_from_record(
415
+ record,
416
+ is_background=bool(record.task),
417
+ is_resumed=True,
418
+ )
419
+ yield ToolResult(
420
+ data=output, result_for_assistant=self.render_result_for_assistant(output)
421
+ )
422
+ return
423
+
424
+ record.history.append(create_user_message(input_data.prompt))
425
+ record.start_time = time.time()
426
+ record.duration_ms = 0.0
427
+ record.tool_use_count = 0
428
+ record.status = "running"
429
+ record.result_text = None
430
+ record.error = None
431
+ record.task = None
432
+
433
+ subagent_context = QueryContext(
434
+ tools=record.tools,
435
+ yolo_mode=context.yolo_mode,
436
+ verbose=context.verbose,
437
+ model=record.model_used or "main",
438
+ stop_hook="subagent",
439
+ )
440
+
441
+ yield ToolProgress(content=f"Resuming subagent '{record.agent_type}'")
442
+
443
+ # Get the Task tool's tool_use_id to set as parent_tool_use_id for subagent messages
444
+ parent_tool_use_id = context.message_id
445
+
446
+ assistant_messages: List[AssistantMessage] = []
447
+ tool_use_count = 0
448
+ async for message in query(
449
+ record.history, # type: ignore[arg-type]
450
+ record.system_prompt,
451
+ {},
452
+ subagent_context,
453
+ context.permission_checker,
454
+ ):
455
+ msg_type = getattr(message, "type", "")
456
+ if msg_type == "progress":
457
+ continue
458
+
459
+ # Track the message for internal state
460
+ tool_use_count, updates = self._track_subagent_message(
461
+ message,
462
+ record.history,
463
+ assistant_messages,
464
+ tool_use_count,
465
+ )
466
+ for update in updates:
467
+ yield ToolProgress(content=update)
468
+
469
+ # CRITICAL: Also yield subagent messages to SDK for compatibility
470
+ if msg_type in ("assistant", "user"):
471
+ # Set parent_tool_use_id to link subagent messages to the Task tool call
472
+ message_with_parent = message.model_copy(update={"parent_tool_use_id": parent_tool_use_id})
473
+ yield ToolProgress(content=message_with_parent, is_subagent_message=True)
474
+
475
+ duration_ms = (time.time() - record.start_time) * 1000
476
+ result_text = (
477
+ self._extract_text(assistant_messages[-1])
478
+ if assistant_messages
479
+ else "Agent returned no response."
480
+ )
481
+ record.duration_ms = duration_ms
482
+ record.tool_use_count = tool_use_count
483
+ record.result_text = result_text.strip()
484
+ record.status = "completed"
485
+
486
+ output = self._output_from_record(
487
+ record,
488
+ result_text_override=result_text.strip(),
489
+ is_resumed=True,
490
+ )
491
+ yield ToolResult(
492
+ data=output, result_for_assistant=self.render_result_for_assistant(output)
493
+ )
494
+ return
495
+
190
496
  target_agent = next(
191
497
  (
192
498
  agent
@@ -217,83 +523,251 @@ class TaskTool(Tool[TaskToolInput, TaskToolOutput]):
217
523
  ]
218
524
 
219
525
  agent_system_prompt = self._build_agent_prompt(target_agent, typed_agent_tools)
220
- subagent_messages = [create_user_message(input_data.prompt)]
526
+ parent_history = (
527
+ self._coerce_parent_history(getattr(context, "conversation_messages", None))
528
+ if target_agent.fork_context
529
+ else []
530
+ )
531
+ subagent_messages = [
532
+ *parent_history,
533
+ create_user_message(input_data.prompt or ""),
534
+ ]
535
+
536
+ agent_id = _new_agent_id()
537
+ record = AgentRunRecord(
538
+ agent_id=agent_id,
539
+ agent_type=target_agent.agent_type,
540
+ tools=typed_agent_tools,
541
+ system_prompt=agent_system_prompt,
542
+ history=subagent_messages,
543
+ missing_tools=missing_tools,
544
+ model_used=target_agent.model or "main",
545
+ start_time=time.time(),
546
+ is_background=bool(input_data.run_in_background),
547
+ )
548
+ _register_agent_run(record)
221
549
 
222
550
  subagent_context = QueryContext(
223
551
  tools=typed_agent_tools,
224
552
  yolo_mode=context.yolo_mode,
225
553
  verbose=context.verbose,
226
- model=target_agent.model or "task",
554
+ model=target_agent.model or "main",
555
+ stop_hook="subagent",
227
556
  )
228
557
 
229
- start = time.time()
230
- assistant_messages: List[AssistantMessage] = []
231
- tool_use_count = 0
558
+ if input_data.run_in_background:
559
+ record.task = asyncio.create_task(
560
+ self._run_subagent_background(
561
+ record,
562
+ subagent_context,
563
+ context.permission_checker,
564
+ )
565
+ )
566
+ output = self._output_from_record(
567
+ record,
568
+ status_override="running",
569
+ result_text_override="Agent started in the background.",
570
+ is_background=True,
571
+ )
572
+ yield ToolResult(
573
+ data=output, result_for_assistant=self.render_result_for_assistant(output)
574
+ )
575
+ return
232
576
 
233
577
  yield ToolProgress(content=f"Launching subagent '{target_agent.agent_type}'")
234
578
 
579
+ # Get the Task tool's tool_use_id to set as parent_tool_use_id for subagent messages
580
+ parent_tool_use_id = context.message_id
581
+
582
+ assistant_messages = []
583
+ tool_use_count = 0
235
584
  async for message in query(
236
- subagent_messages, # type: ignore[arg-type]
585
+ record.history, # type: ignore[arg-type]
237
586
  agent_system_prompt,
238
587
  {},
239
588
  subagent_context,
240
589
  context.permission_checker,
241
590
  ):
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
591
+ msg_type = getattr(message, "type", "")
592
+ if msg_type == "progress":
593
+ continue
594
+
595
+ # Track the message for internal state
596
+ tool_use_count, updates = self._track_subagent_message(
597
+ message,
598
+ record.history,
599
+ assistant_messages,
600
+ tool_use_count,
601
+ )
602
+ for update in updates:
603
+ yield ToolProgress(content=update)
604
+
605
+ # CRITICAL: Also yield subagent messages to SDK for compatibility
606
+ # This allows SDK clients to see the full subagent conversation
607
+ if msg_type in ("assistant", "user"):
608
+ # Set parent_tool_use_id to link subagent messages to the Task tool call
609
+ # Use model_copy() to create a new message with the parent_tool_use_id set
610
+ message_with_parent = message.model_copy(update={"parent_tool_use_id": parent_tool_use_id})
611
+ yield ToolProgress(content=message_with_parent, is_subagent_message=True)
612
+
613
+ duration_ms = (time.time() - record.start_time) * 1000
281
614
  result_text = (
282
615
  self._extract_text(assistant_messages[-1])
283
616
  if assistant_messages
284
617
  else "Agent returned no response."
285
618
  )
286
619
 
287
- output = TaskToolOutput(
288
- agent_type=target_agent.agent_type,
289
- result_text=result_text.strip(),
620
+ record.duration_ms = duration_ms
621
+ record.tool_use_count = tool_use_count
622
+ record.result_text = result_text.strip()
623
+ record.status = "completed"
624
+
625
+ output = self._output_from_record(record, result_text_override=result_text.strip())
626
+
627
+ yield ToolResult(data=output, result_for_assistant=self.render_result_for_assistant(output))
628
+
629
+ def _output_from_record(
630
+ self,
631
+ record: AgentRunRecord,
632
+ *,
633
+ status_override: Optional[str] = None,
634
+ result_text_override: Optional[str] = None,
635
+ is_background: bool = False,
636
+ is_resumed: bool = False,
637
+ error_override: Optional[str] = None,
638
+ ) -> TaskToolOutput:
639
+ status = status_override or record.status
640
+ duration_ms = (
641
+ record.duration_ms
642
+ if record.duration_ms
643
+ else max((time.time() - record.start_time) * 1000, 0.0)
644
+ )
645
+ result_text = (
646
+ result_text_override
647
+ or record.result_text
648
+ or ("Agent is still running." if status == "running" else "Agent returned no response.")
649
+ )
650
+ return TaskToolOutput(
651
+ agent_id=record.agent_id,
652
+ agent_type=record.agent_type,
653
+ result_text=result_text,
290
654
  duration_ms=duration_ms,
291
- tool_use_count=tool_use_count,
292
- missing_tools=missing_tools,
293
- model_used=target_agent.model or "task",
655
+ tool_use_count=record.tool_use_count,
656
+ missing_tools=record.missing_tools,
657
+ model_used=record.model_used,
658
+ status=status,
659
+ is_background=is_background,
660
+ is_resumed=is_resumed,
661
+ error=error_override or record.error,
294
662
  )
295
663
 
296
- yield ToolResult(data=output, result_for_assistant=self.render_result_for_assistant(output))
664
+ def _coerce_parent_history(self, messages: Optional[Sequence[object]]) -> List[MessageType]:
665
+ if not messages:
666
+ return []
667
+ history: List[MessageType] = []
668
+ for msg in messages:
669
+ msg_type = getattr(msg, "type", None)
670
+ if msg_type in ("user", "assistant"):
671
+ history.append(msg) # type: ignore[arg-type]
672
+ return history
673
+
674
+ def _track_subagent_message(
675
+ self,
676
+ message: object,
677
+ history: List[MessageType],
678
+ assistant_messages: List[AssistantMessage],
679
+ tool_use_count: int,
680
+ ) -> tuple[int, List[str]]:
681
+ updates: List[str] = []
682
+ msg_type = getattr(message, "type", "")
683
+ if msg_type in ("assistant", "user"):
684
+ history.append(message) # type: ignore[arg-type]
685
+
686
+ if msg_type == "assistant":
687
+ if isinstance(message, AssistantMessage):
688
+ tool_use_count += self._count_tool_uses(message)
689
+ updates = self._extract_progress_updates(message)
690
+ assistant_messages.append(message) # type: ignore[arg-type]
691
+
692
+ return tool_use_count, updates
693
+
694
+ def _extract_progress_updates(self, message: object) -> List[str]:
695
+ msg_content = getattr(message, "message", None)
696
+ blocks = getattr(msg_content, "content", []) if msg_content else []
697
+ if not isinstance(blocks, list):
698
+ return []
699
+
700
+ updates: List[str] = []
701
+ for block in blocks:
702
+ block_type = getattr(block, "type", None) or (
703
+ block.get("type") if isinstance(block, Dict) else None
704
+ )
705
+ if block_type == "tool_use":
706
+ tool_name = getattr(block, "name", None) or (
707
+ block.get("name") if isinstance(block, Dict) else "unknown tool"
708
+ )
709
+ block_input = (
710
+ getattr(block, "input", None)
711
+ if hasattr(block, "input")
712
+ else (block.get("input") if isinstance(block, Dict) else None)
713
+ )
714
+ summary = self._summarize_tool_input(block_input)
715
+ label = f"Subagent requesting {tool_name}"
716
+ if summary:
717
+ label += f" — {summary}"
718
+ updates.append(label)
719
+ if block_type == "text":
720
+ text_val = getattr(block, "text", None) or (
721
+ block.get("text") if isinstance(block, Dict) else ""
722
+ )
723
+ if text_val:
724
+ snippet = str(text_val).strip()
725
+ if snippet:
726
+ short = snippet if len(snippet) <= 200 else snippet[:197] + "..."
727
+ updates.append(f"Subagent: {short}")
728
+ return updates
729
+
730
+ async def _run_subagent_background(
731
+ self,
732
+ record: AgentRunRecord,
733
+ subagent_context: QueryContext,
734
+ permission_checker: Any,
735
+ ) -> None:
736
+ assistant_messages: List[AssistantMessage] = []
737
+ tool_use_count = 0
738
+ try:
739
+ async for message in query(
740
+ record.history, # type: ignore[arg-type]
741
+ record.system_prompt,
742
+ {},
743
+ subagent_context,
744
+ permission_checker,
745
+ ):
746
+ if getattr(message, "type", "") == "progress":
747
+ continue
748
+ tool_use_count, _ = self._track_subagent_message(
749
+ message,
750
+ record.history,
751
+ assistant_messages,
752
+ tool_use_count,
753
+ )
754
+ except asyncio.CancelledError:
755
+ raise
756
+ except Exception as exc:
757
+ record.status = "failed"
758
+ record.error = str(exc)
759
+ finally:
760
+ record.duration_ms = (time.time() - record.start_time) * 1000
761
+ record.tool_use_count = tool_use_count
762
+ if record.status != "failed":
763
+ result_text = (
764
+ self._extract_text(assistant_messages[-1])
765
+ if assistant_messages
766
+ else "Agent returned no response."
767
+ )
768
+ record.result_text = result_text.strip()
769
+ record.status = "completed"
770
+ record.task = None
297
771
 
298
772
  def _build_agent_prompt(self, agent: AgentDefinition, tools: List[Tool[Any, Any]]) -> str:
299
773
  tool_names = ", ".join(tool.name for tool in tools if getattr(tool, "name", None))