yycode 0.3.2__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 (131) hide show
  1. agent/__init__.py +33 -0
  2. agent/acp/__init__.py +2 -0
  3. agent/acp/approval_adapter.py +134 -0
  4. agent/acp/content_adapter.py +45 -0
  5. agent/acp/jsonrpc.py +92 -0
  6. agent/acp/server.py +197 -0
  7. agent/acp/session_manager.py +193 -0
  8. agent/acp/update_adapter.py +192 -0
  9. agent/app_paths.py +25 -0
  10. agent/approval.py +169 -0
  11. agent/cancellation.py +52 -0
  12. agent/change_snapshot.py +186 -0
  13. agent/context_compressor.py +116 -0
  14. agent/graph.py +137 -0
  15. agent/llm_retry.py +434 -0
  16. agent/logger.py +97 -0
  17. agent/lsp/__init__.py +13 -0
  18. agent/lsp/client.py +151 -0
  19. agent/lsp/manager.py +234 -0
  20. agent/lsp/types.py +119 -0
  21. agent/message_context_manager.py +322 -0
  22. agent/message_format.py +105 -0
  23. agent/nodes/llm_node.py +58 -0
  24. agent/nodes/state.py +12 -0
  25. agent/nodes/task_guard_node.py +50 -0
  26. agent/nodes/tools_node.py +70 -0
  27. agent/plan_snapshot.py +70 -0
  28. agent/providers/__init__.py +13 -0
  29. agent/providers/anthropic_provider.py +268 -0
  30. agent/providers/base.py +52 -0
  31. agent/providers/openai_provider.py +279 -0
  32. agent/providers/text_tool_calls.py +118 -0
  33. agent/runtime/approval_service.py +184 -0
  34. agent/runtime/context.py +43 -0
  35. agent/runtime/tool_events.py +368 -0
  36. agent/runtime/tool_executor.py +208 -0
  37. agent/runtime/tool_output.py +261 -0
  38. agent/runtime/tool_registry.py +91 -0
  39. agent/runtime/tool_scheduler.py +35 -0
  40. agent/runtime/workflow_guard.py +217 -0
  41. agent/runtime/workspace.py +5 -0
  42. agent/runtime/workspace_tools.py +22 -0
  43. agent/session.py +787 -0
  44. agent/session_replay.py +95 -0
  45. agent/session_store.py +186 -0
  46. agent/skills.py +254 -0
  47. agent/streaming.py +248 -0
  48. agent/subagent.py +634 -0
  49. agent/task_memory.py +340 -0
  50. agent/todo_manager.py +304 -0
  51. agent/tool_retry.py +106 -0
  52. agent/tui/__init__.py +14 -0
  53. agent/tui/app.py +1325 -0
  54. agent/tui/approval.py +53 -0
  55. agent/tui/commands/__init__.py +6 -0
  56. agent/tui/commands/base.py +48 -0
  57. agent/tui/commands/clear.py +37 -0
  58. agent/tui/commands/help.py +27 -0
  59. agent/tui/commands/registry.py +94 -0
  60. agent/tui/help_content.py +108 -0
  61. agent/tui/renderers.py +1961 -0
  62. agent/tui/runner.py +439 -0
  63. agent/tui/state.py +653 -0
  64. main.py +465 -0
  65. tools/__init__.py +50 -0
  66. tools/apply_patch.py +305 -0
  67. tools/bash.py +76 -0
  68. tools/diff_utils.py +139 -0
  69. tools/edit_file.py +40 -0
  70. tools/git_diff.py +72 -0
  71. tools/git_show.py +65 -0
  72. tools/grep.py +149 -0
  73. tools/list_files.py +90 -0
  74. tools/list_skills.py +24 -0
  75. tools/load_skill.py +30 -0
  76. tools/lsp_definition.py +27 -0
  77. tools/lsp_diagnostics.py +32 -0
  78. tools/lsp_document_symbols.py +23 -0
  79. tools/lsp_hover.py +29 -0
  80. tools/lsp_references.py +37 -0
  81. tools/lsp_utils.py +38 -0
  82. tools/lsp_workspace_symbols.py +23 -0
  83. tools/read_file.py +61 -0
  84. tools/read_many_files.py +50 -0
  85. tools/safety.py +50 -0
  86. tools/subagent.py +57 -0
  87. tools/todo.py +89 -0
  88. tools/verify.py +107 -0
  89. tools/web_search.py +250 -0
  90. tools/workspace.py +36 -0
  91. tools/workspace_state.py +60 -0
  92. tools/write_file.py +88 -0
  93. utils/__init__.py +5 -0
  94. utils/retry.py +13 -0
  95. yycode-0.3.2.data/data/skills/code_review.md +61 -0
  96. yycode-0.3.2.data/data/skills/code_workflow.md +404 -0
  97. yycode-0.3.2.data/data/skills/drawio/SKILL.md +636 -0
  98. yycode-0.3.2.data/data/skills/drawio/agents/openai.yaml +19 -0
  99. yycode-0.3.2.data/data/skills/drawio/assets/demo-erd.drawio +84 -0
  100. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered-cn.drawio +91 -0
  101. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered-cn.png +0 -0
  102. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered.drawio +112 -0
  103. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered.png +0 -0
  104. yycode-0.3.2.data/data/skills/drawio/assets/demo-ml.drawio +90 -0
  105. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring-cn.drawio +68 -0
  106. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring-cn.png +0 -0
  107. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring.drawio +86 -0
  108. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring.png +0 -0
  109. yycode-0.3.2.data/data/skills/drawio/assets/demo-sequence.drawio +116 -0
  110. yycode-0.3.2.data/data/skills/drawio/assets/demo-star-cn.drawio +66 -0
  111. yycode-0.3.2.data/data/skills/drawio/assets/demo-star-cn.png +0 -0
  112. yycode-0.3.2.data/data/skills/drawio/assets/demo-star.drawio +79 -0
  113. yycode-0.3.2.data/data/skills/drawio/assets/demo-star.png +0 -0
  114. yycode-0.3.2.data/data/skills/drawio/assets/demo-uml-class.drawio +64 -0
  115. yycode-0.3.2.data/data/skills/drawio/assets/microservices-example.drawio +173 -0
  116. yycode-0.3.2.data/data/skills/drawio/assets/microservices-example.png +0 -0
  117. yycode-0.3.2.data/data/skills/drawio/assets/workflow-cn.drawio +120 -0
  118. yycode-0.3.2.data/data/skills/drawio/assets/workflow-cn.png +0 -0
  119. yycode-0.3.2.data/data/skills/drawio/assets/workflow.drawio +120 -0
  120. yycode-0.3.2.data/data/skills/drawio/assets/workflow.png +0 -0
  121. yycode-0.3.2.data/data/skills/drawio/docs/index.html +469 -0
  122. yycode-0.3.2.data/data/skills/drawio/docs/zh.html +456 -0
  123. yycode-0.3.2.data/data/skills/drawio/references/style-extraction.md +254 -0
  124. yycode-0.3.2.data/data/skills/drawio/styles/schema.json +112 -0
  125. yycode-0.3.2.data/data/skills/plan.md +115 -0
  126. yycode-0.3.2.data/data/skills/ppt/SKILL.md +254 -0
  127. yycode-0.3.2.dist-info/METADATA +12 -0
  128. yycode-0.3.2.dist-info/RECORD +131 -0
  129. yycode-0.3.2.dist-info/WHEEL +5 -0
  130. yycode-0.3.2.dist-info/entry_points.txt +2 -0
  131. yycode-0.3.2.dist-info/top_level.txt +4 -0
@@ -0,0 +1,368 @@
1
+ """Formatting helpers for tool stream events."""
2
+
3
+ import re
4
+
5
+
6
+ MAX_TOOL_RESULT_PREVIEW_CHARS = 12_000
7
+
8
+
9
+ def format_tool_description(tc) -> str:
10
+ """Format a tool call for display."""
11
+ tool_name = tc.name
12
+ args = tc.args or {}
13
+ if tool_name == "bash":
14
+ cmd = args.get("command", "")
15
+ cmd_preview = cmd[:40] + "..." if len(cmd) > 40 else cmd
16
+ return f"{tool_name}: {cmd_preview}"
17
+ if tool_name in {"read_file", "write_file", "edit_file"}:
18
+ path = args.get("path", "")
19
+ return f"{tool_name}: {path}"
20
+ if tool_name == "todo":
21
+ items = args.get("items", [])
22
+ return f"{tool_name}: {len(items)} item(s)"
23
+ if tool_name == "subagent":
24
+ role = args.get("role", "")
25
+ task = args.get("task", "")
26
+ if role and task:
27
+ task_preview = task[:30] + "..." if len(task) > 30 else task
28
+ return f"{tool_name} @{role}: {task_preview}"
29
+ return tool_name
30
+ return tool_name
31
+
32
+
33
+ def format_tool_event_metadata(tc) -> dict:
34
+ """Return timeline-friendly metadata for a tool call."""
35
+ tool_name = tc.name
36
+ args = tc.args or {}
37
+ metadata = {"args": args}
38
+
39
+ if tool_name == "read_file":
40
+ path = args.get("path", "")
41
+ start_line = args.get("start_line")
42
+ end_line = args.get("end_line")
43
+ if start_line or end_line:
44
+ detail = f"{path} lines {start_line or 1}-{end_line or 'end'}"
45
+ title = "Read file range"
46
+ else:
47
+ detail = path
48
+ title = "Read file"
49
+ return {
50
+ "title": title,
51
+ "detail": detail,
52
+ "phase": "exploring",
53
+ "tool_name": tool_name,
54
+ "file_paths": [path] if path else None,
55
+ "metadata": metadata,
56
+ }
57
+
58
+ if tool_name == "read_many_files":
59
+ paths = args.get("paths") or []
60
+ return {
61
+ "title": "Read files",
62
+ "detail": ", ".join(paths[:3]) + ("..." if len(paths) > 3 else ""),
63
+ "phase": "exploring",
64
+ "tool_name": tool_name,
65
+ "file_paths": paths,
66
+ "metadata": metadata,
67
+ }
68
+
69
+ if tool_name == "grep":
70
+ pattern = args.get("pattern", "")
71
+ path = args.get("path", ".")
72
+ search_metadata = _grep_search_metadata(pattern, path)
73
+ metadata.update(search_metadata)
74
+ return {
75
+ "title": "Search code",
76
+ "detail": search_metadata["search_display"],
77
+ "phase": "exploring",
78
+ "tool_name": tool_name,
79
+ "file_paths": [path],
80
+ "metadata": metadata,
81
+ }
82
+
83
+ if tool_name in {"list_files", "git_show", "workspace_state", "git_diff"}:
84
+ return {
85
+ "title": _title_for_readonly_tool(tool_name),
86
+ "detail": _detail_for_path_args(args),
87
+ "phase": "exploring",
88
+ "tool_name": tool_name,
89
+ "file_paths": _file_paths_from_args(args),
90
+ "metadata": metadata,
91
+ }
92
+
93
+ if tool_name.startswith("lsp_"):
94
+ return {
95
+ "title": _title_for_lsp_tool(tool_name),
96
+ "detail": _detail_for_lsp_args(args),
97
+ "phase": "semantic_navigation",
98
+ "tool_name": tool_name,
99
+ "file_paths": _file_paths_from_args(args),
100
+ "metadata": metadata,
101
+ }
102
+
103
+ if tool_name in {"apply_patch", "write_file", "edit_file"}:
104
+ return {
105
+ "title": _title_for_write_tool(tool_name),
106
+ "detail": _detail_for_path_args(args),
107
+ "phase": "implementing",
108
+ "tool_name": tool_name,
109
+ "file_paths": _file_paths_from_args(args),
110
+ "metadata": metadata,
111
+ }
112
+
113
+ if tool_name == "verify":
114
+ target = args.get("target") or args.get("command") or args.get("kind", "")
115
+ return {
116
+ "title": "Run verification",
117
+ "detail": str(target),
118
+ "phase": "verifying",
119
+ "tool_name": tool_name,
120
+ "metadata": metadata,
121
+ }
122
+
123
+ if tool_name == "todo":
124
+ items = args.get("items") or []
125
+ active = next((item for item in items if item.get("status") == "in_progress"), None)
126
+ return {
127
+ "title": "Update task plan",
128
+ "detail": active.get("text", "") if active else f"{len(items)} item(s)",
129
+ "phase": "planning",
130
+ "tool_name": tool_name,
131
+ "metadata": metadata,
132
+ }
133
+
134
+ if tool_name == "subagent":
135
+ role = args.get("role", "subagent")
136
+ task = args.get("task", "")
137
+ return {
138
+ "title": f"Start {role} subagent",
139
+ "detail": task,
140
+ "phase": "implementing" if role == "worker" else "exploring",
141
+ "tool_name": tool_name,
142
+ "metadata": metadata,
143
+ }
144
+
145
+ if tool_name == "bash":
146
+ command = args.get("command", "")
147
+ return {
148
+ "title": _title_for_bash(command),
149
+ "detail": command,
150
+ "phase": _phase_for_bash(command),
151
+ "tool_name": tool_name,
152
+ "metadata": {"command": command, "args": args},
153
+ }
154
+
155
+ return {
156
+ "title": f"Run {tool_name}",
157
+ "detail": format_tool_description(tc),
158
+ "tool_name": tool_name,
159
+ "metadata": metadata,
160
+ }
161
+
162
+
163
+ def file_paths_for_tool_call(tc) -> list[str]:
164
+ """Return workspace paths referenced by a tool call when obvious."""
165
+ return _file_paths_from_args(tc.args or {}) or []
166
+
167
+
168
+ def diff_preview_from_output(output: str) -> str:
169
+ """Extract a bounded diff preview from a tool output string."""
170
+ marker = "\ndiff:\n"
171
+ if marker in output:
172
+ preview = output.split(marker, 1)[1]
173
+ elif output.startswith("diff:\n"):
174
+ preview = output[len("diff:\n") :]
175
+ else:
176
+ preview = output
177
+ if len(preview) > MAX_TOOL_RESULT_PREVIEW_CHARS:
178
+ return preview[:MAX_TOOL_RESULT_PREVIEW_CHARS] + (
179
+ f"\n... diff preview truncated to {MAX_TOOL_RESULT_PREVIEW_CHARS} chars"
180
+ )
181
+ return preview
182
+
183
+
184
+ def tool_output_indicates_successful_write(output: str) -> bool:
185
+ """Return whether a workspace write output looks successful."""
186
+ return not output.startswith(
187
+ (
188
+ "Error:",
189
+ "approval_required:",
190
+ "Code workflow guard blocked",
191
+ )
192
+ )
193
+
194
+
195
+ def _title_for_readonly_tool(tool_name: str) -> str:
196
+ return {
197
+ "list_files": "List files",
198
+ "git_show": "Read file from git",
199
+ "workspace_state": "Check workspace state",
200
+ "git_diff": "Review git diff",
201
+ }.get(tool_name, f"Run {tool_name}")
202
+
203
+
204
+ def _title_for_lsp_tool(tool_name: str) -> str:
205
+ return {
206
+ "lsp_document_symbols": "LSP document symbols",
207
+ "lsp_workspace_symbols": "LSP workspace symbols",
208
+ "lsp_definition": "LSP definition",
209
+ "lsp_references": "LSP references",
210
+ "lsp_hover": "LSP hover",
211
+ "lsp_diagnostics": "LSP diagnostics",
212
+ }.get(tool_name, f"LSP {tool_name.removeprefix('lsp_').replace('_', ' ')}")
213
+
214
+
215
+ def _detail_for_lsp_args(args: dict) -> str:
216
+ path = args.get("path")
217
+ query = args.get("query")
218
+ line = args.get("line")
219
+ character = args.get("character")
220
+ parts: list[str] = []
221
+ if path:
222
+ parts.append(str(path))
223
+ if query:
224
+ parts.append(f"query={query}")
225
+ if line is not None and character is not None:
226
+ parts.append(f"position={line}:{character}")
227
+ return " · ".join(parts)
228
+
229
+
230
+ def _title_for_write_tool(tool_name: str) -> str:
231
+ return {
232
+ "apply_patch": "Apply patch",
233
+ "write_file": "Create file",
234
+ "edit_file": "Edit file",
235
+ }.get(tool_name, f"Run {tool_name}")
236
+
237
+
238
+ def _detail_for_path_args(args: dict) -> str:
239
+ paths = _file_paths_from_args(args)
240
+ if paths:
241
+ return ", ".join(paths)
242
+ command = args.get("command")
243
+ return str(command or "")
244
+
245
+
246
+ def _grep_search_metadata(pattern: object, path: object) -> dict:
247
+ pattern_text = str(pattern or "")
248
+ path_text = str(path or ".")
249
+ display_path = "workspace" if path_text == "." else path_text
250
+ terms = _grep_search_terms(pattern_text)
251
+ term_count = len(terms)
252
+ keyword_label = _keyword_label(term_count)
253
+ return {
254
+ "search_display": f"Searching {display_path} · {keyword_label}",
255
+ "pattern_preview": _truncate(pattern_text, 80),
256
+ "search_terms": terms[:5],
257
+ "term_count": term_count,
258
+ "path": display_path,
259
+ }
260
+
261
+
262
+ def _grep_search_terms(pattern: str) -> list[str]:
263
+ if not pattern:
264
+ return []
265
+ raw_parts = pattern.split("|") if "|" in pattern else [pattern]
266
+ terms: list[str] = []
267
+ seen: set[str] = set()
268
+ for raw in raw_parts:
269
+ term = _clean_regex_term(raw)
270
+ if not term or term in seen:
271
+ continue
272
+ seen.add(term)
273
+ terms.append(term)
274
+ if not terms and pattern.strip():
275
+ return ["regex query"]
276
+ return terms
277
+
278
+
279
+ def _clean_regex_term(raw: str) -> str:
280
+ term = raw.strip()
281
+ term = term.strip("()")
282
+ term = term.replace("\\_", "_").replace("\\-", "-").replace("\\.", ".")
283
+ term = term.replace("\\/", "/").replace("\\:", ":")
284
+ term = re.sub(r"\\b|\\B|\\A|\\Z", "", term)
285
+ if re.search(r"[\[\]\{\}\+\*\?\^\$]", term):
286
+ return "regex query"
287
+ term = re.sub(r"\\(.)", r"\1", term)
288
+ term = term.strip()
289
+ return _truncate(term, 48) if term else ""
290
+
291
+
292
+ def _keyword_label(count: int) -> str:
293
+ if count <= 0:
294
+ return "regex query"
295
+ if count == 1:
296
+ return "1 keyword"
297
+ return f"{count} keywords"
298
+
299
+
300
+ def _truncate(text: str, limit: int) -> str:
301
+ if len(text) <= limit:
302
+ return text
303
+ return text[: max(0, limit - 3)] + "..."
304
+
305
+
306
+ def _file_paths_from_args(args: dict) -> list[str]:
307
+ paths = args.get("paths")
308
+ if isinstance(paths, list):
309
+ return [str(path) for path in paths]
310
+ path = args.get("path")
311
+ if path:
312
+ return [str(path)]
313
+ patch = args.get("patch")
314
+ if patch:
315
+ return _paths_from_unified_diff(str(patch))
316
+ return []
317
+
318
+
319
+ def _title_for_bash(command: str) -> str:
320
+ if _is_drawio_command(command):
321
+ if "--version" in command:
322
+ return "Check draw.io CLI"
323
+ if " -x " in f" {command} " or " --export " in f" {command} ":
324
+ return "Export draw.io diagram"
325
+ return "Run draw.io command"
326
+ if command.startswith(("pytest", "ruff", "mypy")):
327
+ return "Run verification"
328
+ if command.startswith("git status"):
329
+ return "Check workspace state"
330
+ if command.startswith("git diff"):
331
+ return "Review git diff"
332
+ if command.startswith(("sed ", "grep ", "rg ")):
333
+ return "Inspect workspace"
334
+ return "Run command"
335
+
336
+
337
+ def _phase_for_bash(command: str) -> str:
338
+ if _is_drawio_command(command):
339
+ return "verifying" if "--version" in command else "implementing"
340
+ if command.startswith(("pytest", "ruff", "mypy")):
341
+ return "verifying"
342
+ return "exploring"
343
+
344
+
345
+ def _is_drawio_command(command: str) -> bool:
346
+ return any(token in command for token in ("draw.io", "drawio"))
347
+
348
+
349
+ def _paths_from_unified_diff(patch: str) -> list[str]:
350
+ paths: list[str] = []
351
+ for line in patch.splitlines():
352
+ path = None
353
+ begin_patch_match = re.match(r"\*\*\* (?:Add|Update|Delete) File: (.+)$", line)
354
+ if begin_patch_match:
355
+ path = begin_patch_match.group(1).strip()
356
+ elif line.startswith("*** Move to: "):
357
+ path = line[len("*** Move to: "):].strip()
358
+ elif line.startswith("diff --git "):
359
+ match = re.match(r"diff --git a/(.+?) b/(.+)$", line)
360
+ if match:
361
+ path = match.group(2)
362
+ elif line.startswith("+++ "):
363
+ raw = line[4:].split("\t", 1)[0].strip()
364
+ if raw != "/dev/null":
365
+ path = raw[2:] if raw.startswith("b/") else raw
366
+ if path and path not in paths:
367
+ paths.append(path)
368
+ return paths
@@ -0,0 +1,208 @@
1
+ """Single tool call execution pipeline."""
2
+
3
+ import time
4
+
5
+ from langchain_core.messages import ToolMessage
6
+
7
+ from agent.approval import ApprovalDenied, ApprovalTargetMissing
8
+ from agent.logger import get_logger
9
+ from agent.runtime.approval_service import ApprovalService
10
+ from agent.runtime.context import AgentRuntimeContext
11
+ from agent.runtime.tool_events import (
12
+ diff_preview_from_output,
13
+ file_paths_for_tool_call,
14
+ format_tool_description,
15
+ format_tool_event_metadata,
16
+ )
17
+ from agent.runtime.tool_output import build_tool_output_view, compact_preflight_output
18
+ from agent.runtime.tool_registry import RuntimeToolRegistry
19
+ from agent.runtime.workflow_guard import WorkflowGuard
20
+ from agent.streaming import StreamEvent
21
+
22
+ logger = get_logger(__name__)
23
+
24
+
25
+ class ToolExecutor:
26
+ """Execute one tool call with guardrails, approval, and stream events."""
27
+
28
+ def __init__(
29
+ self,
30
+ runtime: AgentRuntimeContext,
31
+ registry: RuntimeToolRegistry,
32
+ workflow_guard: WorkflowGuard,
33
+ approval_service: ApprovalService,
34
+ ):
35
+ self.runtime = runtime
36
+ self.registry = registry
37
+ self.workflow_guard = workflow_guard
38
+ self.approval_service = approval_service
39
+
40
+ async def execute(self, tc) -> ToolMessage:
41
+ """Execute a tool call and return a ToolMessage."""
42
+ start_time = time.perf_counter()
43
+ status = "completed"
44
+ await self._emit_tool_start(tc)
45
+ try:
46
+ logger.debug(f"Calling tool: {getattr(tc, 'name', 'unknown')}")
47
+ logger.debug(f"Full tc object: {tc!r}")
48
+ logger.debug(f"tc type: {type(tc)}")
49
+ logger.debug(f"tc.name: {getattr(tc, 'name', 'N/A')!r}")
50
+ logger.debug(f"tc.args: {getattr(tc, 'args', 'N/A')!r}")
51
+ logger.debug(f"tc.id: {getattr(tc, 'id', 'N/A')!r}")
52
+
53
+ if self.registry.is_workspace_write(tc.name) and not self.workflow_guard.has_preflight():
54
+ output = await self.workflow_guard.run_preflight()
55
+ return self._tool_message(tc, compact_preflight_output(output))
56
+
57
+ if self.workflow_guard.should_require_apply_patch(tc):
58
+ return self._tool_message(
59
+ tc,
60
+ self.workflow_guard.apply_patch_required_message(tc),
61
+ )
62
+
63
+ try:
64
+ approved_args = await self.approval_service.approve(tc.name, tc.args or {})
65
+ except ApprovalTargetMissing as exc:
66
+ status = "failed"
67
+ await self._emit_tool_result(
68
+ str(exc),
69
+ title="File edit blocked",
70
+ detail="No target file detected",
71
+ phase="blocked",
72
+ )
73
+ return self._tool_message(tc, str(exc))
74
+ runner = self.registry.create_subagent_runner() if tc.name == "subagent" else None
75
+ handler = runner.run if runner else self.registry.resolve(tc.name)
76
+ output = await self.runtime.run_tool(
77
+ handler,
78
+ tc.name,
79
+ max_retries=3,
80
+ timeout_seconds=self.registry.timeout_for(tc.name),
81
+ **approved_args,
82
+ )
83
+ output_view = build_tool_output_view(tc.name, output, tc)
84
+
85
+ logger.debug(f"Tool output: {output[:200]}...")
86
+ logger.debug(f"End tool: {getattr(tc, 'name', 'unknown')}")
87
+ tool_message = self._tool_message(tc, output_view.model)
88
+ if output_view.context_policy != "full":
89
+ tool_message.additional_kwargs["context_policy"] = output_view.context_policy
90
+ if runner and runner.last_usage:
91
+ tool_message.additional_kwargs["usage"] = dict(runner.last_usage)
92
+
93
+ if tc.name == "todo":
94
+ await self._emit_tool_result(
95
+ output_view.display,
96
+ title="Task Plan",
97
+ detail="Updated todo items and task memory",
98
+ phase="planning",
99
+ )
100
+
101
+ should_emit_diff = self.workflow_guard.update_after_tool(tc, output)
102
+ if should_emit_diff:
103
+ await self._emit_tool_result(diff_preview_from_output(output_view.display))
104
+ await self._emit_file_changed(tc)
105
+ return tool_message
106
+ except ApprovalDenied:
107
+ status = "failed"
108
+ raise
109
+ except Exception as exc:
110
+ status = "failed"
111
+ output = f"Error executing tool {tc.name}: {exc}"
112
+ logger.exception("Tool execution failed: %s", getattr(tc, "name", "unknown"))
113
+ await self._emit_tool_result(
114
+ output,
115
+ title="Tool failed",
116
+ detail=tc.name,
117
+ phase="blocked",
118
+ )
119
+ return self._tool_message(tc, output)
120
+ finally:
121
+ elapsed_ms = int((time.perf_counter() - start_time) * 1000)
122
+ await self._emit_tool_end(tc, status, elapsed_ms)
123
+
124
+ def _tool_message(self, tc, output: str) -> ToolMessage:
125
+ return ToolMessage(
126
+ content=output,
127
+ tool_call_id=tc.id,
128
+ name=tc.name,
129
+ )
130
+
131
+ async def _emit_tool_start(self, tc) -> None:
132
+ if not self.runtime.stream_callback:
133
+ return
134
+ await self.runtime.stream_callback(
135
+ StreamEvent(
136
+ source=self.runtime.source,
137
+ session_id=self.runtime.session_id,
138
+ role=self.runtime.role,
139
+ parent_session_id=self.runtime.parent_session_id,
140
+ event_type="tool_start",
141
+ content=format_tool_description(tc),
142
+ status="running",
143
+ **format_tool_event_metadata(tc),
144
+ )
145
+ )
146
+
147
+ async def _emit_tool_end(self, tc, status: str, elapsed_ms: int) -> None:
148
+ if not self.runtime.stream_callback:
149
+ return
150
+ await self.runtime.stream_callback(
151
+ StreamEvent(
152
+ source=self.runtime.source,
153
+ session_id=self.runtime.session_id,
154
+ role=self.runtime.role,
155
+ parent_session_id=self.runtime.parent_session_id,
156
+ event_type="tool_end",
157
+ content=tc.name,
158
+ status=status,
159
+ elapsed_ms=elapsed_ms,
160
+ **format_tool_event_metadata(tc),
161
+ )
162
+ )
163
+
164
+ async def _emit_tool_result(
165
+ self,
166
+ content: str,
167
+ *,
168
+ title: str = "Review diff",
169
+ detail: str = "Workspace changes produced a diff preview",
170
+ phase: str = "reviewing",
171
+ ) -> None:
172
+ if not self.runtime.stream_callback:
173
+ return
174
+ await self.runtime.stream_callback(
175
+ StreamEvent(
176
+ source=self.runtime.source,
177
+ session_id=self.runtime.session_id,
178
+ role=self.runtime.role,
179
+ parent_session_id=self.runtime.parent_session_id,
180
+ event_type="tool_result",
181
+ content=content,
182
+ title=title,
183
+ detail=detail,
184
+ phase=phase,
185
+ )
186
+ )
187
+
188
+ async def _emit_file_changed(self, tc) -> None:
189
+ if not self.runtime.stream_callback:
190
+ return
191
+ file_paths = file_paths_for_tool_call(tc)
192
+ await self.runtime.stream_callback(
193
+ StreamEvent(
194
+ source=self.runtime.source,
195
+ session_id=self.runtime.session_id,
196
+ role=self.runtime.role,
197
+ parent_session_id=self.runtime.parent_session_id,
198
+ event_type="file_changed",
199
+ content=", ".join(file_paths),
200
+ title="File changed",
201
+ detail=", ".join(file_paths),
202
+ phase="implementing",
203
+ status="completed",
204
+ tool_name=tc.name,
205
+ file_paths=file_paths,
206
+ metadata={"tool_call_id": tc.id},
207
+ )
208
+ )