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,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
+ )