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.
- agent/__init__.py +33 -0
- agent/acp/__init__.py +2 -0
- agent/acp/approval_adapter.py +134 -0
- agent/acp/content_adapter.py +45 -0
- agent/acp/jsonrpc.py +92 -0
- agent/acp/server.py +197 -0
- agent/acp/session_manager.py +193 -0
- agent/acp/update_adapter.py +192 -0
- agent/app_paths.py +25 -0
- agent/approval.py +169 -0
- agent/cancellation.py +52 -0
- agent/change_snapshot.py +186 -0
- agent/context_compressor.py +116 -0
- agent/graph.py +137 -0
- agent/llm_retry.py +434 -0
- agent/logger.py +97 -0
- agent/lsp/__init__.py +13 -0
- agent/lsp/client.py +151 -0
- agent/lsp/manager.py +234 -0
- agent/lsp/types.py +119 -0
- agent/message_context_manager.py +322 -0
- agent/message_format.py +105 -0
- agent/nodes/llm_node.py +58 -0
- agent/nodes/state.py +12 -0
- agent/nodes/task_guard_node.py +50 -0
- agent/nodes/tools_node.py +70 -0
- agent/plan_snapshot.py +70 -0
- agent/providers/__init__.py +13 -0
- agent/providers/anthropic_provider.py +268 -0
- agent/providers/base.py +52 -0
- agent/providers/openai_provider.py +279 -0
- agent/providers/text_tool_calls.py +118 -0
- agent/runtime/approval_service.py +184 -0
- agent/runtime/context.py +43 -0
- agent/runtime/tool_events.py +368 -0
- agent/runtime/tool_executor.py +208 -0
- agent/runtime/tool_output.py +261 -0
- agent/runtime/tool_registry.py +91 -0
- agent/runtime/tool_scheduler.py +35 -0
- agent/runtime/workflow_guard.py +217 -0
- agent/runtime/workspace.py +5 -0
- agent/runtime/workspace_tools.py +22 -0
- agent/session.py +787 -0
- agent/session_replay.py +95 -0
- agent/session_store.py +186 -0
- agent/skills.py +254 -0
- agent/streaming.py +248 -0
- agent/subagent.py +634 -0
- agent/task_memory.py +340 -0
- agent/todo_manager.py +304 -0
- agent/tool_retry.py +106 -0
- agent/tui/__init__.py +14 -0
- agent/tui/app.py +1325 -0
- agent/tui/approval.py +53 -0
- agent/tui/commands/__init__.py +6 -0
- agent/tui/commands/base.py +48 -0
- agent/tui/commands/clear.py +37 -0
- agent/tui/commands/help.py +27 -0
- agent/tui/commands/registry.py +94 -0
- agent/tui/help_content.py +108 -0
- agent/tui/renderers.py +1961 -0
- agent/tui/runner.py +439 -0
- agent/tui/state.py +653 -0
- main.py +465 -0
- tools/__init__.py +50 -0
- tools/apply_patch.py +305 -0
- tools/bash.py +76 -0
- tools/diff_utils.py +139 -0
- tools/edit_file.py +40 -0
- tools/git_diff.py +72 -0
- tools/git_show.py +65 -0
- tools/grep.py +149 -0
- tools/list_files.py +90 -0
- tools/list_skills.py +24 -0
- tools/load_skill.py +30 -0
- tools/lsp_definition.py +27 -0
- tools/lsp_diagnostics.py +32 -0
- tools/lsp_document_symbols.py +23 -0
- tools/lsp_hover.py +29 -0
- tools/lsp_references.py +37 -0
- tools/lsp_utils.py +38 -0
- tools/lsp_workspace_symbols.py +23 -0
- tools/read_file.py +61 -0
- tools/read_many_files.py +50 -0
- tools/safety.py +50 -0
- tools/subagent.py +57 -0
- tools/todo.py +89 -0
- tools/verify.py +107 -0
- tools/web_search.py +250 -0
- tools/workspace.py +36 -0
- tools/workspace_state.py +60 -0
- tools/write_file.py +88 -0
- utils/__init__.py +5 -0
- utils/retry.py +13 -0
- yycode-0.3.2.data/data/skills/code_review.md +61 -0
- yycode-0.3.2.data/data/skills/code_workflow.md +404 -0
- yycode-0.3.2.data/data/skills/drawio/SKILL.md +636 -0
- yycode-0.3.2.data/data/skills/drawio/agents/openai.yaml +19 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-erd.drawio +84 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-layered-cn.drawio +91 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-layered-cn.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-layered.drawio +112 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-layered.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ml.drawio +90 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ring-cn.drawio +68 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ring-cn.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ring.drawio +86 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ring.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-sequence.drawio +116 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-star-cn.drawio +66 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-star-cn.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-star.drawio +79 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-star.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-uml-class.drawio +64 -0
- yycode-0.3.2.data/data/skills/drawio/assets/microservices-example.drawio +173 -0
- yycode-0.3.2.data/data/skills/drawio/assets/microservices-example.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/workflow-cn.drawio +120 -0
- yycode-0.3.2.data/data/skills/drawio/assets/workflow-cn.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/workflow.drawio +120 -0
- yycode-0.3.2.data/data/skills/drawio/assets/workflow.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/docs/index.html +469 -0
- yycode-0.3.2.data/data/skills/drawio/docs/zh.html +456 -0
- yycode-0.3.2.data/data/skills/drawio/references/style-extraction.md +254 -0
- yycode-0.3.2.data/data/skills/drawio/styles/schema.json +112 -0
- yycode-0.3.2.data/data/skills/plan.md +115 -0
- yycode-0.3.2.data/data/skills/ppt/SKILL.md +254 -0
- yycode-0.3.2.dist-info/METADATA +12 -0
- yycode-0.3.2.dist-info/RECORD +131 -0
- yycode-0.3.2.dist-info/WHEEL +5 -0
- yycode-0.3.2.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|