ripperdoc 0.1.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 (81) hide show
  1. ripperdoc/__init__.py +3 -0
  2. ripperdoc/__main__.py +25 -0
  3. ripperdoc/cli/__init__.py +1 -0
  4. ripperdoc/cli/cli.py +317 -0
  5. ripperdoc/cli/commands/__init__.py +76 -0
  6. ripperdoc/cli/commands/agents_cmd.py +234 -0
  7. ripperdoc/cli/commands/base.py +19 -0
  8. ripperdoc/cli/commands/clear_cmd.py +18 -0
  9. ripperdoc/cli/commands/compact_cmd.py +19 -0
  10. ripperdoc/cli/commands/config_cmd.py +31 -0
  11. ripperdoc/cli/commands/context_cmd.py +114 -0
  12. ripperdoc/cli/commands/cost_cmd.py +77 -0
  13. ripperdoc/cli/commands/exit_cmd.py +19 -0
  14. ripperdoc/cli/commands/help_cmd.py +20 -0
  15. ripperdoc/cli/commands/mcp_cmd.py +65 -0
  16. ripperdoc/cli/commands/models_cmd.py +327 -0
  17. ripperdoc/cli/commands/resume_cmd.py +97 -0
  18. ripperdoc/cli/commands/status_cmd.py +167 -0
  19. ripperdoc/cli/commands/tasks_cmd.py +240 -0
  20. ripperdoc/cli/commands/todos_cmd.py +69 -0
  21. ripperdoc/cli/commands/tools_cmd.py +19 -0
  22. ripperdoc/cli/ui/__init__.py +1 -0
  23. ripperdoc/cli/ui/context_display.py +297 -0
  24. ripperdoc/cli/ui/helpers.py +22 -0
  25. ripperdoc/cli/ui/rich_ui.py +1010 -0
  26. ripperdoc/cli/ui/spinner.py +50 -0
  27. ripperdoc/core/__init__.py +1 -0
  28. ripperdoc/core/agents.py +306 -0
  29. ripperdoc/core/commands.py +33 -0
  30. ripperdoc/core/config.py +382 -0
  31. ripperdoc/core/default_tools.py +57 -0
  32. ripperdoc/core/permissions.py +227 -0
  33. ripperdoc/core/query.py +682 -0
  34. ripperdoc/core/system_prompt.py +418 -0
  35. ripperdoc/core/tool.py +214 -0
  36. ripperdoc/sdk/__init__.py +9 -0
  37. ripperdoc/sdk/client.py +309 -0
  38. ripperdoc/tools/__init__.py +1 -0
  39. ripperdoc/tools/background_shell.py +291 -0
  40. ripperdoc/tools/bash_output_tool.py +98 -0
  41. ripperdoc/tools/bash_tool.py +822 -0
  42. ripperdoc/tools/file_edit_tool.py +281 -0
  43. ripperdoc/tools/file_read_tool.py +168 -0
  44. ripperdoc/tools/file_write_tool.py +141 -0
  45. ripperdoc/tools/glob_tool.py +134 -0
  46. ripperdoc/tools/grep_tool.py +232 -0
  47. ripperdoc/tools/kill_bash_tool.py +136 -0
  48. ripperdoc/tools/ls_tool.py +298 -0
  49. ripperdoc/tools/mcp_tools.py +804 -0
  50. ripperdoc/tools/multi_edit_tool.py +393 -0
  51. ripperdoc/tools/notebook_edit_tool.py +325 -0
  52. ripperdoc/tools/task_tool.py +282 -0
  53. ripperdoc/tools/todo_tool.py +362 -0
  54. ripperdoc/tools/tool_search_tool.py +366 -0
  55. ripperdoc/utils/__init__.py +1 -0
  56. ripperdoc/utils/bash_constants.py +51 -0
  57. ripperdoc/utils/bash_output_utils.py +43 -0
  58. ripperdoc/utils/exit_code_handlers.py +241 -0
  59. ripperdoc/utils/log.py +76 -0
  60. ripperdoc/utils/mcp.py +427 -0
  61. ripperdoc/utils/memory.py +239 -0
  62. ripperdoc/utils/message_compaction.py +640 -0
  63. ripperdoc/utils/messages.py +399 -0
  64. ripperdoc/utils/output_utils.py +233 -0
  65. ripperdoc/utils/path_utils.py +46 -0
  66. ripperdoc/utils/permissions/__init__.py +21 -0
  67. ripperdoc/utils/permissions/path_validation_utils.py +165 -0
  68. ripperdoc/utils/permissions/shell_command_validation.py +74 -0
  69. ripperdoc/utils/permissions/tool_permission_utils.py +279 -0
  70. ripperdoc/utils/safe_get_cwd.py +24 -0
  71. ripperdoc/utils/sandbox_utils.py +38 -0
  72. ripperdoc/utils/session_history.py +223 -0
  73. ripperdoc/utils/session_usage.py +110 -0
  74. ripperdoc/utils/shell_token_utils.py +95 -0
  75. ripperdoc/utils/todo.py +199 -0
  76. ripperdoc-0.1.0.dist-info/METADATA +178 -0
  77. ripperdoc-0.1.0.dist-info/RECORD +81 -0
  78. ripperdoc-0.1.0.dist-info/WHEEL +5 -0
  79. ripperdoc-0.1.0.dist-info/entry_points.txt +3 -0
  80. ripperdoc-0.1.0.dist-info/licenses/LICENSE +53 -0
  81. ripperdoc-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,325 @@
1
+ """Notebook edit tool.
2
+
3
+ Allows performing insert/replace/delete operations on Jupyter notebook cells.
4
+ """
5
+
6
+ import json
7
+ import random
8
+ import string
9
+ from pathlib import Path
10
+ from textwrap import dedent
11
+ from typing import AsyncGenerator, List, Optional
12
+ from pydantic import BaseModel, Field
13
+
14
+ from ripperdoc.core.tool import (
15
+ Tool,
16
+ ToolUseContext,
17
+ ToolResult,
18
+ ToolOutput,
19
+ ToolUseExample,
20
+ ValidationResult,
21
+ )
22
+ from ripperdoc.utils.log import get_logger
23
+
24
+
25
+ logger = get_logger()
26
+
27
+
28
+ def _resolve_path(path_str: str) -> Path:
29
+ """Return an absolute Path, interpreting relative paths from CWD."""
30
+ path = Path(path_str).expanduser()
31
+ return path if path.is_absolute() else Path.cwd() / path
32
+
33
+
34
+ def _generate_cell_id() -> str:
35
+ """Generate a short random cell id."""
36
+ return "".join(random.choices(string.ascii_lowercase + string.digits, k=12))
37
+
38
+
39
+ NOTEBOOK_EDIT_DESCRIPTION = dedent(
40
+ """\
41
+ Replace, insert, or delete a specific cell in a Jupyter notebook (.ipynb file).
42
+ notebook_path must be an absolute path. cell_id may be a 0-based index or a cell id.
43
+ Use edit_mode=insert to add a new cell after the referenced cell (or at the start if omitted).
44
+ Use edit_mode=delete to delete the referenced cell. Defaults to edit_mode=replace.
45
+
46
+ Usage:
47
+ - cell_type: 'code' or 'markdown'. Required for insert; defaults to existing type for replace.
48
+ - new_source: New content for the cell.
49
+ - Edits are applied atomically; failures leave the file unchanged.
50
+ - Code cell replacements clear execution_count and outputs.
51
+ - Only use emojis if explicitly requested; avoid adding emojis otherwise.
52
+ """
53
+ )
54
+
55
+
56
+ class NotebookEditInput(BaseModel):
57
+ """Input schema for NotebookEditTool."""
58
+
59
+ notebook_path: str = Field(description="Absolute path to the Jupyter notebook file to edit")
60
+ cell_id: Optional[str] = Field(
61
+ default=None,
62
+ description="Cell ID or 0-based index. For insert, omitted means insert at start.",
63
+ )
64
+ new_source: str = Field(description="New source content for the target cell")
65
+ cell_type: Optional[str] = Field(
66
+ default=None,
67
+ description="Cell type: 'code' or 'markdown'. Required for insert.",
68
+ )
69
+ edit_mode: Optional[str] = Field(
70
+ default="replace",
71
+ description="Edit mode: 'replace' (default), 'insert', or 'delete'.",
72
+ )
73
+
74
+
75
+ class NotebookEditOutput(BaseModel):
76
+ """Output from notebook editing."""
77
+
78
+ new_source: str
79
+ cell_id: Optional[str] = None
80
+ cell_type: str
81
+ language: str
82
+ edit_mode: str
83
+ error: Optional[str] = None
84
+
85
+
86
+ class NotebookEditTool(Tool[NotebookEditInput, NotebookEditOutput]):
87
+ """Tool for editing Jupyter notebooks."""
88
+
89
+ @property
90
+ def name(self) -> str:
91
+ return "NotebookEdit"
92
+
93
+ async def description(self) -> str:
94
+ return NOTEBOOK_EDIT_DESCRIPTION
95
+
96
+ @property
97
+ def input_schema(self) -> type[NotebookEditInput]:
98
+ return NotebookEditInput
99
+
100
+ def input_examples(self) -> List[ToolUseExample]:
101
+ return [
102
+ ToolUseExample(
103
+ description="Replace a markdown cell by id",
104
+ input={
105
+ "notebook_path": "/repo/notebooks/analysis.ipynb",
106
+ "cell_id": "abc123",
107
+ "new_source": "# Updated overview\\nThis notebook analyzes revenue.",
108
+ "cell_type": "markdown",
109
+ "edit_mode": "replace",
110
+ },
111
+ ),
112
+ ToolUseExample(
113
+ description="Insert a new code cell at the beginning",
114
+ input={
115
+ "notebook_path": "/repo/notebooks/analysis.ipynb",
116
+ "cell_type": "code",
117
+ "edit_mode": "insert",
118
+ "new_source": "import pandas as pd\\nimport numpy as np",
119
+ },
120
+ ),
121
+ ]
122
+
123
+ async def prompt(self, safe_mode: bool = False) -> str:
124
+ return NOTEBOOK_EDIT_DESCRIPTION
125
+
126
+ def is_read_only(self) -> bool:
127
+ return False
128
+
129
+ def is_concurrency_safe(self) -> bool:
130
+ return False
131
+
132
+ def needs_permissions(self, input_data: Optional[NotebookEditInput] = None) -> bool:
133
+ return True
134
+
135
+ async def validate_input(
136
+ self, input_data: NotebookEditInput, context: Optional[ToolUseContext] = None
137
+ ) -> ValidationResult:
138
+ path = _resolve_path(input_data.notebook_path)
139
+
140
+ if not path.exists():
141
+ return ValidationResult(result=False, message="Notebook file does not exist.")
142
+ if path.suffix != ".ipynb":
143
+ return ValidationResult(
144
+ result=False,
145
+ message="File must be a Jupyter notebook (.ipynb file). Use Edit for other file types.",
146
+ )
147
+
148
+ mode = (input_data.edit_mode or "replace").lower()
149
+ if mode not in {"replace", "insert", "delete"}:
150
+ return ValidationResult(
151
+ result=False, message="edit_mode must be replace, insert, or delete."
152
+ )
153
+ if mode == "insert" and not input_data.cell_type:
154
+ return ValidationResult(
155
+ result=False,
156
+ message="cell_type is required when using edit_mode=insert.",
157
+ )
158
+ if mode != "insert" and not input_data.cell_id:
159
+ return ValidationResult(
160
+ result=False,
161
+ message="cell_id must be specified when using edit_mode=replace or delete.",
162
+ )
163
+
164
+ # Validate notebook structure and target cell.
165
+ try:
166
+ raw = path.read_text(encoding="utf-8")
167
+ nb_json = json.loads(raw)
168
+ except Exception as exc:
169
+ logger.error(f"Failed to parse notebook {path}: {exc}")
170
+ return ValidationResult(
171
+ result=False, message="Notebook is not valid JSON.", error_code=6
172
+ )
173
+
174
+ cells = nb_json.get("cells", [])
175
+ target_index, _ = self._resolve_cell_index(cells, input_data.cell_id, mode)
176
+ if target_index is None:
177
+ if mode == "insert" and input_data.cell_id is None:
178
+ return ValidationResult(result=True)
179
+ return ValidationResult(
180
+ result=False,
181
+ message=f"Cell '{input_data.cell_id}' not found in notebook.",
182
+ error_code=7,
183
+ )
184
+
185
+ return ValidationResult(result=True)
186
+
187
+ def render_result_for_assistant(self, output: NotebookEditOutput) -> str:
188
+ if output.error:
189
+ return output.error
190
+ action = output.edit_mode or "replace"
191
+ cell_label = output.cell_id or "(new cell)"
192
+ if action == "delete":
193
+ return f"Deleted cell {cell_label}"
194
+ if action == "insert":
195
+ return f"Inserted cell {cell_label}"
196
+ return f"Updated cell {cell_label}"
197
+
198
+ def render_tool_use_message(self, input_data: NotebookEditInput, verbose: bool = False) -> str:
199
+ parts = [f"path: {input_data.notebook_path}"]
200
+ if input_data.cell_id:
201
+ parts.append(f"cell_id: {input_data.cell_id}")
202
+ if verbose:
203
+ parts.append(f"mode: {input_data.edit_mode or 'replace'}")
204
+ return ", ".join(parts)
205
+
206
+ async def call(
207
+ self, input_data: NotebookEditInput, context: ToolUseContext
208
+ ) -> AsyncGenerator[ToolOutput, None]:
209
+ path = _resolve_path(input_data.notebook_path)
210
+ mode = (input_data.edit_mode or "replace").lower()
211
+ cell_type = (input_data.cell_type or "").lower() or None
212
+ new_source = input_data.new_source
213
+ cell_id = input_data.cell_id
214
+
215
+ try:
216
+ raw = path.read_text(encoding="utf-8")
217
+ nb_json = json.loads(raw)
218
+ cells = nb_json.get("cells", [])
219
+
220
+ target_index, matched_id = self._resolve_cell_index(cells, cell_id, mode)
221
+
222
+ final_mode = mode
223
+ if final_mode == "replace" and target_index is not None and target_index == len(cells):
224
+ final_mode = "insert"
225
+ if not cell_type:
226
+ cell_type = "code"
227
+
228
+ if final_mode == "delete":
229
+ if target_index is None:
230
+ raise ValueError("Target cell not found for delete.")
231
+ cells.pop(target_index)
232
+ elif final_mode == "insert":
233
+ insert_at = target_index if target_index is not None else 0
234
+ new_id = matched_id or _generate_cell_id()
235
+ new_cell_type = cell_type or "code"
236
+ new_cell = (
237
+ {
238
+ "cell_type": "markdown",
239
+ "id": new_id,
240
+ "source": new_source,
241
+ "metadata": {},
242
+ }
243
+ if new_cell_type == "markdown"
244
+ else {
245
+ "cell_type": "code",
246
+ "id": new_id,
247
+ "source": new_source,
248
+ "metadata": {},
249
+ "execution_count": 0,
250
+ "outputs": [],
251
+ }
252
+ )
253
+ cells.insert(insert_at, new_cell)
254
+ matched_id = new_id
255
+ cell_type = new_cell_type
256
+ else: # replace
257
+ if target_index is None:
258
+ raise ValueError("Target cell not found for replace.")
259
+ target_cell = cells[target_index]
260
+ target_cell["source"] = new_source
261
+ if target_cell.get("cell_type") == "code":
262
+ target_cell["execution_count"] = None
263
+ target_cell["outputs"] = []
264
+ if cell_type and cell_type != target_cell.get("cell_type"):
265
+ target_cell["cell_type"] = cell_type
266
+ matched_id = target_cell.get("id") or matched_id
267
+ cell_type = target_cell.get("cell_type", cell_type or "code")
268
+
269
+ nb_json["cells"] = cells
270
+ notebook_language = (
271
+ nb_json.get("metadata", {}).get("language_info", {}).get("name", "python")
272
+ )
273
+
274
+ path.write_text(json.dumps(nb_json, indent=1), encoding="utf-8")
275
+
276
+ output = NotebookEditOutput(
277
+ new_source=new_source,
278
+ cell_type=cell_type or "code",
279
+ language=notebook_language,
280
+ edit_mode=final_mode,
281
+ cell_id=matched_id,
282
+ error=None,
283
+ )
284
+ yield ToolResult(
285
+ data=output, result_for_assistant=self.render_result_for_assistant(output)
286
+ )
287
+ except Exception as exc: # pragma: no cover - error path
288
+ logger.error(f"Error editing notebook {input_data.notebook_path}: {exc}")
289
+ output = NotebookEditOutput(
290
+ new_source=new_source,
291
+ cell_type=cell_type or "code",
292
+ language="python",
293
+ edit_mode=mode,
294
+ cell_id=cell_id,
295
+ error=str(exc),
296
+ )
297
+ yield ToolResult(
298
+ data=output, result_for_assistant=self.render_result_for_assistant(output)
299
+ )
300
+
301
+ def _resolve_cell_index(
302
+ self, cells: list, cell_id: Optional[str], mode: str
303
+ ) -> tuple[Optional[int], Optional[str]]:
304
+ """Return target index and resolved id."""
305
+ if cell_id is None:
306
+ return (0 if mode == "insert" else None, None)
307
+
308
+ # Try numeric index first.
309
+ try:
310
+ idx = int(cell_id)
311
+ if mode == "insert":
312
+ if idx < 0 or idx > len(cells):
313
+ return None, None
314
+ return min(idx + 1, len(cells)), None
315
+ else:
316
+ if idx < 0 or idx >= len(cells):
317
+ return None, None
318
+ return idx, None
319
+ except (ValueError, TypeError):
320
+ pass
321
+
322
+ for i, cell in enumerate(cells):
323
+ if cell.get("id") == cell_id:
324
+ return (i if mode != "insert" else i + 1, cell.get("id"))
325
+ return None, None
@@ -0,0 +1,282 @@
1
+ """Task tool that delegates work to configured subagents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any, AsyncGenerator, Callable, Dict, Iterable, List, Optional
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+ from ripperdoc.core.agents import (
11
+ AgentDefinition,
12
+ AgentLoadResult,
13
+ clear_agent_cache,
14
+ load_agent_definitions,
15
+ resolve_agent_tools,
16
+ summarize_agent,
17
+ )
18
+ from ripperdoc.core.query import QueryContext, query
19
+ from ripperdoc.core.system_prompt import build_environment_prompt
20
+ from ripperdoc.core.tool import Tool, ToolOutput, ToolProgress, ToolResult, ToolUseContext
21
+ from ripperdoc.utils.messages import AssistantMessage, create_user_message
22
+
23
+
24
+ class TaskToolInput(BaseModel):
25
+ """Input schema for delegating to a subagent."""
26
+
27
+ prompt: str = Field(description="Detailed task description for the subagent to perform")
28
+ subagent_type: str = Field(description="Agent type to run (matches agent frontmatter name)")
29
+
30
+
31
+ class TaskToolOutput(BaseModel):
32
+ """Summary of a completed subagent run."""
33
+
34
+ agent_type: str
35
+ result_text: str
36
+ duration_ms: float
37
+ tool_use_count: int
38
+ missing_tools: List[str] = Field(default_factory=list)
39
+ model_used: Optional[str] = None
40
+
41
+
42
+ class TaskTool(Tool[TaskToolInput, TaskToolOutput]):
43
+ """Launches a configured agent in a fresh context."""
44
+
45
+ def __init__(self, available_tools_provider: Callable[[], Iterable[Tool[Any, Any]]]) -> None:
46
+ super().__init__()
47
+ self._available_tools_provider = available_tools_provider
48
+
49
+ @property
50
+ def name(self) -> str:
51
+ return "Task"
52
+
53
+ async def description(self) -> str:
54
+ clear_agent_cache()
55
+ agents = load_agent_definitions()
56
+ agent_lines = "\n".join(summarize_agent(agent) for agent in agents.active_agents)
57
+ return (
58
+ "Launch a specialized subagent in its own context window to handle a task.\n"
59
+ f"Available agents:\n{agent_lines or '- general-purpose (built-in)'}"
60
+ )
61
+
62
+ @property
63
+ def input_schema(self) -> type[TaskToolInput]:
64
+ return TaskToolInput
65
+
66
+ async def prompt(self, safe_mode: bool = False) -> str:
67
+ del safe_mode
68
+ clear_agent_cache()
69
+ agents: AgentLoadResult = load_agent_definitions()
70
+ agent_lines = "\n".join(summarize_agent(agent) for agent in agents.active_agents)
71
+ return (
72
+ "Use this tool to delegate a well-scoped task to a subagent. "
73
+ "Always set subagent_type to one of the available agent types below. "
74
+ "Provide a detailed prompt so the agent can work autonomously and return a single, concise report.\n\n"
75
+ f"Available agents:\n{agent_lines or '- general-purpose (built-in)'}"
76
+ )
77
+
78
+ def is_read_only(self) -> bool:
79
+ return True
80
+
81
+ def is_concurrency_safe(self) -> bool:
82
+ return True
83
+
84
+ def render_result_for_assistant(self, output: TaskToolOutput) -> str:
85
+ details: List[str] = []
86
+ if output.tool_use_count:
87
+ details.append(f"{output.tool_use_count} tool uses")
88
+ details.append(f"{output.duration_ms/1000:.1f}s")
89
+ if output.missing_tools:
90
+ details.append(f"missing tools: {', '.join(output.missing_tools)}")
91
+
92
+ suffix = f" ({'; '.join(details)})" if details else ""
93
+ return f"[subagent:{output.agent_type}] {output.result_text}{suffix}"
94
+
95
+ def render_tool_use_message(self, input_data: TaskToolInput, verbose: bool = False) -> str:
96
+ del verbose
97
+ return f"Task via {input_data.subagent_type}: {input_data.prompt}"
98
+
99
+ async def call(
100
+ self,
101
+ input_data: TaskToolInput,
102
+ context: ToolUseContext,
103
+ ) -> AsyncGenerator[ToolOutput, None]:
104
+ clear_agent_cache()
105
+ agents = load_agent_definitions()
106
+ target_agent = next(
107
+ (
108
+ agent
109
+ for agent in agents.active_agents
110
+ if agent.agent_type == input_data.subagent_type
111
+ ),
112
+ None,
113
+ )
114
+ if not target_agent:
115
+ raise ValueError(
116
+ f"Agent type '{input_data.subagent_type}' not found. "
117
+ f"Available agents: {', '.join(agent.agent_type for agent in agents.active_agents)}"
118
+ )
119
+
120
+ available_tools = list(self._available_tools_provider())
121
+ agent_tools, missing_tools = resolve_agent_tools(target_agent, available_tools, self.name)
122
+ if not agent_tools:
123
+ raise ValueError(
124
+ f"Agent '{target_agent.agent_type}' has no usable tools. "
125
+ f"Missing or unknown tools: {', '.join(missing_tools) if missing_tools else 'none'}"
126
+ )
127
+
128
+ agent_system_prompt = self._build_agent_prompt(target_agent, agent_tools)
129
+ subagent_messages = [create_user_message(input_data.prompt)]
130
+
131
+ subagent_context = QueryContext(
132
+ tools=agent_tools,
133
+ safe_mode=context.safe_mode,
134
+ verbose=context.verbose,
135
+ model=target_agent.model or "task",
136
+ )
137
+
138
+ start = time.time()
139
+ assistant_messages: List[AssistantMessage] = []
140
+ tool_use_count = 0
141
+
142
+ yield ToolProgress(content=f"Launching subagent '{target_agent.agent_type}'")
143
+
144
+ async for message in query(
145
+ subagent_messages,
146
+ agent_system_prompt,
147
+ {},
148
+ subagent_context,
149
+ context.permission_checker,
150
+ ):
151
+ if getattr(message, "type", "") == "assistant":
152
+ # Surface subagent tool requests as progress so the user sees activity.
153
+ msg_content = getattr(message, "message", None)
154
+ blocks = getattr(msg_content, "content", []) if msg_content else []
155
+ if isinstance(blocks, list):
156
+ for block in blocks:
157
+ block_type = getattr(block, "type", None) or (
158
+ block.get("type") if isinstance(block, Dict) else None
159
+ )
160
+ if block_type == "tool_use":
161
+ tool_name = getattr(block, "name", None) or (
162
+ block.get("name") if isinstance(block, Dict) else "unknown tool"
163
+ )
164
+ block_input = (
165
+ getattr(block, "input", None)
166
+ if hasattr(block, "input")
167
+ else (block.get("input") if isinstance(block, Dict) else None)
168
+ )
169
+ summary = self._summarize_tool_input(block_input)
170
+ label = f"Subagent requesting {tool_name}"
171
+ if summary:
172
+ label += f" — {summary}"
173
+ yield ToolProgress(content=label)
174
+ if block_type == "text":
175
+ text_val = getattr(block, "text", None) or (
176
+ block.get("text") if isinstance(block, Dict) else ""
177
+ )
178
+ if text_val:
179
+ snippet = str(text_val).strip()
180
+ if snippet:
181
+ short = (
182
+ snippet if len(snippet) <= 200 else snippet[:197] + "..."
183
+ )
184
+ yield ToolProgress(content=f"Subagent: {short}")
185
+ assistant_messages.append(message) # type: ignore[arg-type]
186
+ tool_use_count += self._count_tool_uses(message)
187
+
188
+ duration_ms = (time.time() - start) * 1000
189
+ result_text = (
190
+ self._extract_text(assistant_messages[-1])
191
+ if assistant_messages
192
+ else "Agent returned no response."
193
+ )
194
+
195
+ output = TaskToolOutput(
196
+ agent_type=target_agent.agent_type,
197
+ result_text=result_text.strip(),
198
+ duration_ms=duration_ms,
199
+ tool_use_count=tool_use_count,
200
+ missing_tools=missing_tools,
201
+ model_used=target_agent.model or "task",
202
+ )
203
+
204
+ yield ToolResult(data=output, result_for_assistant=self.render_result_for_assistant(output))
205
+
206
+ def _build_agent_prompt(self, agent: AgentDefinition, tools: List[Tool[Any, Any]]) -> str:
207
+ tool_names = ", ".join(tool.name for tool in tools if getattr(tool, "name", None))
208
+ guidance = (
209
+ "You are a specialized Ripperdoc subagent working autonomously. "
210
+ "Execute the task completely using the allowed tools. "
211
+ "Return a single, concise summary for the parent agent that includes what you did, "
212
+ "important findings, and any follow-ups. Do not ask the user questions."
213
+ )
214
+ sections = [
215
+ guidance,
216
+ f"Agent type: {agent.agent_type}",
217
+ f"When to use: {agent.when_to_use}",
218
+ f"Allowed tools: {tool_names}",
219
+ "Agent system prompt:",
220
+ agent.system_prompt or "(no additional prompt)",
221
+ build_environment_prompt(),
222
+ ]
223
+ return "\n\n".join(sections)
224
+
225
+ def _extract_text(self, message: AssistantMessage) -> str:
226
+ content = message.message.content
227
+ if isinstance(content, str):
228
+ return content
229
+ if isinstance(content, list):
230
+ parts: List[str] = []
231
+ for block in content:
232
+ text = getattr(block, "text", None) or (
233
+ block.get("text") if isinstance(block, Dict) else None
234
+ )
235
+ if text:
236
+ parts.append(str(text))
237
+ return "\n".join(parts)
238
+ return ""
239
+
240
+ def _count_tool_uses(self, message: AssistantMessage) -> int:
241
+ content = message.message.content
242
+ if not isinstance(content, list):
243
+ return 0
244
+ count = 0
245
+ for block in content:
246
+ block_type = getattr(block, "type", None) or (
247
+ block.get("type") if isinstance(block, Dict) else None
248
+ )
249
+ if block_type == "tool_use":
250
+ count += 1
251
+ return count
252
+
253
+ def _summarize_tool_input(self, inp: Any) -> str:
254
+ """Generate a short human-readable summary of a tool_use input."""
255
+ if not inp or not isinstance(inp, (dict, Dict)):
256
+ return ""
257
+
258
+ pieces: List[str] = []
259
+ # Prioritize common keys
260
+ for key in ("command", "file_path", "path", "glob", "pattern", "description", "prompt"):
261
+ if key in inp and inp[key]:
262
+ val = str(inp[key])
263
+ short = val if len(val) <= 80 else val[:77] + "..."
264
+ pieces.append(f"{key}={short}")
265
+
266
+ # Include range info if present
267
+ start = inp.get("start_line") or inp.get("offset")
268
+ end = inp.get("end_line") or inp.get("limit")
269
+ if start is not None or end is not None:
270
+ pieces.append(f"range={start or 0}-{end or '…'}")
271
+
272
+ if not pieces:
273
+ # Fallback to truncated dict representation
274
+ import json
275
+
276
+ try:
277
+ serialized = json.dumps(inp, ensure_ascii=False)
278
+ except Exception:
279
+ serialized = str(inp)
280
+ return serialized if len(serialized) <= 120 else serialized[:117] + "..."
281
+
282
+ return ", ".join(pieces)