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,532 @@
|
|
|
1
|
+
"""Interactive run loop for the agent graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
|
|
10
|
+
|
|
11
|
+
from voidx.agent.attachments import (
|
|
12
|
+
build_user_message_payload,
|
|
13
|
+
parse_structured_content,
|
|
14
|
+
serialize_message_content,
|
|
15
|
+
)
|
|
16
|
+
from voidx.agent.graph_components.runtime import ui
|
|
17
|
+
from voidx.agent.state import AgentState
|
|
18
|
+
from voidx.agent.task_state import resolve_turn_intent
|
|
19
|
+
from voidx.llm.provider import get_context_limit
|
|
20
|
+
from voidx.memory.session import (
|
|
21
|
+
MessageRow,
|
|
22
|
+
create_session,
|
|
23
|
+
load_messages,
|
|
24
|
+
save_message,
|
|
25
|
+
touch_session,
|
|
26
|
+
update_title,
|
|
27
|
+
delete_messages_from,
|
|
28
|
+
_now,
|
|
29
|
+
)
|
|
30
|
+
from voidx.memory.runtime_state import (
|
|
31
|
+
MessageRuntimeSnapshot,
|
|
32
|
+
RuntimeStateSnapshot,
|
|
33
|
+
clear_runtime_state,
|
|
34
|
+
load_runtime_state,
|
|
35
|
+
save_message_runtime_snapshot,
|
|
36
|
+
save_runtime_state,
|
|
37
|
+
)
|
|
38
|
+
from voidx.memory.transcript import load_transcript, replace_transcript
|
|
39
|
+
from voidx.ui.commands import COMMANDS
|
|
40
|
+
from voidx.ui.dock import dock, get_dock
|
|
41
|
+
from voidx.ui.events import (
|
|
42
|
+
DockEventConsumer,
|
|
43
|
+
InputSet,
|
|
44
|
+
StartupShown,
|
|
45
|
+
StatusFinished,
|
|
46
|
+
StatusUpdated,
|
|
47
|
+
TurnStarted,
|
|
48
|
+
ui_events,
|
|
49
|
+
)
|
|
50
|
+
from voidx.ui.session_changes import session_tracker
|
|
51
|
+
from voidx.ui.startup import show_startup
|
|
52
|
+
from voidx.ui.transcript import transcript_rows_to_tree, tree_to_transcript_rows
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class GraphRunLoopMixin:
|
|
56
|
+
async def _show_startup(self, *, append_transcript: bool = False) -> None:
|
|
57
|
+
is_new = self._session is None
|
|
58
|
+
title = self._startup_title()
|
|
59
|
+
active_dock = get_dock()
|
|
60
|
+
startup_event = StartupShown(
|
|
61
|
+
model=self.config.model.model,
|
|
62
|
+
provider=self.config.model.provider,
|
|
63
|
+
workspace=self._workspace,
|
|
64
|
+
session_title=title,
|
|
65
|
+
is_new=is_new,
|
|
66
|
+
profile_configured=self.model is not None,
|
|
67
|
+
)
|
|
68
|
+
startup_via_event = active_dock is not None and ui_events.is_running
|
|
69
|
+
if startup_via_event:
|
|
70
|
+
await ui_events.request(startup_event)
|
|
71
|
+
if append_transcript:
|
|
72
|
+
await self._restore_transcript_snapshot(append=True)
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
if active_dock is not None and active_dock.active:
|
|
76
|
+
active_dock.append_startup(
|
|
77
|
+
model=self.config.model.model,
|
|
78
|
+
provider=self.config.model.provider,
|
|
79
|
+
workspace=self._workspace,
|
|
80
|
+
session_title=title,
|
|
81
|
+
is_new=is_new,
|
|
82
|
+
profile_configured=self.model is not None,
|
|
83
|
+
)
|
|
84
|
+
if append_transcript:
|
|
85
|
+
await self._restore_transcript_snapshot(append=True)
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
show_startup(
|
|
89
|
+
console=ui,
|
|
90
|
+
model=self.config.model.model,
|
|
91
|
+
provider=self.config.model.provider,
|
|
92
|
+
workspace=self._workspace,
|
|
93
|
+
session_title=title,
|
|
94
|
+
is_new=is_new,
|
|
95
|
+
)
|
|
96
|
+
if self.model is None:
|
|
97
|
+
ui.print()
|
|
98
|
+
ui.print("[yellow]No profile configured — chat is disabled until you set one up.[/yellow]")
|
|
99
|
+
ui.print(f"[dim] Use [cyan]/model new[/cyan] to create a profile interactively[/dim]")
|
|
100
|
+
ui.print()
|
|
101
|
+
|
|
102
|
+
def _startup_title(self) -> str:
|
|
103
|
+
title = self._session.title if self._session else "New session"
|
|
104
|
+
if len(title) > 60:
|
|
105
|
+
title = title[:57] + "..."
|
|
106
|
+
return title
|
|
107
|
+
|
|
108
|
+
async def run(self) -> None:
|
|
109
|
+
"""Interactive REPL with orchestrator agent."""
|
|
110
|
+
from voidx.ui.app import McpServerStatus, PromptToolkitTui, UiStatus
|
|
111
|
+
|
|
112
|
+
self._any_messages_sent = False
|
|
113
|
+
session_tracker.clear()
|
|
114
|
+
|
|
115
|
+
title = self._startup_title()
|
|
116
|
+
|
|
117
|
+
dock.begin_capture()
|
|
118
|
+
active_dock = get_dock()
|
|
119
|
+
if active_dock is not None:
|
|
120
|
+
ui_events.start(DockEventConsumer(active_dock))
|
|
121
|
+
await self._restore_runtime_state()
|
|
122
|
+
await self._show_startup(append_transcript=True)
|
|
123
|
+
|
|
124
|
+
exit_message: str | None = None
|
|
125
|
+
|
|
126
|
+
app = PromptToolkitTui(
|
|
127
|
+
UiStatus(
|
|
128
|
+
provider=self.config.model.provider,
|
|
129
|
+
model=self.config.model.model,
|
|
130
|
+
workspace=self._workspace,
|
|
131
|
+
session_title=title,
|
|
132
|
+
context_limit=get_context_limit(self.config.model.provider),
|
|
133
|
+
reasoning_effort=self.config.model.reasoning_effort or "xhigh",
|
|
134
|
+
permission_label=self._permission.status_label,
|
|
135
|
+
sandbox_label=lambda: self._permission._sandbox_label(),
|
|
136
|
+
approval_label=lambda: self._permission._approval_label(),
|
|
137
|
+
approval_reviewer_label=lambda: self._permission._reviewer_label(),
|
|
138
|
+
usage_stats=self._usage_stats,
|
|
139
|
+
debug=lambda: self._debug,
|
|
140
|
+
plan_mode=lambda: self._plan_mode,
|
|
141
|
+
interaction_mode=lambda: getattr(
|
|
142
|
+
getattr(self, "_interaction_mode", None),
|
|
143
|
+
"value",
|
|
144
|
+
"plan" if getattr(self, "_plan_mode", False) else "auto",
|
|
145
|
+
),
|
|
146
|
+
goal_label=lambda: getattr(getattr(self, "_task_run", None), "goal", ""),
|
|
147
|
+
goal_phase=lambda: getattr(getattr(getattr(self, "_task_run", None), "phase", None), "value", "clarify"),
|
|
148
|
+
goal_status=lambda: getattr(getattr(getattr(self, "_task_run", None), "status", None), "value", "idle"),
|
|
149
|
+
goal_turn_count=lambda: getattr(getattr(self, "_task_run", None), "turn_count", 0),
|
|
150
|
+
goal_awaiting_approval=lambda: getattr(getattr(self, "_task_run", None), "awaiting_implementation_approval", False),
|
|
151
|
+
mcp_servers=lambda: [
|
|
152
|
+
McpServerStatus(
|
|
153
|
+
name=s.name,
|
|
154
|
+
status=s.status,
|
|
155
|
+
tool_count=s.tool_count,
|
|
156
|
+
)
|
|
157
|
+
for s in (
|
|
158
|
+
self._mcp_manager.statuses()
|
|
159
|
+
if hasattr(self, '_mcp_manager')
|
|
160
|
+
else []
|
|
161
|
+
)
|
|
162
|
+
] if self._settings is not None else [],
|
|
163
|
+
mcp_config_path=str(self._settings.path) if self._settings is not None else "",
|
|
164
|
+
code_ide=lambda: (
|
|
165
|
+
self._settings.get_code_ide().value
|
|
166
|
+
if self._settings is not None
|
|
167
|
+
else "trae"
|
|
168
|
+
),
|
|
169
|
+
),
|
|
170
|
+
COMMANDS,
|
|
171
|
+
)
|
|
172
|
+
self._app = app
|
|
173
|
+
|
|
174
|
+
if hasattr(self, '_lsp_manager'):
|
|
175
|
+
lsp_lines = []
|
|
176
|
+
for check in self._lsp_manager.doctor():
|
|
177
|
+
if check.available and check.enabled:
|
|
178
|
+
source = f" [dim][{check.detected_source}][/dim]" if check.detected_source else ""
|
|
179
|
+
lsp_lines.append(f" [cyan]{check.language}[/cyan] [dim]→[/dim] {check.resolved_path}{source}")
|
|
180
|
+
if lsp_lines:
|
|
181
|
+
app.show_transient_output("\n".join(lsp_lines), title="LSP")
|
|
182
|
+
|
|
183
|
+
if hasattr(self, '_mcp_manager'):
|
|
184
|
+
await self._mcp_manager.start_all()
|
|
185
|
+
|
|
186
|
+
async def handle_user_input(user_input: str) -> bool:
|
|
187
|
+
nonlocal exit_message
|
|
188
|
+
user_input = user_input.strip()
|
|
189
|
+
if not user_input:
|
|
190
|
+
return True
|
|
191
|
+
|
|
192
|
+
if user_input.startswith("/"):
|
|
193
|
+
if user_input in ("/exit", "/quit"):
|
|
194
|
+
exit_message = "\n[dim]bye.[/dim]"
|
|
195
|
+
return False
|
|
196
|
+
if app.consume_quiet_command(user_input):
|
|
197
|
+
app.hide_command_output()
|
|
198
|
+
with ui.capture_command_output(
|
|
199
|
+
lambda _text: None,
|
|
200
|
+
width=app.command_output_width,
|
|
201
|
+
):
|
|
202
|
+
await self._dispatch_slash(user_input)
|
|
203
|
+
app.hide_command_output()
|
|
204
|
+
return True
|
|
205
|
+
if user_input == "/":
|
|
206
|
+
app.begin_command_output(user_input)
|
|
207
|
+
with ui.capture_command_output(
|
|
208
|
+
app.append_command_output,
|
|
209
|
+
width=app.command_output_width,
|
|
210
|
+
):
|
|
211
|
+
ui.print("[bold]Commands:[/bold]")
|
|
212
|
+
for name, desc in COMMANDS:
|
|
213
|
+
ui.print(f" [cyan]{name}[/cyan] — {desc}")
|
|
214
|
+
return True
|
|
215
|
+
app.begin_command_output(user_input)
|
|
216
|
+
with ui.capture_command_output(
|
|
217
|
+
app.append_command_output,
|
|
218
|
+
width=app.command_output_width,
|
|
219
|
+
):
|
|
220
|
+
dispatched = await self._dispatch_slash(user_input)
|
|
221
|
+
return True if dispatched else True
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
await self._run_once(user_input)
|
|
225
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
226
|
+
ui.print(f"\n[dim]Interrupted.[/dim]")
|
|
227
|
+
return True
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
await app.run(handle_user_input)
|
|
231
|
+
if exit_message is None:
|
|
232
|
+
exit_message = "\n[dim]bye.[/dim]"
|
|
233
|
+
finally:
|
|
234
|
+
if hasattr(self, '_mcp_manager'):
|
|
235
|
+
await self._mcp_manager.stop_all()
|
|
236
|
+
if hasattr(self, '_lsp_manager'):
|
|
237
|
+
await self._lsp_manager.stop_all()
|
|
238
|
+
if ui_events.is_running:
|
|
239
|
+
await ui_events.stop()
|
|
240
|
+
dock.deactivate()
|
|
241
|
+
if exit_message:
|
|
242
|
+
ui.print(exit_message)
|
|
243
|
+
|
|
244
|
+
async def _run_once(self, user_text: str) -> None:
|
|
245
|
+
t_turn_start = time.monotonic()
|
|
246
|
+
user_message_id: int | None = None
|
|
247
|
+
try:
|
|
248
|
+
session_tracker.begin_turn(self._workspace)
|
|
249
|
+
payload = build_user_message_payload(user_text, self._workspace)
|
|
250
|
+
self._current_tree = dock.tree
|
|
251
|
+
if dock.active and ui_events.is_running:
|
|
252
|
+
self._turn_node = await ui_events.request(TurnStarted(text=payload.display_text))
|
|
253
|
+
await ui_events.emit(StatusUpdated(
|
|
254
|
+
status_id="turn:analyzing",
|
|
255
|
+
label="Analyzing",
|
|
256
|
+
detail="loading session and preparing context",
|
|
257
|
+
stage="analyzing",
|
|
258
|
+
))
|
|
259
|
+
else:
|
|
260
|
+
self._turn_node = dock.start_turn(payload.display_text)
|
|
261
|
+
session_msgs = await load_messages(self._session.id) if self._session else []
|
|
262
|
+
# Safety: if session is huge, only load recent messages
|
|
263
|
+
if len(session_msgs) > 500:
|
|
264
|
+
ui.warn(f"Session has {len(session_msgs)} messages — loading last 200")
|
|
265
|
+
session_msgs = session_msgs[-200:]
|
|
266
|
+
|
|
267
|
+
msgs = []
|
|
268
|
+
for row in session_msgs:
|
|
269
|
+
if row.role == "system":
|
|
270
|
+
msgs.append(SystemMessage(content=row.content, id=str(row.id) if row.id is not None else None))
|
|
271
|
+
elif row.role == "user":
|
|
272
|
+
msgs.append(HumanMessage(
|
|
273
|
+
content=parse_structured_content(row.content, row.content_format),
|
|
274
|
+
id=str(row.id) if row.id is not None else None,
|
|
275
|
+
))
|
|
276
|
+
elif row.role == "assistant":
|
|
277
|
+
content = parse_structured_content(row.content, row.content_format)
|
|
278
|
+
msgs.append(AIMessage(
|
|
279
|
+
content=content,
|
|
280
|
+
tool_calls=row.tool_calls or [],
|
|
281
|
+
id=str(row.id) if row.id is not None else None,
|
|
282
|
+
))
|
|
283
|
+
elif row.role == "tool":
|
|
284
|
+
msgs.append(ToolMessage(
|
|
285
|
+
content=row.content,
|
|
286
|
+
tool_call_id=row.tool_call_id or "",
|
|
287
|
+
id=str(row.id) if row.id is not None else None,
|
|
288
|
+
))
|
|
289
|
+
|
|
290
|
+
for warning in payload.warnings:
|
|
291
|
+
ui.warn(warning)
|
|
292
|
+
|
|
293
|
+
turn_msg = HumanMessage(content=payload.content, id=f"user_{time.time_ns()}")
|
|
294
|
+
msgs.append(turn_msg)
|
|
295
|
+
if self._session is None:
|
|
296
|
+
self._session = await create_session(workspace=self._workspace)
|
|
297
|
+
|
|
298
|
+
interaction_mode = getattr(
|
|
299
|
+
getattr(self, "_interaction_mode", None),
|
|
300
|
+
"value",
|
|
301
|
+
"plan" if getattr(self, "_plan_mode", False) else "auto",
|
|
302
|
+
)
|
|
303
|
+
task_run = getattr(self, "_task_run", None)
|
|
304
|
+
if interaction_mode == "goal" and task_run is not None and not task_run.goal:
|
|
305
|
+
task_run.set_goal(payload.title_text)
|
|
306
|
+
intent_resolution = resolve_turn_intent(
|
|
307
|
+
payload.title_text,
|
|
308
|
+
interaction_mode,
|
|
309
|
+
getattr(self, "_task_state", None),
|
|
310
|
+
)
|
|
311
|
+
task_intent = intent_resolution.intent
|
|
312
|
+
implementation_allowed = intent_resolution.implementation_allowed
|
|
313
|
+
self._current_implementation_allowed = implementation_allowed
|
|
314
|
+
goal_scope = (
|
|
315
|
+
task_run.goal
|
|
316
|
+
if interaction_mode == "goal" and task_run is not None and task_run.goal
|
|
317
|
+
else payload.title_text
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
saved_user_content, user_content_format = serialize_message_content(payload.content)
|
|
321
|
+
user_message_id = await save_message(MessageRow(
|
|
322
|
+
session_id=self._session.id,
|
|
323
|
+
role="user",
|
|
324
|
+
content=saved_user_content,
|
|
325
|
+
content_format=user_content_format,
|
|
326
|
+
created_at=_now(),
|
|
327
|
+
))
|
|
328
|
+
self._any_messages_sent = True
|
|
329
|
+
|
|
330
|
+
initial: AgentState = {
|
|
331
|
+
"messages": msgs,
|
|
332
|
+
"workspace": self._workspace,
|
|
333
|
+
"tool_results": {},
|
|
334
|
+
"step_count": 0,
|
|
335
|
+
"max_steps": 50,
|
|
336
|
+
"should_continue": True,
|
|
337
|
+
"agent": "orchestrator",
|
|
338
|
+
"plan_mode": self._plan_mode,
|
|
339
|
+
"interaction_mode": interaction_mode,
|
|
340
|
+
"task_intent": task_intent.value,
|
|
341
|
+
"implementation_allowed": implementation_allowed,
|
|
342
|
+
"intent_resolution_reason": intent_resolution.reason,
|
|
343
|
+
"awaiting_implementation_approval": intent_resolution.awaiting_implementation_approval,
|
|
344
|
+
"approved_scope": intent_resolution.approved_scope,
|
|
345
|
+
"goal": task_run.goal if task_run is not None else "",
|
|
346
|
+
"goal_phase": task_run.phase.value if task_run is not None else "",
|
|
347
|
+
"goal_status": task_run.status.value if task_run is not None else "",
|
|
348
|
+
"goal_turn_count": task_run.turn_count if task_run is not None else 0,
|
|
349
|
+
"user_message_id": user_message_id,
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
# ── compaction: check overflow before running ──────────────────
|
|
353
|
+
head, tail_id = await self._maybe_compact(msgs, session_msgs)
|
|
354
|
+
if dock.active and ui_events.is_running:
|
|
355
|
+
await ui_events.emit(StatusFinished(status_id="turn:analyzing"))
|
|
356
|
+
|
|
357
|
+
final = await self.graph.ainvoke(initial, {"recursion_limit": self.config.agent.recursion_limit})
|
|
358
|
+
if self.model is not None and hasattr(self, "_task_state"):
|
|
359
|
+
self._task_state.update_after_turn(
|
|
360
|
+
intent_resolution,
|
|
361
|
+
payload.title_text,
|
|
362
|
+
scope_text=goal_scope,
|
|
363
|
+
)
|
|
364
|
+
if self.model is not None and interaction_mode == "goal" and task_run is not None:
|
|
365
|
+
task_run.update_after_turn(
|
|
366
|
+
intent_resolution,
|
|
367
|
+
payload.title_text,
|
|
368
|
+
scope_text=goal_scope,
|
|
369
|
+
)
|
|
370
|
+
await save_message_runtime_snapshot(MessageRuntimeSnapshot(
|
|
371
|
+
message_id=user_message_id,
|
|
372
|
+
session_id=self._session.id,
|
|
373
|
+
interaction_mode=interaction_mode,
|
|
374
|
+
task_intent=task_intent,
|
|
375
|
+
implementation_allowed=implementation_allowed,
|
|
376
|
+
intent_resolution_reason=intent_resolution.reason,
|
|
377
|
+
goal=task_run.goal if task_run is not None else "",
|
|
378
|
+
goal_phase=task_run.phase.value if task_run is not None else "",
|
|
379
|
+
goal_status=task_run.status.value if task_run is not None else "",
|
|
380
|
+
goal_turn_count=task_run.turn_count if task_run is not None else 0,
|
|
381
|
+
awaiting_implementation_approval=(
|
|
382
|
+
task_run.awaiting_implementation_approval
|
|
383
|
+
if interaction_mode == "goal" and task_run is not None
|
|
384
|
+
else getattr(
|
|
385
|
+
getattr(self, "_task_state", None),
|
|
386
|
+
"awaiting_implementation_approval",
|
|
387
|
+
False,
|
|
388
|
+
)
|
|
389
|
+
),
|
|
390
|
+
approved_scope=(
|
|
391
|
+
task_run.approved_scope
|
|
392
|
+
if interaction_mode == "goal" and task_run is not None
|
|
393
|
+
else getattr(getattr(self, "_task_state", None), "approved_scope", "")
|
|
394
|
+
),
|
|
395
|
+
))
|
|
396
|
+
await self._persist_runtime_state()
|
|
397
|
+
|
|
398
|
+
# ── prune old tool outputs after turn ──────────────────────────
|
|
399
|
+
self._compaction.prune(final["messages"])
|
|
400
|
+
|
|
401
|
+
# Persist new messages
|
|
402
|
+
if self._session:
|
|
403
|
+
turn_index = None
|
|
404
|
+
for i, msg in enumerate(final["messages"]):
|
|
405
|
+
if getattr(msg, "id", None) == turn_msg.id:
|
|
406
|
+
turn_index = i
|
|
407
|
+
break
|
|
408
|
+
if turn_index is None:
|
|
409
|
+
for i in range(len(final["messages"]) - 1, -1, -1):
|
|
410
|
+
msg = final["messages"][i]
|
|
411
|
+
if isinstance(msg, HumanMessage) and msg.content == payload.content:
|
|
412
|
+
turn_index = i
|
|
413
|
+
break
|
|
414
|
+
new_messages = final["messages"][turn_index + 1:] if turn_index is not None else []
|
|
415
|
+
|
|
416
|
+
for msg in new_messages:
|
|
417
|
+
if isinstance(msg, AIMessage):
|
|
418
|
+
raw_content = msg.content
|
|
419
|
+
if isinstance(raw_content, list):
|
|
420
|
+
saved = json.dumps(raw_content, ensure_ascii=False)
|
|
421
|
+
fmt = "structured"
|
|
422
|
+
else:
|
|
423
|
+
saved = str(raw_content)
|
|
424
|
+
fmt = "text"
|
|
425
|
+
await save_message(MessageRow(
|
|
426
|
+
session_id=self._session.id,
|
|
427
|
+
role="assistant",
|
|
428
|
+
content=saved,
|
|
429
|
+
content_format=fmt,
|
|
430
|
+
tool_calls=msg.tool_calls if msg.tool_calls else None,
|
|
431
|
+
created_at=_now(),
|
|
432
|
+
))
|
|
433
|
+
elif isinstance(msg, ToolMessage):
|
|
434
|
+
await save_message(MessageRow(
|
|
435
|
+
session_id=self._session.id,
|
|
436
|
+
role="tool",
|
|
437
|
+
content=str(msg.content),
|
|
438
|
+
tool_call_id=getattr(msg, "tool_call_id", None),
|
|
439
|
+
created_at=_now(),
|
|
440
|
+
))
|
|
441
|
+
await touch_session(self._session.id)
|
|
442
|
+
|
|
443
|
+
# Auto-title on first message
|
|
444
|
+
if len(session_msgs) <= 1:
|
|
445
|
+
title_source = payload.title_text
|
|
446
|
+
title = title_source[:80] + ("..." if len(title_source) > 80 else "")
|
|
447
|
+
await update_title(self._session.id, title)
|
|
448
|
+
await self._persist_transcript_snapshot()
|
|
449
|
+
|
|
450
|
+
elapsed = time.monotonic() - t_turn_start
|
|
451
|
+
if self._debug:
|
|
452
|
+
ui.print(f"[dim]✻ Churned for {elapsed:.0f}s[/dim]")
|
|
453
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
454
|
+
if self._session is not None and user_message_id is not None:
|
|
455
|
+
await delete_messages_from(self._session.id, user_message_id)
|
|
456
|
+
raise
|
|
457
|
+
finally:
|
|
458
|
+
session_tracker.finish_turn()
|
|
459
|
+
if dock.active and ui_events.is_running:
|
|
460
|
+
await ui_events.emit(StatusFinished(status_id="turn:analyzing"))
|
|
461
|
+
await ui_events.emit(StatusFinished(status_id="agent:-1:progress"))
|
|
462
|
+
await ui_events.emit(StatusFinished(status_id="compaction"))
|
|
463
|
+
await ui_events.emit(InputSet(text="", hints=[]))
|
|
464
|
+
await ui_events.drain()
|
|
465
|
+
else:
|
|
466
|
+
dock.set_input("", [])
|
|
467
|
+
self._current_implementation_allowed = True
|
|
468
|
+
|
|
469
|
+
async def _dispatch_slash(self, inp: str) -> bool:
|
|
470
|
+
"""Try to dispatch a slash command. Returns True if handled."""
|
|
471
|
+
return await self._slash.dispatch(inp)
|
|
472
|
+
|
|
473
|
+
async def _restore_runtime_state(self) -> None:
|
|
474
|
+
if self._session is None:
|
|
475
|
+
return
|
|
476
|
+
snapshot = await load_runtime_state(self._session.id)
|
|
477
|
+
self._interaction_mode = snapshot.interaction_mode
|
|
478
|
+
self._task_state = snapshot.task_state
|
|
479
|
+
self._task_run = snapshot.task_run
|
|
480
|
+
self._compaction_summary = snapshot.compaction_summary
|
|
481
|
+
|
|
482
|
+
async def _persist_runtime_state(self) -> None:
|
|
483
|
+
if self._session is None:
|
|
484
|
+
return
|
|
485
|
+
from voidx.agent.runtime_context import InteractionMode
|
|
486
|
+
from voidx.agent.task_state import TaskRun, TaskState
|
|
487
|
+
|
|
488
|
+
interaction_mode = getattr(self, "_interaction_mode", None) or InteractionMode.AUTO
|
|
489
|
+
task_state = getattr(self, "_task_state", None) or TaskState()
|
|
490
|
+
task_run = getattr(self, "_task_run", None) or TaskRun()
|
|
491
|
+
await save_runtime_state(
|
|
492
|
+
self._session.id,
|
|
493
|
+
RuntimeStateSnapshot(
|
|
494
|
+
interaction_mode=interaction_mode,
|
|
495
|
+
task_state=task_state,
|
|
496
|
+
task_run=task_run,
|
|
497
|
+
compaction_summary=getattr(self, "_compaction_summary", ""),
|
|
498
|
+
),
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
async def _clear_runtime_state(self) -> None:
|
|
502
|
+
from voidx.agent.runtime_context import InteractionMode
|
|
503
|
+
from voidx.agent.task_state import TaskRun, TaskState
|
|
504
|
+
|
|
505
|
+
if self._session is not None:
|
|
506
|
+
await clear_runtime_state(self._session.id)
|
|
507
|
+
self._interaction_mode = InteractionMode.AUTO
|
|
508
|
+
self._task_state = TaskState()
|
|
509
|
+
self._task_run = TaskRun()
|
|
510
|
+
self._compaction_summary = ""
|
|
511
|
+
self._pending_summary = None
|
|
512
|
+
|
|
513
|
+
async def _persist_transcript_snapshot(self) -> None:
|
|
514
|
+
if self._session is None:
|
|
515
|
+
return
|
|
516
|
+
active_dock = get_dock()
|
|
517
|
+
if active_dock is None:
|
|
518
|
+
return
|
|
519
|
+
rows, turn_count = tree_to_transcript_rows(self._session.id, active_dock.tree)
|
|
520
|
+
await replace_transcript(self._session.id, rows, turn_count=turn_count)
|
|
521
|
+
|
|
522
|
+
async def _restore_transcript_snapshot(self, *, append: bool = False) -> bool:
|
|
523
|
+
if self._session is None:
|
|
524
|
+
return False
|
|
525
|
+
active_dock = get_dock()
|
|
526
|
+
if active_dock is None:
|
|
527
|
+
return False
|
|
528
|
+
rows = await load_transcript(self._session.id)
|
|
529
|
+
if not rows:
|
|
530
|
+
return False
|
|
531
|
+
active_dock.restore_tree(transcript_rows_to_tree(rows), append=append)
|
|
532
|
+
return True
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Shared runtime state for agent orchestration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextvars import ContextVar
|
|
6
|
+
|
|
7
|
+
from voidx.ui.console import VoidConsole
|
|
8
|
+
|
|
9
|
+
ui = VoidConsole()
|
|
10
|
+
console = ui.console
|
|
11
|
+
current_parent_tool_call_id: ContextVar[str] = ContextVar(
|
|
12
|
+
"current_parent_tool_call_id",
|
|
13
|
+
default="",
|
|
14
|
+
)
|