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
agent/subagent.py ADDED
@@ -0,0 +1,634 @@
1
+ """Subagent runner for synchronous task delegation."""
2
+
3
+ import time
4
+ import uuid
5
+ from pathlib import Path
6
+ from typing import Callable, Optional
7
+
8
+ from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, ToolMessage
9
+
10
+ from agent.approval import ApprovalCallback, ApprovalTargetMissing
11
+ from agent.llm_retry import chat_with_retry
12
+ from agent.logger import get_logger
13
+ from agent.message_format import messages_to_provider_format
14
+ from agent.providers.base import LLMProvider
15
+ from agent.runtime.approval_service import ApprovalService
16
+ from agent.runtime.context import AgentRuntimeContext, WorkflowState
17
+ from agent.runtime.tool_output import build_tool_output_view
18
+ from agent.runtime.tool_scheduler import execute_tool_calls
19
+ from agent.skills import LoadedSkill, SkillRegistry
20
+ from agent.streaming import StreamEvent, StreamEventCallback, make_provider_stream_callback
21
+ from agent.todo_manager import TodoManager
22
+ from tools import TOOLS
23
+
24
+
25
+ DEFAULT_MAX_TURNS = 30
26
+ MAX_OUTPUT_CHARS = 20_000
27
+ SUBAGENT_TOOL_TIMEOUT_SECONDS = 3600 # 1 hour for subagent tools
28
+ SUBAGENT_TOOL_RETRIES = 2
29
+ logger = get_logger(__name__)
30
+
31
+
32
+ ROLE_PROMPTS = {
33
+ "explorer": (
34
+ "You are an explorer subagent. Your job is to investigate the requested task in "
35
+ "the codebase and return concise, evidence-backed findings. Prefer read-only "
36
+ "actions and cite relevant files or commands. Do not modify files unless the task "
37
+ "explicitly asks you to."
38
+ ),
39
+ "architect": (
40
+ "You are an architect subagent. Your job is to design a focused technical approach "
41
+ "for the requested task. Analyze architecture, interfaces, data flow, tradeoffs, "
42
+ "risks, and migration concerns. Prefer plans and recommendations over code changes "
43
+ "unless the task explicitly asks you to edit files."
44
+ ),
45
+ "tester": (
46
+ "You are a tester subagent. Your job is to design or execute verification for the "
47
+ "requested task. Identify important test scenarios, add or update focused tests "
48
+ "when asked, run relevant checks when possible, and report coverage, failures, and "
49
+ "remaining risk clearly."
50
+ ),
51
+ "security": (
52
+ "You are a security subagent. Your job is to review code and changes for security "
53
+ "risks. Look for unsafe input handling, injection paths, secret exposure, auth or "
54
+ "permission flaws, insecure file/network operations, dependency risks, and unsafe "
55
+ "command execution. Prefer read-only review unless explicitly asked to edit files, "
56
+ "and report findings with severity, evidence, and concrete remediation."
57
+ ),
58
+ "worker": (
59
+ "You are a worker subagent. Your job is to complete the requested implementation "
60
+ "subtask in the shared workspace. Stay tightly scoped, avoid unrelated changes, "
61
+ "and return a concise summary of changes plus any verification you ran."
62
+ ),
63
+ }
64
+
65
+
66
+ def filter_subagent_tool(tools: list[dict]) -> list[dict]:
67
+ """Return tools safe for focused subagent work."""
68
+ blocked_tools = {"subagent", "todo"}
69
+ return [tool for tool in tools if tool.get("name") not in blocked_tools]
70
+
71
+
72
+ def build_subagent_system_prompt(
73
+ role: str,
74
+ workdir: Path,
75
+ parent_prompt: str,
76
+ skill_catalog_prompt: str = "",
77
+ loaded_skills_prompt: str = "",
78
+ ) -> str:
79
+ """Build the role-specific system prompt for a child agent."""
80
+ role_prompt = ROLE_PROMPTS[role]
81
+ prompt = f"""You are a delegated coding subagent at {workdir}.
82
+ {role_prompt}
83
+
84
+ Important constraints:
85
+ - Stay scoped to the assigned task and use tools only when needed.
86
+ - You have an isolated conversation history.
87
+ - Do not use todo planning; the parent agent owns overall task planning.
88
+ - For semantic code navigation, prefer LSP tools when available:
89
+ lsp_workspace_symbols, lsp_document_symbols, lsp_definition, lsp_references,
90
+ lsp_hover, and lsp_diagnostics. Fall back to grep/read_file when LSP is
91
+ unavailable, returns no_results, or plain text search is more appropriate.
92
+ - LSP line and character inputs are zero-based. Model-facing locations are
93
+ displayed as one-based file:line:character.
94
+ - Use list_skills to discover skills and load_skill to load only the skill instructions
95
+ you need for this task.
96
+ - You must not delegate to another subagent.
97
+ - Return only the information needed by the parent agent to continue.
98
+ - Be concise: return at most 5 bullets unless the task explicitly asks for detail."""
99
+ if skill_catalog_prompt:
100
+ prompt = f"{prompt}\n\n{skill_catalog_prompt}"
101
+ if loaded_skills_prompt:
102
+ prompt = f"{prompt}\n\n{loaded_skills_prompt}"
103
+ return prompt
104
+
105
+ def format_subagent_result(
106
+ role: str,
107
+ session_id: str,
108
+ hit_turn_limit: bool,
109
+ content: str,
110
+ skills: Optional[list[str]] = None,
111
+ ) -> str:
112
+ """Format a subagent result for the parent agent."""
113
+ if len(content) > MAX_OUTPUT_CHARS:
114
+ content = content[:MAX_OUTPUT_CHARS] + "\n\n[Subagent output truncated.]"
115
+
116
+ status = "hit_turn_limit" if hit_turn_limit else "completed"
117
+ return (
118
+ f"Subagent result\n"
119
+ f"role: {role}\n"
120
+ f"skills: {', '.join(skills) if skills else 'none'}\n"
121
+ f"session_id: {session_id}\n"
122
+ f"status: {status}\n\n"
123
+ f"{content}"
124
+ )
125
+
126
+
127
+ class SubagentRunner:
128
+ """Run isolated child agents with bounded tool loops."""
129
+
130
+ def __init__(
131
+ self,
132
+ provider: LLMProvider,
133
+ workdir: Path,
134
+ parent_system_prompt: str,
135
+ tool_handlers: dict[str, Callable],
136
+ tools: Optional[list[dict]] = None,
137
+ parent_session_id: Optional[str] = None,
138
+ skill_dirs: Optional[list[str]] = None,
139
+ app_root: Path | None = None,
140
+ stream_callback: Optional[StreamEventCallback] = None,
141
+ approval_callback: Optional[ApprovalCallback] = None,
142
+ ):
143
+ self.provider = provider
144
+ self.workdir = workdir
145
+ self.app_root = app_root
146
+ self.parent_system_prompt = parent_system_prompt
147
+ self.parent_session_id = parent_session_id
148
+ self.skill_dirs = skill_dirs
149
+ self.skill_registry = SkillRegistry(workdir, skill_dirs)
150
+ self.skill_catalog_prompt = self.skill_registry.format_skill_catalog_prompt()
151
+ self.list_skills_handler = lambda: self.skill_registry.format_skill_list()
152
+ self.load_skill_handler = lambda names: self.skill_registry.format_loaded_skills(names)
153
+ self.stream_callback = stream_callback
154
+ self.approval_callback = approval_callback
155
+ self.last_usage: Optional[dict[str, int]] = None
156
+ self.workflow_state = WorkflowState()
157
+ self._active_session_id: Optional[str] = None
158
+ self._active_role: Optional[str] = None
159
+ self.tool_handlers = {
160
+ name: handler
161
+ for name, handler in tool_handlers.items()
162
+ if name != "subagent"
163
+ }
164
+ self.tools = filter_subagent_tool(tools or TOOLS)
165
+
166
+ async def run(
167
+ self,
168
+ role: str,
169
+ task: str,
170
+ context: str = "",
171
+ max_turns: int = DEFAULT_MAX_TURNS,
172
+ skills: Optional[list[str]] = None,
173
+ ) -> str:
174
+ """Run a subagent and return a formatted summary result."""
175
+ if role not in ROLE_PROMPTS:
176
+ return f"Error: Unknown subagent role: {role}"
177
+ explicit_skills = self._normalize_skills(skills)
178
+ loaded_skills = self._load_explicit_skills(explicit_skills)
179
+ if isinstance(loaded_skills, str):
180
+ return loaded_skills
181
+
182
+ max_turns = self._normalize_max_turns(max_turns)
183
+ self.last_usage = {
184
+ "input_tokens": 0,
185
+ "output_tokens": 0,
186
+ "total_tokens": 0,
187
+ }
188
+ self.workflow_state = WorkflowState()
189
+ session_id = str(uuid.uuid4())
190
+ self._active_session_id = session_id
191
+ self._active_role = role
192
+ provider_stream_callback = make_provider_stream_callback(
193
+ self.stream_callback,
194
+ source="subagent",
195
+ session_id=session_id,
196
+ role=role,
197
+ parent_session_id=self.parent_session_id,
198
+ )
199
+ system_prompt = build_subagent_system_prompt(
200
+ role,
201
+ self.workdir,
202
+ self.parent_system_prompt,
203
+ self.skill_catalog_prompt,
204
+ self._format_explicit_skills_prompt(loaded_skills),
205
+ )
206
+ prompt = self._build_user_prompt(task, context, explicit_skills)
207
+ messages: list[BaseMessage] = [HumanMessage(content=prompt)]
208
+ hit_turn_limit = True
209
+ start_time = time.perf_counter()
210
+ await self._emit_subagent_started(session_id, role, task, explicit_skills)
211
+
212
+ try:
213
+ for _ in range(max_turns):
214
+ provider_messages = messages_to_provider_format(messages)
215
+ response = await chat_with_retry(
216
+ self.provider,
217
+ messages=provider_messages,
218
+ tools=self.tools,
219
+ system_prompt=system_prompt,
220
+ stream_callback=provider_stream_callback,
221
+ event_callback=self.stream_callback,
222
+ source="subagent",
223
+ session_id=session_id,
224
+ role=role,
225
+ parent_session_id=self.parent_session_id,
226
+ )
227
+ self._accumulate_usage(response.usage)
228
+ if self.stream_callback and response.usage:
229
+ await self.stream_callback(
230
+ StreamEvent(
231
+ source="subagent",
232
+ session_id=session_id,
233
+ role=role,
234
+ parent_session_id=self.parent_session_id,
235
+ event_type="usage",
236
+ usage=response.usage,
237
+ )
238
+ )
239
+ tool_calls = [
240
+ {
241
+ "name": tc.name,
242
+ "args": dict(tc.args or {}),
243
+ "id": tc.id,
244
+ }
245
+ for tc in response.tool_calls
246
+ ]
247
+ ai_msg = AIMessage(content=response.content, tool_calls=tool_calls)
248
+ ai_msg.additional_kwargs["tool_calls_data"] = response.tool_calls
249
+ if response.content_blocks:
250
+ ai_msg.additional_kwargs["provider_blocks"] = response.content_blocks
251
+ messages.append(ai_msg)
252
+
253
+ if not response.tool_calls:
254
+ hit_turn_limit = False
255
+ break
256
+
257
+ tool_messages = await self._run_tool_calls(response.tool_calls, session_id, role)
258
+ messages.extend(tool_messages)
259
+ except Exception:
260
+ await self._emit_subagent_finished(
261
+ session_id,
262
+ role,
263
+ task,
264
+ "failed",
265
+ int((time.perf_counter() - start_time) * 1000),
266
+ explicit_skills,
267
+ )
268
+ self._active_session_id = None
269
+ self._active_role = None
270
+ raise
271
+
272
+ if hit_turn_limit:
273
+ try:
274
+ await self._synthesize_after_turn_limit(
275
+ messages,
276
+ system_prompt,
277
+ provider_stream_callback,
278
+ session_id,
279
+ role,
280
+ )
281
+ except Exception as exc:
282
+ logger.debug("Subagent turn-limit synthesis failed; falling back to tool output: %s", exc)
283
+ final_content = self._final_content(messages)
284
+ status = "hit_turn_limit" if hit_turn_limit else "completed"
285
+ await self._emit_subagent_finished(
286
+ session_id,
287
+ role,
288
+ task,
289
+ status,
290
+ int((time.perf_counter() - start_time) * 1000),
291
+ explicit_skills,
292
+ )
293
+ result = format_subagent_result(role, session_id, hit_turn_limit, final_content, explicit_skills)
294
+ self._active_session_id = None
295
+ self._active_role = None
296
+ return result
297
+
298
+ async def _run_tool_calls(self, tool_calls, session_id: str, role: str) -> list[ToolMessage]:
299
+ """Execute subagent tool calls through shared runtime registry/scheduler/approval."""
300
+ from agent.runtime.tool_registry import RuntimeToolRegistry
301
+
302
+ runtime = AgentRuntimeContext(
303
+ provider=self.provider,
304
+ system_prompt=self.parent_system_prompt,
305
+ todo_manager=TodoManager(),
306
+ workdir=self.workdir,
307
+ session_id=session_id,
308
+ source="subagent",
309
+ role=role,
310
+ parent_session_id=self.parent_session_id,
311
+ skill_dirs=self.skill_dirs,
312
+ app_root=self.app_root,
313
+ stream_callback=self.stream_callback,
314
+ approval_callback=self.approval_callback,
315
+ tools=self.tools,
316
+ tool_handlers=self.tool_handlers,
317
+ workflow_state=self.workflow_state,
318
+ run_tool=self._run_runtime_tool,
319
+ )
320
+ registry = RuntimeToolRegistry(runtime)
321
+ approval_service = ApprovalService(
322
+ self.approval_callback,
323
+ self.workflow_state,
324
+ self.stream_callback,
325
+ session_id,
326
+ source="subagent",
327
+ role=role,
328
+ parent_session_id=self.parent_session_id,
329
+ workdir=self.workdir,
330
+ )
331
+
332
+ async def execute(tc) -> ToolMessage:
333
+ return await self._execute_tool_call(tc, registry, approval_service)
334
+
335
+ return await execute_tool_calls(tool_calls, execute, registry.can_run_concurrently)
336
+
337
+ async def _execute_tool_call(
338
+ self,
339
+ tc,
340
+ registry,
341
+ approval_service: ApprovalService,
342
+ ) -> ToolMessage:
343
+ handler = registry.resolve(tc.name)
344
+ try:
345
+ approved_args = await approval_service.approve(tc.name, tc.args or {})
346
+ except ApprovalTargetMissing as exc:
347
+ await self._emit_tool_blocked(tc.name, str(exc))
348
+ return self._tool_message(tc, str(exc))
349
+ output = await self._run_runtime_tool(
350
+ handler,
351
+ tc.name,
352
+ max_retries=SUBAGENT_TOOL_RETRIES,
353
+ timeout_seconds=registry.timeout_for(tc.name),
354
+ **approved_args,
355
+ )
356
+ output_view = build_tool_output_view(tc.name, output, tc)
357
+ tool_message = self._tool_message(tc, output_view.model)
358
+ if output_view.context_policy != "full":
359
+ tool_message.additional_kwargs["context_policy"] = output_view.context_policy
360
+ return tool_message
361
+
362
+ async def _run_runtime_tool(self, handler: Optional[Callable], tool_name: str, **kwargs) -> str:
363
+ from agent.tool_retry import async_run_tool_with_retry
364
+
365
+ return await async_run_tool_with_retry(
366
+ handler,
367
+ tool_name,
368
+ max_retries=kwargs.pop("max_retries", SUBAGENT_TOOL_RETRIES),
369
+ timeout_seconds=kwargs.pop("timeout_seconds", SUBAGENT_TOOL_TIMEOUT_SECONDS),
370
+ **kwargs,
371
+ )
372
+
373
+ def _tool_message(self, tc, output: str) -> ToolMessage:
374
+ return ToolMessage(content=output, tool_call_id=tc.id, name=tc.name)
375
+
376
+ async def _synthesize_after_turn_limit(
377
+ self,
378
+ messages: list[BaseMessage],
379
+ system_prompt: str,
380
+ provider_stream_callback,
381
+ session_id: str,
382
+ role: str,
383
+ ) -> None:
384
+ """Ask for a final no-tool summary after the delegated tool budget is exhausted."""
385
+ messages.append(
386
+ HumanMessage(
387
+ content=(
388
+ "You have reached the delegated tool-turn limit. Do not call any tools. "
389
+ "Based only on the information already gathered in this subagent conversation, "
390
+ "return a concise final summary for the parent agent. Include key findings, "
391
+ "evidence, and any remaining uncertainty."
392
+ )
393
+ )
394
+ )
395
+ response = await chat_with_retry(
396
+ self.provider,
397
+ messages=messages_to_provider_format(messages),
398
+ tools=[],
399
+ system_prompt=system_prompt,
400
+ stream_callback=provider_stream_callback,
401
+ event_callback=self.stream_callback,
402
+ source="subagent",
403
+ session_id=session_id,
404
+ role=role,
405
+ parent_session_id=self.parent_session_id,
406
+ )
407
+ self._accumulate_usage(response.usage)
408
+ if self.stream_callback and response.usage:
409
+ await self.stream_callback(
410
+ StreamEvent(
411
+ source="subagent",
412
+ session_id=session_id,
413
+ role=role,
414
+ parent_session_id=self.parent_session_id,
415
+ event_type="usage",
416
+ usage=response.usage,
417
+ )
418
+ )
419
+ ai_msg = AIMessage(content=response.content)
420
+ if response.content_blocks:
421
+ ai_msg.additional_kwargs["provider_blocks"] = response.content_blocks
422
+ messages.append(ai_msg)
423
+
424
+ async def _emit_tool_blocked(self, tool_name: str, content: str) -> None:
425
+ if self.stream_callback is None:
426
+ return
427
+ await self.stream_callback(
428
+ StreamEvent(
429
+ source="subagent",
430
+ session_id=self._active_session_id or "",
431
+ role=self._active_role,
432
+ parent_session_id=self.parent_session_id,
433
+ event_type="tool_result",
434
+ content=content,
435
+ title="File edit blocked",
436
+ detail="No target file detected",
437
+ phase="blocked",
438
+ status="failed",
439
+ tool_name=tool_name,
440
+ )
441
+ )
442
+
443
+ def _build_user_prompt(self, task: str, context: str, skills: list[str] | None = None) -> str:
444
+ explicit = ""
445
+ if skills:
446
+ explicit = "Explicit skills:\n" + "\n".join(f"- {skill}" for skill in skills) + "\n\n"
447
+ if context:
448
+ return f"{explicit}Task:\n{task}\n\nContext:\n{context}"
449
+ return f"{explicit}Task:\n{task}"
450
+
451
+ def _normalize_skills(self, skills: Optional[list[str]]) -> list[str]:
452
+ normalized: list[str] = []
453
+ for skill in skills or []:
454
+ name = str(skill).strip()
455
+ if name.startswith("/"):
456
+ name = name[1:]
457
+ if name and name not in normalized:
458
+ normalized.append(name)
459
+ return normalized
460
+
461
+ def _load_explicit_skills(self, skills: list[str]) -> list[LoadedSkill] | str:
462
+ if not skills:
463
+ return []
464
+ loaded = self.skill_registry.load_skills(skills)
465
+ loaded_names = {skill.name for skill in loaded}
466
+ missing = [skill for skill in skills if skill not in loaded_names]
467
+ if not missing:
468
+ return loaded
469
+ available = ", ".join(skill.name for skill in self.skill_registry.list_skills()) or "(none)"
470
+ return (
471
+ f"Error: Unknown skill: /{missing[0]}\n"
472
+ f"Available skills: {available}"
473
+ )
474
+
475
+ def _format_explicit_skills_prompt(self, skills: list[LoadedSkill]) -> str:
476
+ if not skills:
477
+ return ""
478
+ sections = [
479
+ "Explicit skills selected by the parent:",
480
+ *[f"- {skill.name}" for skill in skills],
481
+ "",
482
+ "Loaded skill instructions:",
483
+ ]
484
+ for skill in skills:
485
+ sections.append("")
486
+ sections.append(f"## {skill.name}")
487
+ sections.append(f"Source: {skill.path}")
488
+ if skill.description:
489
+ sections.append(f"Description: {skill.description}")
490
+ sections.append("")
491
+ sections.append(skill.content)
492
+ sections.append("")
493
+ sections.append("You must follow the loaded skill instructions for this delegated task.")
494
+ sections.append("If a selected skill is not suitable for the task, mention that in your result.")
495
+ return "\n".join(sections).rstrip()
496
+
497
+ def _last_ai_content(self, messages: list[BaseMessage]) -> str:
498
+ for msg in reversed(messages):
499
+ if isinstance(msg, AIMessage):
500
+ return str(msg.content or "")
501
+ return ""
502
+
503
+ def _final_content(self, messages: list[BaseMessage]) -> str:
504
+ content = self._last_ai_content(messages).strip()
505
+ if content:
506
+ return content
507
+ tool_outputs = [
508
+ str(msg.content or "").strip()
509
+ for msg in messages
510
+ if isinstance(msg, ToolMessage) and str(msg.content or "").strip()
511
+ ]
512
+ if not tool_outputs:
513
+ return "Subagent stopped without producing a final text response."
514
+ recent_outputs = tool_outputs[-3:]
515
+ return (
516
+ "Subagent stopped without producing a final text response. "
517
+ "Recent tool output is included so the parent can continue:\n\n"
518
+ + "\n\n---\n\n".join(recent_outputs)
519
+ )
520
+
521
+ def _normalize_max_turns(self, max_turns: int) -> int:
522
+ try:
523
+ max_turns = int(max_turns)
524
+ except (TypeError, ValueError):
525
+ max_turns = DEFAULT_MAX_TURNS
526
+ return min(max(max_turns, 1), DEFAULT_MAX_TURNS)
527
+
528
+ def _accumulate_usage(self, usage: Optional[dict[str, int]]) -> None:
529
+ """Accumulate real provider usage across the subagent run."""
530
+ if not usage or self.last_usage is None:
531
+ return
532
+ self.last_usage["input_tokens"] += usage.get("input_tokens", 0)
533
+ self.last_usage["output_tokens"] += usage.get("output_tokens", 0)
534
+ self.last_usage["total_tokens"] += usage.get("total_tokens", 0)
535
+
536
+ async def _emit_subagent_started(
537
+ self,
538
+ session_id: str,
539
+ role: str,
540
+ task: str,
541
+ skills: Optional[list[str]] = None,
542
+ ) -> None:
543
+ if self.stream_callback is None:
544
+ return
545
+ await self.stream_callback(
546
+ StreamEvent(
547
+ source="subagent",
548
+ session_id=session_id,
549
+ role=role,
550
+ parent_session_id=self.parent_session_id,
551
+ event_type="subagent_started",
552
+ content=task,
553
+ title=f"{role} subagent started",
554
+ detail=task,
555
+ phase="implementing" if role == "worker" else "exploring",
556
+ status="running",
557
+ metadata={"task": task, "skills": skills or []},
558
+ )
559
+ )
560
+
561
+ async def _emit_subagent_finished(
562
+ self,
563
+ session_id: str,
564
+ role: str,
565
+ task: str,
566
+ status: str,
567
+ elapsed_ms: int,
568
+ skills: Optional[list[str]] = None,
569
+ ) -> None:
570
+ if self.stream_callback is None:
571
+ return
572
+ await self.stream_callback(
573
+ StreamEvent(
574
+ source="subagent",
575
+ session_id=session_id,
576
+ role=role,
577
+ parent_session_id=self.parent_session_id,
578
+ event_type="subagent_finished",
579
+ content=status,
580
+ title=f"{role} subagent finished",
581
+ detail=task,
582
+ phase="implementing" if role == "worker" else "exploring",
583
+ status=status,
584
+ elapsed_ms=elapsed_ms,
585
+ metadata={"task": task, "skills": skills or []},
586
+ )
587
+ )
588
+
589
+ async def _emit_approval_required(self, request) -> None:
590
+ if self.stream_callback is None:
591
+ return
592
+ await self.stream_callback(
593
+ StreamEvent(
594
+ source="subagent",
595
+ session_id=self._active_session_id or self.parent_session_id or "",
596
+ role=self._active_role,
597
+ parent_session_id=self.parent_session_id,
598
+ event_type="approval_required",
599
+ content=request.format(include_diff=False),
600
+ title="Approve subagent action",
601
+ detail=request.path or request.command or request.reason,
602
+ phase="blocked",
603
+ status="waiting_for_user",
604
+ tool_name=request.tool_name,
605
+ file_paths=[request.path] if request.path else None,
606
+ metadata={
607
+ "action": request.action,
608
+ "reason": request.reason,
609
+ "risk": request.risk,
610
+ "diff_preview": request.diff_preview,
611
+ },
612
+ )
613
+ )
614
+
615
+ async def _emit_approval_resolved(self, request, status: str) -> None:
616
+ if self.stream_callback is None:
617
+ return
618
+ await self.stream_callback(
619
+ StreamEvent(
620
+ source="subagent",
621
+ session_id=self._active_session_id or self.parent_session_id or "",
622
+ role=self._active_role,
623
+ parent_session_id=self.parent_session_id,
624
+ event_type="approval_resolved",
625
+ content=status,
626
+ title="Subagent approval resolved",
627
+ detail=request.path or request.command or request.reason,
628
+ phase="blocked" if status == "denied" else "implementing",
629
+ status=status,
630
+ tool_name=request.tool_name,
631
+ file_paths=[request.path] if request.path else None,
632
+ metadata={"action": request.action},
633
+ )
634
+ )