voidx 1.0.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 (126) hide show
  1. voidx/__init__.py +3 -0
  2. voidx/agent/__init__.py +0 -0
  3. voidx/agent/agents.py +439 -0
  4. voidx/agent/attachments.py +235 -0
  5. voidx/agent/graph.py +463 -0
  6. voidx/agent/graph_components/__init__.py +1 -0
  7. voidx/agent/graph_components/compaction.py +268 -0
  8. voidx/agent/graph_components/permissions.py +139 -0
  9. voidx/agent/graph_components/run_loop.py +532 -0
  10. voidx/agent/graph_components/runtime.py +14 -0
  11. voidx/agent/graph_components/streaming.py +351 -0
  12. voidx/agent/graph_components/subagent.py +278 -0
  13. voidx/agent/graph_components/tool_execution.py +208 -0
  14. voidx/agent/runtime_context.py +368 -0
  15. voidx/agent/slash.py +466 -0
  16. voidx/agent/slash_components/__init__.py +1 -0
  17. voidx/agent/slash_components/code_ide.py +68 -0
  18. voidx/agent/slash_components/lsp.py +105 -0
  19. voidx/agent/slash_components/mcp.py +332 -0
  20. voidx/agent/slash_components/model.py +419 -0
  21. voidx/agent/slash_components/runtime.py +55 -0
  22. voidx/agent/slash_components/skills.py +94 -0
  23. voidx/agent/state.py +32 -0
  24. voidx/agent/task_state.py +278 -0
  25. voidx/agent/tool_filters.py +27 -0
  26. voidx/config.py +707 -0
  27. voidx/llm/__init__.py +0 -0
  28. voidx/llm/catalog.py +188 -0
  29. voidx/llm/compaction.py +267 -0
  30. voidx/llm/context.py +43 -0
  31. voidx/llm/instruction.py +220 -0
  32. voidx/llm/provider.py +312 -0
  33. voidx/llm/usage.py +341 -0
  34. voidx/lsp/__init__.py +30 -0
  35. voidx/lsp/client.py +259 -0
  36. voidx/lsp/config.py +172 -0
  37. voidx/lsp/detector.py +512 -0
  38. voidx/lsp/errors.py +19 -0
  39. voidx/lsp/manager.py +280 -0
  40. voidx/lsp/schema.py +179 -0
  41. voidx/lsp/service.py +103 -0
  42. voidx/main.py +154 -0
  43. voidx/mcp/__init__.py +33 -0
  44. voidx/mcp/client.py +458 -0
  45. voidx/mcp/manager.py +267 -0
  46. voidx/mcp/schema.py +112 -0
  47. voidx/mcp/tool.py +122 -0
  48. voidx/mcp_servers/__init__.py +1 -0
  49. voidx/mcp_servers/web.py +104 -0
  50. voidx/memory/__init__.py +0 -0
  51. voidx/memory/context_frames.py +188 -0
  52. voidx/memory/model_profiles.py +98 -0
  53. voidx/memory/runtime_state.py +240 -0
  54. voidx/memory/session.py +272 -0
  55. voidx/memory/store.py +245 -0
  56. voidx/memory/transcript.py +137 -0
  57. voidx/permission/__init__.py +28 -0
  58. voidx/permission/engine.py +430 -0
  59. voidx/permission/evaluate.py +114 -0
  60. voidx/permission/sandbox.py +280 -0
  61. voidx/permission/schema.py +24 -0
  62. voidx/permission/service.py +314 -0
  63. voidx/permission/wildcard.py +34 -0
  64. voidx/skills/__init__.py +18 -0
  65. voidx/skills/bundled/superpowers/receiving-code-review/SKILL.md +30 -0
  66. voidx/skills/bundled/superpowers/requesting-code-review/SKILL.md +27 -0
  67. voidx/skills/bundled/superpowers/systematic-debugging/SKILL.md +36 -0
  68. voidx/skills/bundled/superpowers/test-driven-development/SKILL.md +33 -0
  69. voidx/skills/bundled/superpowers/verification-before-completion/SKILL.md +31 -0
  70. voidx/skills/bundled/superpowers/writing-plans/SKILL.md +27 -0
  71. voidx/skills/policy.py +97 -0
  72. voidx/skills/registry.py +162 -0
  73. voidx/skills/schema.py +47 -0
  74. voidx/skills/service.py +199 -0
  75. voidx/tools/__init__.py +0 -0
  76. voidx/tools/agent.py +81 -0
  77. voidx/tools/base.py +86 -0
  78. voidx/tools/bash.py +105 -0
  79. voidx/tools/file_ops.py +193 -0
  80. voidx/tools/lsp.py +155 -0
  81. voidx/tools/registry.py +104 -0
  82. voidx/tools/repomap.py +238 -0
  83. voidx/tools/search.py +162 -0
  84. voidx/tools/task_status.py +57 -0
  85. voidx/tools/task_tracker.py +81 -0
  86. voidx/tools/todo.py +82 -0
  87. voidx/tools/web_content.py +357 -0
  88. voidx/tools/web_mcp.py +107 -0
  89. voidx/tools/webfetch.py +155 -0
  90. voidx/tools/websearch.py +276 -0
  91. voidx/ui/__init__.py +0 -0
  92. voidx/ui/app.py +1033 -0
  93. voidx/ui/app_components/__init__.py +1 -0
  94. voidx/ui/app_components/clipboard_image.py +245 -0
  95. voidx/ui/app_components/commands.py +18 -0
  96. voidx/ui/app_components/controls.py +29 -0
  97. voidx/ui/app_components/file_picker.py +115 -0
  98. voidx/ui/app_components/formatting.py +187 -0
  99. voidx/ui/app_components/git_changes.py +51 -0
  100. voidx/ui/app_components/rendering.py +1169 -0
  101. voidx/ui/browse.py +160 -0
  102. voidx/ui/capture.py +169 -0
  103. voidx/ui/code_ide.py +251 -0
  104. voidx/ui/commands.py +83 -0
  105. voidx/ui/console.py +381 -0
  106. voidx/ui/console_components/__init__.py +1 -0
  107. voidx/ui/console_components/formatting.py +96 -0
  108. voidx/ui/console_components/streaming.py +253 -0
  109. voidx/ui/diff.py +331 -0
  110. voidx/ui/dock.py +372 -0
  111. voidx/ui/dock_components/__init__.py +1 -0
  112. voidx/ui/dock_components/formatting.py +123 -0
  113. voidx/ui/dock_components/nodes.py +401 -0
  114. voidx/ui/dock_components/state.py +51 -0
  115. voidx/ui/event_components/__init__.py +1 -0
  116. voidx/ui/event_components/schema.py +249 -0
  117. voidx/ui/events.py +341 -0
  118. voidx/ui/session_changes.py +163 -0
  119. voidx/ui/startup.py +161 -0
  120. voidx/ui/transcript.py +148 -0
  121. voidx/ui/tree.py +316 -0
  122. voidx-1.0.0.dist-info/METADATA +59 -0
  123. voidx-1.0.0.dist-info/RECORD +126 -0
  124. voidx-1.0.0.dist-info/WHEEL +5 -0
  125. voidx-1.0.0.dist-info/entry_points.txt +2 -0
  126. voidx-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,351 @@
1
+ """LLM streaming helpers for agent execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import json
7
+ import re
8
+ import uuid
9
+
10
+ from langchain_core.messages import AIMessage, AIMessageChunk, ToolMessage
11
+
12
+ from voidx.ui.console import StreamingRenderer
13
+
14
+ _REPLAY_UNSAFE_BLOCK_TYPES = {
15
+ "thinking",
16
+ "redacted_thinking",
17
+ "reasoning",
18
+ "reasoning_content",
19
+ }
20
+ _DSML_TOOL_CALLS_RE = re.compile(
21
+ r"<\|+DSML\|+tool_calls\b[^>]*>.*?</\|+DSML\|+tool_calls>",
22
+ re.DOTALL,
23
+ )
24
+ _DSML_INVOKE_RE = re.compile(
25
+ r"<\|+DSML\|+invoke\b([^>]*)>(.*?)</\|+DSML\|+invoke>",
26
+ re.DOTALL,
27
+ )
28
+ _DSML_PARAMETER_RE = re.compile(
29
+ r"<\|+DSML\|+parameter\b([^>]*)>(.*?)</\|+DSML\|+parameter>",
30
+ re.DOTALL,
31
+ )
32
+ _DSML_ATTR_RE = re.compile(r'([A-Za-z_][\w:-]*)="([^"]*)"')
33
+ _DSML_BOILERPLATE_RE = re.compile(
34
+ r"(commands?\s*(列表|list).*(注册|register)|注册.*commands?\s*(列表|list))",
35
+ re.IGNORECASE,
36
+ )
37
+
38
+
39
+ async def stream_llm(
40
+ model,
41
+ messages: list,
42
+ renderer: StreamingRenderer,
43
+ protocol: str = "",
44
+ ) -> AIMessage:
45
+ """Stream LLM response, render live, return merged AIMessage."""
46
+ from voidx.llm.provider import extract_thinking
47
+
48
+ chunks: list[AIMessageChunk] = []
49
+ renderer.start()
50
+
51
+ try:
52
+ async for chunk in model.astream(_sanitize_messages_for_replay(messages)):
53
+ chunks.append(chunk)
54
+ content = chunk.content
55
+ if isinstance(content, str) and content:
56
+ if _should_render_text_chunk(content):
57
+ renderer.feed_text(content)
58
+ elif isinstance(content, list):
59
+ for item in content:
60
+ if isinstance(item, dict) and item.get("type") == "text":
61
+ text = item.get("text", "")
62
+ if isinstance(text, str) and _should_render_text_chunk(text):
63
+ renderer.feed_text(text)
64
+ thinking = extract_thinking(chunk, protocol)
65
+ if thinking:
66
+ renderer.feed_thinking(thinking)
67
+ except Exception:
68
+ renderer.discard()
69
+ raise
70
+ finally:
71
+ renderer.done()
72
+
73
+ if not chunks:
74
+ return AIMessage(content="")
75
+
76
+ merged = chunks[0]
77
+ for c in chunks[1:]:
78
+ merged = merged + c
79
+
80
+ _, dsml_tool_calls = _extract_dsml_tool_calls(merged.content)
81
+ content = _sanitize_ai_content_for_replay(merged.content)
82
+ tool_calls = merged.tool_calls or dsml_tool_calls
83
+ kwargs = {
84
+ "content": content,
85
+ "tool_calls": tool_calls,
86
+ "response_metadata": merged.response_metadata,
87
+ "additional_kwargs": merged.additional_kwargs,
88
+ }
89
+ usage_metadata = getattr(merged, "usage_metadata", None)
90
+ if usage_metadata:
91
+ kwargs["usage_metadata"] = usage_metadata
92
+ return AIMessage(**kwargs)
93
+
94
+
95
+ def _sanitize_messages_for_replay(messages: list) -> list:
96
+ """Remove reasoning-only blocks before replaying assistant history."""
97
+ sanitized = []
98
+ for message in messages:
99
+ if isinstance(message, AIMessage):
100
+ content = _sanitize_ai_content_for_replay(message.content)
101
+ if _is_empty_content(content) and not getattr(message, "tool_calls", None):
102
+ continue
103
+ if content != message.content:
104
+ sanitized.append(message.model_copy(update={"content": content}))
105
+ continue
106
+ sanitized.append(message)
107
+ return _repair_tool_result_adjacency(sanitized)
108
+
109
+
110
+ def _repair_tool_result_adjacency(messages: list) -> list:
111
+ repaired = []
112
+ i = 0
113
+
114
+ while i < len(messages):
115
+ message = messages[i]
116
+ if isinstance(message, ToolMessage):
117
+ i += 1
118
+ continue
119
+
120
+ repaired.append(message)
121
+ if not isinstance(message, AIMessage):
122
+ i += 1
123
+ continue
124
+
125
+ tool_call_ids = _ai_tool_call_ids(message)
126
+ if not tool_call_ids:
127
+ i += 1
128
+ continue
129
+
130
+ seen: set[str] = set()
131
+ j = i + 1
132
+ while j < len(messages) and isinstance(messages[j], ToolMessage):
133
+ tool_message = messages[j]
134
+ tool_call_id = str(getattr(tool_message, "tool_call_id", "") or "")
135
+ if tool_call_id in tool_call_ids and tool_call_id not in seen:
136
+ repaired.append(tool_message)
137
+ seen.add(tool_call_id)
138
+ j += 1
139
+
140
+ for tool_call_id in tool_call_ids:
141
+ if tool_call_id not in seen:
142
+ repaired.append(ToolMessage(
143
+ content="Tool result unavailable: previous tool call was not executed.",
144
+ tool_call_id=tool_call_id,
145
+ ))
146
+
147
+ i = j
148
+
149
+ return repaired
150
+
151
+
152
+ def _ai_tool_call_ids(message: AIMessage) -> list[str]:
153
+ ids: list[str] = []
154
+
155
+ for call in getattr(message, "tool_calls", None) or []:
156
+ if isinstance(call, dict) and call.get("id"):
157
+ ids.append(str(call["id"]))
158
+
159
+ content = message.content
160
+ if isinstance(content, list):
161
+ for item in content:
162
+ if isinstance(item, dict) and item.get("type") == "tool_use" and item.get("id"):
163
+ ids.append(str(item["id"]))
164
+
165
+ result: list[str] = []
166
+ seen: set[str] = set()
167
+ for tool_call_id in ids:
168
+ if tool_call_id not in seen:
169
+ result.append(tool_call_id)
170
+ seen.add(tool_call_id)
171
+ return result
172
+
173
+
174
+ def _sanitize_ai_content_for_replay(content: object) -> object:
175
+ if isinstance(content, str):
176
+ cleaned, _ = _extract_dsml_tool_calls_from_text(content)
177
+ return cleaned
178
+ if not isinstance(content, list):
179
+ return content
180
+
181
+ blocks: list[object] = []
182
+ text_parts: list[str] = []
183
+ has_non_text = False
184
+
185
+ def flush_text() -> None:
186
+ if text_parts:
187
+ blocks.append({"type": "text", "text": "".join(text_parts)})
188
+ text_parts.clear()
189
+
190
+ for item in content:
191
+ if isinstance(item, str):
192
+ if item:
193
+ cleaned, _ = _extract_dsml_tool_calls_from_text(item)
194
+ if cleaned:
195
+ text_parts.append(cleaned)
196
+ continue
197
+ if not isinstance(item, dict):
198
+ if item is not None:
199
+ text_parts.append(str(item))
200
+ continue
201
+
202
+ block_type = item.get("type")
203
+ if block_type in _REPLAY_UNSAFE_BLOCK_TYPES:
204
+ continue
205
+ if block_type == "text":
206
+ text = item.get("text", "")
207
+ if isinstance(text, str) and text:
208
+ cleaned, _ = _extract_dsml_tool_calls_from_text(text)
209
+ if cleaned:
210
+ text_parts.append(cleaned)
211
+ continue
212
+
213
+ flush_text()
214
+ blocks.append(item)
215
+ has_non_text = True
216
+
217
+ if not has_non_text:
218
+ return "".join(text_parts)
219
+
220
+ flush_text()
221
+ return blocks
222
+
223
+
224
+ def _is_empty_content(content: object) -> bool:
225
+ if content is None:
226
+ return True
227
+ if isinstance(content, str):
228
+ return content == ""
229
+ if isinstance(content, list):
230
+ return not content
231
+ return False
232
+
233
+
234
+ def _should_render_text_chunk(text: str) -> bool:
235
+ normalized = _normalize_dsml(text).strip()
236
+ if "DSML" in normalized and "<|" in normalized:
237
+ return False
238
+ return not _DSML_BOILERPLATE_RE.search(normalized)
239
+
240
+
241
+ def _extract_dsml_tool_calls(content: object) -> tuple[object, list[dict]]:
242
+ if isinstance(content, str):
243
+ return _extract_dsml_tool_calls_from_text(content)
244
+ if not isinstance(content, list):
245
+ return content, []
246
+
247
+ blocks: list[object] = []
248
+ calls: list[dict] = []
249
+ for item in content:
250
+ if isinstance(item, str):
251
+ cleaned, found = _extract_dsml_tool_calls_from_text(item)
252
+ calls.extend(found)
253
+ if cleaned:
254
+ blocks.append(cleaned)
255
+ continue
256
+ if isinstance(item, dict) and item.get("type") == "text":
257
+ text = item.get("text", "")
258
+ if isinstance(text, str):
259
+ cleaned, found = _extract_dsml_tool_calls_from_text(text)
260
+ calls.extend(found)
261
+ if cleaned:
262
+ updated = dict(item)
263
+ updated["text"] = cleaned
264
+ blocks.append(updated)
265
+ continue
266
+ blocks.append(item)
267
+
268
+ return (blocks if blocks else ""), calls
269
+
270
+
271
+ def _extract_dsml_tool_calls_from_text(text: str) -> tuple[str, list[dict]]:
272
+ normalized = _normalize_dsml(text)
273
+ calls: list[dict] = []
274
+
275
+ for block_match in _DSML_TOOL_CALLS_RE.finditer(normalized):
276
+ block = block_match.group(0)
277
+ for invoke_match in _DSML_INVOKE_RE.finditer(block):
278
+ attrs = _parse_dsml_attrs(invoke_match.group(1))
279
+ name = attrs.get("name", "").strip()
280
+ if not name:
281
+ continue
282
+ args: dict[str, object] = {}
283
+ body = invoke_match.group(2)
284
+ for param_match in _DSML_PARAMETER_RE.finditer(body):
285
+ param_attrs = _parse_dsml_attrs(param_match.group(1))
286
+ param_name = param_attrs.get("name", "").strip()
287
+ if not param_name:
288
+ continue
289
+ args[param_name] = _decode_dsml_parameter(param_match.group(2), param_attrs)
290
+ calls.append({
291
+ "name": name,
292
+ "args": args,
293
+ "id": f"call_dsml_{uuid.uuid4().hex[:12]}",
294
+ "type": "tool_call",
295
+ })
296
+
297
+ cleaned = _DSML_TOOL_CALLS_RE.sub("", normalized).strip()
298
+ if calls and _DSML_BOILERPLATE_RE.search(cleaned) and len(cleaned) <= 160:
299
+ cleaned = ""
300
+ return cleaned, calls
301
+
302
+
303
+ def _normalize_dsml(text: str) -> str:
304
+ return text.replace("|", "|")
305
+
306
+
307
+ def _parse_dsml_attrs(raw: str) -> dict[str, str]:
308
+ return {key: html.unescape(value) for key, value in _DSML_ATTR_RE.findall(raw)}
309
+
310
+
311
+ def _decode_dsml_parameter(raw: str, attrs: dict[str, str]) -> object:
312
+ value = html.unescape(raw)
313
+ stripped = value.strip()
314
+
315
+ if attrs.get("boolean", "").lower() == "true":
316
+ return stripped.lower() == "true"
317
+ if attrs.get("integer", "").lower() == "true":
318
+ try:
319
+ return int(stripped)
320
+ except ValueError:
321
+ return stripped
322
+ if attrs.get("number", "").lower() == "true":
323
+ try:
324
+ return float(stripped)
325
+ except ValueError:
326
+ return stripped
327
+ if attrs.get("json", "").lower() == "true":
328
+ try:
329
+ return json.loads(stripped)
330
+ except json.JSONDecodeError:
331
+ return stripped
332
+ if attrs.get("string", "").lower() == "true":
333
+ return value
334
+
335
+ try:
336
+ return json.loads(stripped)
337
+ except json.JSONDecodeError:
338
+ return value
339
+
340
+
341
+ def extract_text(msg) -> str:
342
+ content = msg.content if hasattr(msg, "content") else str(msg)
343
+ if isinstance(content, str):
344
+ return content
345
+ if isinstance(content, list):
346
+ parts = []
347
+ for item in content:
348
+ if isinstance(item, dict) and item.get("type") == "text":
349
+ parts.append(item.get("text", ""))
350
+ return "".join(parts)
351
+ return str(content)
@@ -0,0 +1,278 @@
1
+ """Child agent execution loop."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+
8
+ from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
9
+
10
+ from voidx.agent.agents import BASE_SYSTEM_PROMPT, PLAN_MODE_APPEND, AgentDef
11
+ from voidx.agent.graph_components.runtime import console, ui
12
+ from voidx.agent.graph_components.streaming import extract_text, stream_llm
13
+ from voidx.agent.runtime_context import InteractionMode, RuntimeContextBuilder
14
+ from voidx.agent.tool_filters import filter_unavailable_lsp_tools
15
+ from voidx.config import Config
16
+ from voidx.llm.provider import create_chat_model, resolve_protocol
17
+ from voidx.llm.usage import (
18
+ UsageStats,
19
+ estimate_context_tokens,
20
+ estimate_message_tokens,
21
+ extract_token_usage,
22
+ )
23
+ from voidx.memory.context_frames import save_context_frame_from_messages
24
+ from voidx.skills.registry import SkillRegistry
25
+ from voidx.skills.service import SkillService
26
+ from voidx.tools.base import ToolContext
27
+ from voidx.tools.registry import ToolRegistry
28
+ from voidx.tools.task_tracker import TaskTracker
29
+ from voidx.ui.capture import CaptureConsole
30
+ from voidx.ui.tree import OutputTree
31
+ from voidx.ui.console import StreamingRenderer
32
+
33
+
34
+ async def run_subagent(
35
+ agent_def: AgentDef,
36
+ task_description: str,
37
+ model_override: str | None,
38
+ api_key: str,
39
+ config: Config,
40
+ tracker: TaskTracker | None = None,
41
+ capture_tree: OutputTree | None = None,
42
+ parent_node=None,
43
+ parent_messages: list | None = None,
44
+ sub_messages: list | None = None,
45
+ authorize_tools=None,
46
+ debug: bool = True,
47
+ agent_id: int = -1,
48
+ session_id: str | None = None,
49
+ usage_stats: UsageStats | None = None,
50
+ lsp_manager=None,
51
+ skill_selection=None,
52
+ ) -> str:
53
+ """Run a child agent. Child messages are appended to sub_messages
54
+ (when provided) so the caller can place them after ToolMessages."""
55
+ model_cfg = config.model.model_copy()
56
+ if model_override:
57
+ model_cfg.model = model_override
58
+ elif agent_def.model:
59
+ model_cfg.model = agent_def.model
60
+
61
+ # Child agents use their own tool registry and cannot start nested agents.
62
+ agent_tools = ToolRegistry()
63
+ all_tool_ids = agent_tools.ids()
64
+ for tid in list(all_tool_ids):
65
+ if tid not in agent_def.tools and tid != "agent":
66
+ agent_tools._tools.pop(tid, None)
67
+ agent_tools._instances.pop(tid, None)
68
+ agent_tools._tools.pop("agent", None)
69
+ agent_tools._instances.pop("agent", None)
70
+ agent_tools._tools.pop("task_status", None)
71
+ agent_tools._instances.pop("task_status", None)
72
+
73
+ model = create_chat_model(api_key, model_cfg)
74
+ tool_defs = [
75
+ t for t in agent_tools.tools_for_llm()
76
+ if t["function"]["name"] not in ("agent", "task_status")
77
+ ]
78
+ tool_defs = filter_unavailable_lsp_tools(tool_defs, lsp_manager)
79
+
80
+ if sub_messages is None:
81
+ sub_messages = []
82
+
83
+ if parent_messages is not None:
84
+ messages = []
85
+ # Copy parent context: skip system prompts, agent-spawning AIMessages,
86
+ # and their orphaned ToolMessages.
87
+ skipped_ids: set[str] = set()
88
+ for m in parent_messages:
89
+ if isinstance(m, AIMessage) and m.tool_calls:
90
+ for tc in m.tool_calls:
91
+ name = tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", "")
92
+ if name == "agent":
93
+ tc_id = tc.get("id") if isinstance(tc, dict) else getattr(tc, "id", "")
94
+ if tc_id:
95
+ skipped_ids.add(tc_id)
96
+ for m in parent_messages:
97
+ if isinstance(m, SystemMessage):
98
+ continue
99
+ if isinstance(m, AIMessage) and m.tool_calls:
100
+ if any(
101
+ (tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", "")) == "agent"
102
+ for tc in m.tool_calls
103
+ ):
104
+ continue
105
+ if isinstance(m, ToolMessage):
106
+ tc_id = getattr(m, "tool_call_id", "")
107
+ if tc_id in skipped_ids:
108
+ continue
109
+ content = m.content
110
+ if isinstance(content, list):
111
+ text_parts = [
112
+ item.get("text", "") for item in content
113
+ if isinstance(item, dict) and item.get("type") == "text"
114
+ ]
115
+ if isinstance(m, AIMessage):
116
+ messages.append(AIMessage(content="".join(text_parts), tool_calls=m.tool_calls))
117
+ elif isinstance(m, HumanMessage):
118
+ messages.append(HumanMessage(content="".join(text_parts)))
119
+ elif isinstance(m, ToolMessage):
120
+ messages.append(ToolMessage(content="".join(text_parts), tool_call_id=getattr(m, "tool_call_id", "")))
121
+ else:
122
+ messages.append(type(m)(content="".join(text_parts)))
123
+ else:
124
+ messages.append(m)
125
+ messages.append(HumanMessage(content=task_description))
126
+ else:
127
+ messages = [HumanMessage(content=task_description)]
128
+
129
+ context_config = config.model_copy(deep=True)
130
+ context_config.model = model_cfg
131
+ interaction_mode = InteractionMode.PLAN.value if agent_def.name == "plan" else InteractionMode.AUTO.value
132
+ mode_prompt = PLAN_MODE_APPEND if InteractionMode.parse(interaction_mode) == InteractionMode.PLAN else ""
133
+ task_intent = _task_intent_for_agent(agent_def.name)
134
+ skill_service = SkillService(
135
+ SkillRegistry(config.workspace),
136
+ selection=skill_selection,
137
+ )
138
+ skill_matches = skill_service.select(
139
+ task_description,
140
+ agent=agent_def.name,
141
+ task_intent=task_intent,
142
+ interaction_mode=interaction_mode,
143
+ )
144
+ RuntimeContextBuilder(
145
+ config=context_config,
146
+ workspace=config.workspace,
147
+ base_system_prompt=BASE_SYSTEM_PROMPT,
148
+ role_prompt=agent_def.role_prompt,
149
+ mode_prompt=mode_prompt,
150
+ tool_contract=agent_def.tool_contract,
151
+ agent=agent_def.name,
152
+ interaction_mode=interaction_mode,
153
+ skill_instructions=[skill_service.render_instruction(match.skill) for match in skill_matches],
154
+ active_skill_summaries=[f"{match.name} ({match.reason})" for match in skill_matches],
155
+ current_user_text=task_description,
156
+ task_intent=task_intent,
157
+ agent_id=agent_id,
158
+ ).build().apply_to_messages(messages)
159
+
160
+ ctx = ToolContext(
161
+ workspace=config.workspace,
162
+ lsp_manager=lsp_manager,
163
+ sandbox_extra_paths=config.sandbox_workspace_write,
164
+ )
165
+
166
+ # Register with tracker
167
+ task_id = f"sub_{agent_def.name}_{int(time.time())}"
168
+ if tracker:
169
+ tracker.start(task_id, agent_def.name, task_description, agent_def.max_steps)
170
+
171
+ try:
172
+ for step in range(1, agent_def.max_steps + 1):
173
+ if tracker:
174
+ tracker.update(task_id, step=step)
175
+
176
+ if capture_tree and parent_node is not None:
177
+ capture = CaptureConsole(capture_tree, parent_node, agent_id=agent_id)
178
+ capture.step_header(step, agent_def.max_steps, agent_def.name)
179
+ else:
180
+ ui.step_header(step, agent_def.max_steps, agent_def.name)
181
+
182
+ active_tool_defs = tool_defs if step < agent_def.max_steps else []
183
+ model_with_tools = model.bind_tools(active_tool_defs) if active_tool_defs else model
184
+ renderer = StreamingRenderer(console, debug=debug, agent_id=agent_id)
185
+ context_tokens = estimate_context_tokens(messages, config.model.model)
186
+ if usage_stats is not None:
187
+ usage_stats.update_context(context_tokens)
188
+ if session_id:
189
+ await save_context_frame_from_messages(
190
+ session_id=session_id,
191
+ frame_kind="worker",
192
+ agent_role=agent_def.name,
193
+ provider=config.model.provider,
194
+ model=config.model.model,
195
+ messages=messages,
196
+ token_estimate=context_tokens,
197
+ metadata={
198
+ "step": step,
199
+ "max_steps": agent_def.max_steps,
200
+ "tool_count": len(active_tool_defs),
201
+ "agent_id": agent_id,
202
+ },
203
+ )
204
+ assistant_msg = await stream_llm(
205
+ model_with_tools,
206
+ messages,
207
+ renderer,
208
+ resolve_protocol(config.model),
209
+ )
210
+ if usage_stats is not None:
211
+ usage_stats.record_call(
212
+ extract_token_usage(assistant_msg),
213
+ fallback_input_tokens=context_tokens,
214
+ fallback_output_tokens=estimate_message_tokens(assistant_msg, config.model.model),
215
+ messages=messages,
216
+ model=config.model.model,
217
+ cache_key=f"{config.model.provider}/{config.model.model}",
218
+ )
219
+ messages.append(assistant_msg)
220
+ sub_messages.append(assistant_msg)
221
+
222
+ if not assistant_msg.tool_calls:
223
+ text = extract_text(assistant_msg)
224
+ if tracker:
225
+ tracker.update(task_id, last_output=text[:200])
226
+ tracker.finish(task_id, "completed")
227
+ return text
228
+
229
+ # Update tracker with preview
230
+ text_preview = extract_text(assistant_msg)[:200]
231
+ if tracker and text_preview:
232
+ tracker.update(task_id, last_output=text_preview)
233
+
234
+ if authorize_tools:
235
+ approved, denied = await authorize_tools(assistant_msg.tool_calls, agent_def.name)
236
+ else:
237
+ approved = list(assistant_msg.tool_calls)
238
+ denied = []
239
+
240
+ async def run_one(tc):
241
+ tid = tc.get("name", "")
242
+ targs = tc.get("args", {})
243
+ cid = tc.get("id", "")
244
+ if capture_tree and parent_node is not None:
245
+ capture.tool_call(tid, targs, tool_call_id=cid)
246
+ result = await agent_tools.execute_tool(tid, targs, ctx)
247
+ if capture_tree and parent_node is not None:
248
+ capture.tool_done(tid, 0.0, True, tool_call_id=cid)
249
+ capture.tool_result(result.output, tool_call_id=cid)
250
+ return ToolMessage(content=result.output, tool_call_id=cid)
251
+
252
+ tool_msgs = await asyncio.gather(*[run_one(tc) for tc in approved])
253
+ denied_msgs = [
254
+ ToolMessage(content=reason, tool_call_id=tc.get("id", ""))
255
+ for tc, reason in denied
256
+ ]
257
+ messages.extend(tool_msgs + denied_msgs)
258
+ sub_messages.extend(tool_msgs + denied_msgs)
259
+
260
+ if tracker:
261
+ tracker.finish(task_id, "completed")
262
+ return extract_text(messages[-1]) if messages else "Max steps reached."
263
+
264
+ except Exception as e:
265
+ if tracker:
266
+ tracker.update(task_id, last_output=str(e)[:200])
267
+ tracker.finish(task_id, "error")
268
+ raise
269
+
270
+
271
+ def _task_intent_for_agent(agent_name: str) -> str:
272
+ if agent_name == "implement":
273
+ return "implement"
274
+ if agent_name == "review":
275
+ return "review"
276
+ if agent_name == "plan":
277
+ return "design"
278
+ return "inspect"