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.
- voidx/__init__.py +3 -0
- voidx/agent/__init__.py +0 -0
- voidx/agent/agents.py +439 -0
- voidx/agent/attachments.py +235 -0
- voidx/agent/graph.py +463 -0
- voidx/agent/graph_components/__init__.py +1 -0
- voidx/agent/graph_components/compaction.py +268 -0
- voidx/agent/graph_components/permissions.py +139 -0
- voidx/agent/graph_components/run_loop.py +532 -0
- voidx/agent/graph_components/runtime.py +14 -0
- voidx/agent/graph_components/streaming.py +351 -0
- voidx/agent/graph_components/subagent.py +278 -0
- voidx/agent/graph_components/tool_execution.py +208 -0
- voidx/agent/runtime_context.py +368 -0
- voidx/agent/slash.py +466 -0
- voidx/agent/slash_components/__init__.py +1 -0
- voidx/agent/slash_components/code_ide.py +68 -0
- voidx/agent/slash_components/lsp.py +105 -0
- voidx/agent/slash_components/mcp.py +332 -0
- voidx/agent/slash_components/model.py +419 -0
- voidx/agent/slash_components/runtime.py +55 -0
- voidx/agent/slash_components/skills.py +94 -0
- voidx/agent/state.py +32 -0
- voidx/agent/task_state.py +278 -0
- voidx/agent/tool_filters.py +27 -0
- voidx/config.py +707 -0
- voidx/llm/__init__.py +0 -0
- voidx/llm/catalog.py +188 -0
- voidx/llm/compaction.py +267 -0
- voidx/llm/context.py +43 -0
- voidx/llm/instruction.py +220 -0
- voidx/llm/provider.py +312 -0
- voidx/llm/usage.py +341 -0
- voidx/lsp/__init__.py +30 -0
- voidx/lsp/client.py +259 -0
- voidx/lsp/config.py +172 -0
- voidx/lsp/detector.py +512 -0
- voidx/lsp/errors.py +19 -0
- voidx/lsp/manager.py +280 -0
- voidx/lsp/schema.py +179 -0
- voidx/lsp/service.py +103 -0
- voidx/main.py +154 -0
- voidx/mcp/__init__.py +33 -0
- voidx/mcp/client.py +458 -0
- voidx/mcp/manager.py +267 -0
- voidx/mcp/schema.py +112 -0
- voidx/mcp/tool.py +122 -0
- voidx/mcp_servers/__init__.py +1 -0
- voidx/mcp_servers/web.py +104 -0
- voidx/memory/__init__.py +0 -0
- voidx/memory/context_frames.py +188 -0
- voidx/memory/model_profiles.py +98 -0
- voidx/memory/runtime_state.py +240 -0
- voidx/memory/session.py +272 -0
- voidx/memory/store.py +245 -0
- voidx/memory/transcript.py +137 -0
- voidx/permission/__init__.py +28 -0
- voidx/permission/engine.py +430 -0
- voidx/permission/evaluate.py +114 -0
- voidx/permission/sandbox.py +280 -0
- voidx/permission/schema.py +24 -0
- voidx/permission/service.py +314 -0
- voidx/permission/wildcard.py +34 -0
- voidx/skills/__init__.py +18 -0
- voidx/skills/bundled/superpowers/receiving-code-review/SKILL.md +30 -0
- voidx/skills/bundled/superpowers/requesting-code-review/SKILL.md +27 -0
- voidx/skills/bundled/superpowers/systematic-debugging/SKILL.md +36 -0
- voidx/skills/bundled/superpowers/test-driven-development/SKILL.md +33 -0
- voidx/skills/bundled/superpowers/verification-before-completion/SKILL.md +31 -0
- voidx/skills/bundled/superpowers/writing-plans/SKILL.md +27 -0
- voidx/skills/policy.py +97 -0
- voidx/skills/registry.py +162 -0
- voidx/skills/schema.py +47 -0
- voidx/skills/service.py +199 -0
- voidx/tools/__init__.py +0 -0
- voidx/tools/agent.py +81 -0
- voidx/tools/base.py +86 -0
- voidx/tools/bash.py +105 -0
- voidx/tools/file_ops.py +193 -0
- voidx/tools/lsp.py +155 -0
- voidx/tools/registry.py +104 -0
- voidx/tools/repomap.py +238 -0
- voidx/tools/search.py +162 -0
- voidx/tools/task_status.py +57 -0
- voidx/tools/task_tracker.py +81 -0
- voidx/tools/todo.py +82 -0
- voidx/tools/web_content.py +357 -0
- voidx/tools/web_mcp.py +107 -0
- voidx/tools/webfetch.py +155 -0
- voidx/tools/websearch.py +276 -0
- voidx/ui/__init__.py +0 -0
- voidx/ui/app.py +1033 -0
- voidx/ui/app_components/__init__.py +1 -0
- voidx/ui/app_components/clipboard_image.py +245 -0
- voidx/ui/app_components/commands.py +18 -0
- voidx/ui/app_components/controls.py +29 -0
- voidx/ui/app_components/file_picker.py +115 -0
- voidx/ui/app_components/formatting.py +187 -0
- voidx/ui/app_components/git_changes.py +51 -0
- voidx/ui/app_components/rendering.py +1169 -0
- voidx/ui/browse.py +160 -0
- voidx/ui/capture.py +169 -0
- voidx/ui/code_ide.py +251 -0
- voidx/ui/commands.py +83 -0
- voidx/ui/console.py +381 -0
- voidx/ui/console_components/__init__.py +1 -0
- voidx/ui/console_components/formatting.py +96 -0
- voidx/ui/console_components/streaming.py +253 -0
- voidx/ui/diff.py +331 -0
- voidx/ui/dock.py +372 -0
- voidx/ui/dock_components/__init__.py +1 -0
- voidx/ui/dock_components/formatting.py +123 -0
- voidx/ui/dock_components/nodes.py +401 -0
- voidx/ui/dock_components/state.py +51 -0
- voidx/ui/event_components/__init__.py +1 -0
- voidx/ui/event_components/schema.py +249 -0
- voidx/ui/events.py +341 -0
- voidx/ui/session_changes.py +163 -0
- voidx/ui/startup.py +161 -0
- voidx/ui/transcript.py +148 -0
- voidx/ui/tree.py +316 -0
- voidx-1.0.0.dist-info/METADATA +59 -0
- voidx-1.0.0.dist-info/RECORD +126 -0
- voidx-1.0.0.dist-info/WHEEL +5 -0
- voidx-1.0.0.dist-info/entry_points.txt +2 -0
- 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"
|