symphony-code 0.0.1__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.
- coding_agent/__init__.py +20 -0
- coding_agent/_lazy.py +17 -0
- coding_agent/addons/__init__.py +35 -0
- coding_agent/addons/approvals.py +224 -0
- coding_agent/addons/compaction/__init__.py +36 -0
- coding_agent/addons/compaction/addon.py +88 -0
- coding_agent/addons/compaction/compactor.py +138 -0
- coding_agent/addons/compaction/prompts.py +20 -0
- coding_agent/addons/compaction/transcript.py +118 -0
- coding_agent/addons/evaluation/__init__.py +68 -0
- coding_agent/addons/evaluation/addon.py +199 -0
- coding_agent/addons/evaluation/evaluators.py +245 -0
- coding_agent/addons/evaluation/handlers.py +66 -0
- coding_agent/addons/evaluation/policy.py +277 -0
- coding_agent/addons/evaluation/protocol.py +192 -0
- coding_agent/addons/evaluation/state.py +284 -0
- coding_agent/addons/langfuse/__init__.py +24 -0
- coding_agent/addons/langfuse/addon.py +465 -0
- coding_agent/addons/langfuse/install.py +79 -0
- coding_agent/addons/langfuse/serialize.py +158 -0
- coding_agent/addons/learning/__init__.py +33 -0
- coding_agent/addons/learning/addon.py +181 -0
- coding_agent/addons/learning/loop.py +247 -0
- coding_agent/addons/learning/prompts.py +26 -0
- coding_agent/addons/learning/sanitize.py +64 -0
- coding_agent/addons/learning/store.py +522 -0
- coding_agent/addons/manager/__init__.py +18 -0
- coding_agent/addons/manager/bridge.py +74 -0
- coding_agent/addons/manager/discovery.py +34 -0
- coding_agent/addons/manager/report.py +33 -0
- coding_agent/addons/manager/runtime.py +76 -0
- coding_agent/addons/manager/tool_changes.py +16 -0
- coding_agent/addons/persistence/__init__.py +41 -0
- coding_agent/addons/persistence/active.py +132 -0
- coding_agent/addons/persistence/artifacts.py +110 -0
- coding_agent/addons/persistence/collection.py +166 -0
- coding_agent/addons/persistence/compatibility.py +30 -0
- coding_agent/addons/persistence/images.py +133 -0
- coding_agent/addons/persistence/jsonl.py +1195 -0
- coding_agent/addons/persistence/presentation.py +104 -0
- coding_agent/addons/plugins/__init__.py +19 -0
- coding_agent/addons/plugins/install.py +37 -0
- coding_agent/addons/plugins/manager.py +140 -0
- coding_agent/addons/plugins/models.py +50 -0
- coding_agent/addons/skills/__init__.py +28 -0
- coding_agent/addons/skills/addon.py +26 -0
- coding_agent/addons/skills/args.py +78 -0
- coding_agent/addons/skills/bundled_skills/addon-authoring/SKILL.md +77 -0
- coding_agent/addons/skills/bundled_skills/context-budget/SKILL.md +12 -0
- coding_agent/addons/skills/bundled_skills/orient-first/SKILL.md +16 -0
- coding_agent/addons/skills/bundled_skills/security-boundary/SKILL.md +16 -0
- coding_agent/addons/skills/bundled_skills/skill-discipline/SKILL.md +13 -0
- coding_agent/addons/skills/bundled_skills/surgical-edit/SKILL.md +11 -0
- coding_agent/addons/skills/bundled_skills/systematic-debug/SKILL.md +18 -0
- coding_agent/addons/skills/bundled_skills/verify-before-done/SKILL.md +11 -0
- coding_agent/addons/skills/frontmatter.py +57 -0
- coding_agent/addons/skills/models.py +57 -0
- coding_agent/addons/skills/registry.py +109 -0
- coding_agent/addons/skills/tool.py +54 -0
- coding_agent/addons/voice/__init__.py +5 -0
- coding_agent/addons/voice/addon.py +106 -0
- coding_agent/addons/voice/credentials.py +42 -0
- coding_agent/addons/voice/playback.py +42 -0
- coding_agent/addons/voice/speak.py +287 -0
- coding_agent/addons/voice/stream.py +651 -0
- coding_agent/addons/voice/summary.py +126 -0
- coding_agent/agent.py +523 -0
- coding_agent/bench/__init__.py +1 -0
- coding_agent/bench/cli.py +200 -0
- coding_agent/bench/patch.py +78 -0
- coding_agent/commands/__init__.py +5 -0
- coding_agent/commands/catalog.py +179 -0
- coding_agent/commands/jev_argument.py +40 -0
- coding_agent/config/__init__.py +51 -0
- coding_agent/config/config.py +376 -0
- coding_agent/config/credentials.py +153 -0
- coding_agent/config/personalities.json +34 -0
- coding_agent/config/personalities.py +170 -0
- coding_agent/config/prompts.py +67 -0
- coding_agent/protocols/__init__.py +5 -0
- coding_agent/protocols/acp/README.md +36 -0
- coding_agent/protocols/acp/__init__.py +27 -0
- coding_agent/protocols/acp/mapping.py +164 -0
- coding_agent/protocols/acp/server.py +261 -0
- coding_agent/protocols/acp/transport.py +72 -0
- coding_agent/protocols/stdio/README.md +36 -0
- coding_agent/protocols/stdio/__init__.py +37 -0
- coding_agent/protocols/stdio/catalog.py +49 -0
- coding_agent/protocols/stdio/commands.py +218 -0
- coding_agent/protocols/stdio/server.py +181 -0
- coding_agent/protocols/stdio/transport.py +71 -0
- coding_agent/py.typed +0 -0
- coding_agent/resources/__init__.py +5 -0
- coding_agent/resources/usage.py +264 -0
- coding_agent/run/__init__.py +1 -0
- coding_agent/run/cli.py +99 -0
- coding_agent/run/detach.py +37 -0
- coding_agent/run/interrupted.py +67 -0
- coding_agent/run/log_sink.py +46 -0
- coding_agent/tools/__init__.py +73 -0
- coding_agent/tools/ask_user.py +61 -0
- coding_agent/tools/base.py +136 -0
- coding_agent/tools/bash.py +288 -0
- coding_agent/tools/bash_jobs.py +116 -0
- coding_agent/tools/generate_image.py +123 -0
- coding_agent/tools/memory.py +100 -0
- coding_agent/tools/patch.py +218 -0
- coding_agent/tools/read_file.py +167 -0
- coding_agent/tools/search.py +433 -0
- coding_agent/tools/web_search.py +151 -0
- coding_agent/tools/write_file.py +40 -0
- coding_agent/tui/EFFICIENCY_PLAN.md +25 -0
- coding_agent/tui/__init__.py +22 -0
- coding_agent/tui/__main__.py +122 -0
- coding_agent/tui/app.py +456 -0
- coding_agent/tui/chrome/__init__.py +33 -0
- coding_agent/tui/chrome/footer.py +274 -0
- coding_agent/tui/chrome/topbar.py +259 -0
- coding_agent/tui/commands/__init__.py +45 -0
- coding_agent/tui/commands/catalog.py +35 -0
- coding_agent/tui/commands/manager.py +557 -0
- coding_agent/tui/composer/__init__.py +18 -0
- coding_agent/tui/composer/input.py +219 -0
- coding_agent/tui/composer/slash_menu.py +341 -0
- coding_agent/tui/composer/surface.py +330 -0
- coding_agent/tui/composer/voice.py +267 -0
- coding_agent/tui/composer/voice_mode.py +491 -0
- coding_agent/tui/runtime/__init__.py +23 -0
- coding_agent/tui/runtime/events.py +728 -0
- coding_agent/tui/runtime/sink.py +115 -0
- coding_agent/tui/runtime/state.py +145 -0
- coding_agent/tui/runtime/subagent.py +531 -0
- coding_agent/tui/runtime/turn.py +206 -0
- coding_agent/tui/screens/__init__.py +54 -0
- coding_agent/tui/screens/ask.py +69 -0
- coding_agent/tui/screens/context.py +195 -0
- coding_agent/tui/screens/dashboard.py +91 -0
- coding_agent/tui/screens/diff.py +302 -0
- coding_agent/tui/screens/extensions.py +68 -0
- coding_agent/tui/screens/file_selector.py +167 -0
- coding_agent/tui/screens/history.py +458 -0
- coding_agent/tui/screens/langfuse.py +142 -0
- coding_agent/tui/screens/learning.py +113 -0
- coding_agent/tui/screens/modal.py +131 -0
- coding_agent/tui/screens/onboard.py +455 -0
- coding_agent/tui/screens/resume.py +163 -0
- coding_agent/tui/theme/__init__.py +49 -0
- coding_agent/tui/theme/load.py +325 -0
- coding_agent/tui/theme/style.py +95 -0
- coding_agent/tui/theme/theme.toml +1687 -0
- coding_agent/tui/tools/__init__.py +53 -0
- coding_agent/tui/tools/activity.py +184 -0
- coding_agent/tui/tools/calls.py +560 -0
- coding_agent/tui/tools/code_view.py +52 -0
- coding_agent/tui/tools/images.py +250 -0
- coding_agent/tui/tools/labels.py +135 -0
- coding_agent/tui/tools/modal.py +137 -0
- coding_agent/tui/tools/partial_args.py +19 -0
- coding_agent/tui/tools/snapshots.py +382 -0
- coding_agent/tui/transcript/__init__.py +36 -0
- coding_agent/tui/transcript/messages.py +408 -0
- coding_agent/tui/transcript/process.py +504 -0
- coding_agent/tui/transcript/surface.py +345 -0
- coding_agent/tui/transcript/thinking_markdown.py +314 -0
- symphony_code-0.0.1.dist-info/METADATA +66 -0
- symphony_code-0.0.1.dist-info/RECORD +169 -0
- symphony_code-0.0.1.dist-info/WHEEL +4 -0
- symphony_code-0.0.1.dist-info/entry_points.txt +5 -0
- symphony_code-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
"""Nested transcript UI for spawned child agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Mapping, Optional
|
|
9
|
+
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
from textual.binding import Binding
|
|
12
|
+
from textual.widgets import Static, OptionList
|
|
13
|
+
from textual.widgets.option_list import Option
|
|
14
|
+
|
|
15
|
+
from coding_agent.addons.persistence.collection import is_collected
|
|
16
|
+
from coding_agent.tui.chrome import TopBar, render_footer, display_workspace_path
|
|
17
|
+
from coding_agent.tui.runtime.events import EventPresenter
|
|
18
|
+
from coding_agent.tui.runtime.state import UiRunState
|
|
19
|
+
from coding_agent.tui.screens.modal import ModalBase
|
|
20
|
+
from coding_agent.tui.theme import APP_CSS
|
|
21
|
+
from coding_agent.tui.transcript import (
|
|
22
|
+
UserMessage,
|
|
23
|
+
TranscriptScroll,
|
|
24
|
+
TranscriptSurface,
|
|
25
|
+
)
|
|
26
|
+
from coding_agent.tui.tools import BashToolHeader, ToolCallWidget
|
|
27
|
+
from coding_agent.tui.transcript import clip_text, compact_json, preview_text
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class SubagentRecord:
|
|
32
|
+
"""Live child-agent transcript, keyed by ``agent_id``."""
|
|
33
|
+
|
|
34
|
+
agent_id: str
|
|
35
|
+
parent_id: str
|
|
36
|
+
label: str
|
|
37
|
+
prompt: str
|
|
38
|
+
status: str = "running"
|
|
39
|
+
model_id: str = ""
|
|
40
|
+
output_text: str = ""
|
|
41
|
+
events: list[tuple[str, dict[str, Any]]] = field(default_factory=list)
|
|
42
|
+
tools: list[dict[str, Any]] = field(default_factory=list)
|
|
43
|
+
session_id: str = ""
|
|
44
|
+
tool_call_id: str = ""
|
|
45
|
+
run_id: str = ""
|
|
46
|
+
config: dict[str, Any] = field(default_factory=dict)
|
|
47
|
+
_seen_events: set[tuple[str, int]] = field(default_factory=set, repr=False)
|
|
48
|
+
_widgets: list[Any] = field(default_factory=list, repr=False)
|
|
49
|
+
|
|
50
|
+
def _tool(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
51
|
+
call_id = str(payload.get("tool_call_id") or f"tool-{len(self.tools)}")
|
|
52
|
+
existing = next(
|
|
53
|
+
(tool for tool in self.tools if tool.get("id") == call_id),
|
|
54
|
+
None,
|
|
55
|
+
)
|
|
56
|
+
if existing is not None:
|
|
57
|
+
return existing
|
|
58
|
+
tool = {
|
|
59
|
+
"id": call_id,
|
|
60
|
+
"name": str(payload.get("tool_name") or "tool"),
|
|
61
|
+
"status": "preparing",
|
|
62
|
+
"arguments": {},
|
|
63
|
+
"raw_arguments": "",
|
|
64
|
+
"summary": "",
|
|
65
|
+
"result": "",
|
|
66
|
+
}
|
|
67
|
+
self.tools.append(tool)
|
|
68
|
+
return tool
|
|
69
|
+
|
|
70
|
+
def ingest(self, event_type: str, payload: dict[str, Any]) -> None:
|
|
71
|
+
if event_type == "collected" or is_collected(payload):
|
|
72
|
+
return
|
|
73
|
+
if payload.get("parent_id") and (
|
|
74
|
+
payload.get("agent_id") != self.agent_id
|
|
75
|
+
or payload.get("parent_id") != self.parent_id
|
|
76
|
+
):
|
|
77
|
+
return
|
|
78
|
+
if payload.get("run_id") and isinstance(payload.get("seq"), int):
|
|
79
|
+
key = (str(payload["run_id"]), payload["seq"])
|
|
80
|
+
if key in self._seen_events:
|
|
81
|
+
return
|
|
82
|
+
self._seen_events.add(key)
|
|
83
|
+
self.events.append((event_type, dict(payload)))
|
|
84
|
+
if event_type == "run_started":
|
|
85
|
+
self.run_id = str(payload.get("run_id") or self.run_id)
|
|
86
|
+
self.session_id = str(payload.get("session_id") or self.session_id)
|
|
87
|
+
self.status = "running"
|
|
88
|
+
self.output_text = ""
|
|
89
|
+
self.model_id = str(payload.get("model_id") or self.model_id)
|
|
90
|
+
elif event_type == "tool_call_started":
|
|
91
|
+
tool = self._tool(payload)
|
|
92
|
+
tool["name"] = str(payload.get("tool_name") or tool["name"])
|
|
93
|
+
elif event_type in {"tool_call_delta", "tool_call_arguments"}:
|
|
94
|
+
tool = self._tool(payload)
|
|
95
|
+
if event_type == "tool_call_arguments":
|
|
96
|
+
raw = payload.get("arguments")
|
|
97
|
+
if not isinstance(raw, str):
|
|
98
|
+
return
|
|
99
|
+
else:
|
|
100
|
+
raw = str(tool.get("raw_arguments") or "") + str(payload.get("delta") or "")
|
|
101
|
+
tool["raw_arguments"] = raw
|
|
102
|
+
tool["summary"] = raw
|
|
103
|
+
try:
|
|
104
|
+
arguments = json.loads(raw)
|
|
105
|
+
except json.JSONDecodeError:
|
|
106
|
+
arguments = None
|
|
107
|
+
if isinstance(arguments, dict):
|
|
108
|
+
tool["arguments"] = arguments
|
|
109
|
+
tool["summary"] = compact_json(arguments)
|
|
110
|
+
elif event_type == "tool_execution_started":
|
|
111
|
+
tool = self._tool(payload)
|
|
112
|
+
arguments = payload.get("arguments") or {}
|
|
113
|
+
tool.update(
|
|
114
|
+
name=str(payload.get("tool_name") or tool["name"]),
|
|
115
|
+
status="running",
|
|
116
|
+
arguments=arguments,
|
|
117
|
+
summary=compact_json(arguments) if arguments else tool["summary"],
|
|
118
|
+
)
|
|
119
|
+
elif event_type == "tool_execution_completed":
|
|
120
|
+
tool = self._tool(payload)
|
|
121
|
+
result = payload.get("result") or ""
|
|
122
|
+
tool.update(
|
|
123
|
+
name=str(payload.get("tool_name") or tool["name"]),
|
|
124
|
+
status=(
|
|
125
|
+
"failed" if str(payload.get("status") or "") == "error" else "done"
|
|
126
|
+
),
|
|
127
|
+
result=preview_text(result, limit=400),
|
|
128
|
+
)
|
|
129
|
+
elif event_type == "text_delta":
|
|
130
|
+
self.output_text += str(payload.get("delta") or "")
|
|
131
|
+
elif event_type in {"run_completed", "agent_completed"}:
|
|
132
|
+
self.status = "completed"
|
|
133
|
+
if payload.get("output_text"):
|
|
134
|
+
self.output_text = str(payload["output_text"])
|
|
135
|
+
elif event_type in {"run_failed", "run_cancelled", "run_limit_exceeded", "agent_failed"}:
|
|
136
|
+
self.status = "cancelled" if event_type == "run_cancelled" or payload.get("error_type") == "HarnessCancelled" else "failed"
|
|
137
|
+
if payload.get("message"):
|
|
138
|
+
self.output_text = str(payload["message"])
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class SubagentScreen(TranscriptSurface, ModalBase[None]):
|
|
142
|
+
"""Full-screen child transcript using the parent session's visual language."""
|
|
143
|
+
|
|
144
|
+
# Use the same chrome and transcript surface as the parent app. This is
|
|
145
|
+
# intentionally not a second, legacy subagent layout: the child prompt is
|
|
146
|
+
# the first user message and the child run uses the normal process cards.
|
|
147
|
+
CSS = APP_CSS + """
|
|
148
|
+
SubagentScreen { border: round #484F58; }
|
|
149
|
+
#child-heading { height: 2; padding: 0 $space-gutter; color: #a2adb8; }
|
|
150
|
+
#child-back { height: 1; padding: 0 $space-gutter; color: #737373; }
|
|
151
|
+
"""
|
|
152
|
+
BINDINGS = [
|
|
153
|
+
Binding("escape,q", "close_modal", "Back to parent", show=False, priority=True),
|
|
154
|
+
Binding("ctrl+x", "stop_child", "Stop child", show=False, priority=True),
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
def __init__(self, record: SubagentRecord | None = None, workspace: Any = None,
|
|
158
|
+
*, child_id: str = "", parent_id: str = "") -> None:
|
|
159
|
+
super().__init__()
|
|
160
|
+
self.record = record
|
|
161
|
+
self.child_id = child_id or (record.agent_id if record else "")
|
|
162
|
+
self.parent_id = parent_id or (record.parent_id if record else "")
|
|
163
|
+
self.workspace = Path(workspace or ".").resolve()
|
|
164
|
+
self._thinking = self._process = self._assistant = self._reasoning = None
|
|
165
|
+
self._tools = {}
|
|
166
|
+
self._tool_groups = {}
|
|
167
|
+
self._open_tool_group = None
|
|
168
|
+
self._subagents = {}
|
|
169
|
+
self._busy = True
|
|
170
|
+
self._event_cursor = 0
|
|
171
|
+
self._ready = False
|
|
172
|
+
self._stream_flush_timer = None
|
|
173
|
+
self._ui_state = UiRunState()
|
|
174
|
+
self._presenter = EventPresenter(
|
|
175
|
+
state=self._ui_state, view=self, set_status=self._set_status,
|
|
176
|
+
workspace=str(self.workspace), schedule_flush=self._schedule_stream_flush,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def compose(self): # type: ignore[no-untyped-def]
|
|
180
|
+
yield TopBar(id="topbar")
|
|
181
|
+
yield Static(id="child-heading", markup=False)
|
|
182
|
+
yield TranscriptScroll(id="transcript")
|
|
183
|
+
yield Static("Viewing child transcript · Esc back · Ctrl+X stop child", id="child-back")
|
|
184
|
+
yield Static(id="status")
|
|
185
|
+
|
|
186
|
+
def on_mount(self) -> None:
|
|
187
|
+
if self.record is None:
|
|
188
|
+
self.record = getattr(self.app, "_subagents", {}).get(self.child_id)
|
|
189
|
+
if self.record is None or self.record.parent_id != self.parent_id:
|
|
190
|
+
self.add_notice("Child session not found for this parent.", "error")
|
|
191
|
+
return
|
|
192
|
+
self._mount_transcript(UserMessage(self.record.prompt or "Child task"))
|
|
193
|
+
self._ready = True
|
|
194
|
+
self.refresh_record()
|
|
195
|
+
|
|
196
|
+
def _schedule_stream_flush(self, callback: Any) -> None:
|
|
197
|
+
self._stream_flush_callback = callback
|
|
198
|
+
if self._stream_flush_timer is not None:
|
|
199
|
+
return
|
|
200
|
+
def flush() -> None:
|
|
201
|
+
self._stream_flush_timer = None
|
|
202
|
+
pending = self._stream_flush_callback
|
|
203
|
+
self._stream_flush_callback = None
|
|
204
|
+
if pending is not None:
|
|
205
|
+
pending()
|
|
206
|
+
self._stream_flush_timer = self.set_timer(1 / 15, flush)
|
|
207
|
+
|
|
208
|
+
def on_unmount(self) -> None:
|
|
209
|
+
if self._stream_flush_timer is not None:
|
|
210
|
+
self._stream_flush_timer.stop()
|
|
211
|
+
|
|
212
|
+
def _set_status(self, _value: str = "") -> None:
|
|
213
|
+
self.query_one("#status", Static).update(render_footer(
|
|
214
|
+
self._ui_state, hint="esc back", workspace=display_workspace_path(self.workspace),
|
|
215
|
+
), layout=False)
|
|
216
|
+
|
|
217
|
+
async def action_stop_child(self) -> None:
|
|
218
|
+
await self.app.cancel_subagent(self.child_id)
|
|
219
|
+
|
|
220
|
+
def refresh_record(self) -> None:
|
|
221
|
+
"""Synchronize live child state into the nested transcript."""
|
|
222
|
+
record = self.record
|
|
223
|
+
if record is None or not self._ready:
|
|
224
|
+
return
|
|
225
|
+
self.query_one("#topbar", TopBar).set_context(
|
|
226
|
+
self.workspace,
|
|
227
|
+
record.model_id,
|
|
228
|
+
label=record.label,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
self.query_one("#child-heading", Static).update(
|
|
232
|
+
f"{record.label} · {record.status} · {record.model_id}\n"
|
|
233
|
+
f"child {record.agent_id[:8]} ← parent {record.parent_id[:8]}"
|
|
234
|
+
)
|
|
235
|
+
for event_type, payload in record.events[self._event_cursor:]:
|
|
236
|
+
self._event_cursor += 1
|
|
237
|
+
if event_type in {"agent_spawned", "agent_completed", "agent_failed"}:
|
|
238
|
+
continue
|
|
239
|
+
if event_type == "run_started" and self._process is not None and self._process.completed:
|
|
240
|
+
self._thinking = self._process = self._assistant = self._reasoning = None
|
|
241
|
+
self._mount_transcript(UserMessage(str(payload.get("prompt") or "Continued child task")))
|
|
242
|
+
if event_type == "question_asked":
|
|
243
|
+
self.add_notice("Waiting for an answer in the parent session.")
|
|
244
|
+
continue
|
|
245
|
+
self._presenter.handle(event_type, payload)
|
|
246
|
+
# Child events may arrive in a burst while the parent screen is also
|
|
247
|
+
# being refreshed. Do not leave the child's final buffered delta
|
|
248
|
+
# waiting on a scheduler tick; the child view must reflect the record
|
|
249
|
+
# immediately after its refresh.
|
|
250
|
+
self._presenter.flush_stream_paints()
|
|
251
|
+
if record.status != "running":
|
|
252
|
+
if self._assistant is None and record.output_text:
|
|
253
|
+
self.set_assistant(record.output_text)
|
|
254
|
+
self.finish_assistant()
|
|
255
|
+
self.finish_reasoning()
|
|
256
|
+
if self._process is None or not self._process.completed:
|
|
257
|
+
self.finish_process(f"Child {record.status}")
|
|
258
|
+
self._ui_state.phase = "idle"
|
|
259
|
+
self._busy = False
|
|
260
|
+
self._set_status()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class SubagentWidget(ToolCallWidget):
|
|
264
|
+
"""Clickable parent-transcript row for a child agent."""
|
|
265
|
+
|
|
266
|
+
keep_in_transcript = True
|
|
267
|
+
|
|
268
|
+
def __init__(self, call_id: str, tool_name: str) -> None:
|
|
269
|
+
self.record: Optional[SubagentRecord] = None
|
|
270
|
+
super().__init__(call_id, tool_name)
|
|
271
|
+
self.add_class("subagent-call")
|
|
272
|
+
|
|
273
|
+
def _tool_title(self) -> tuple[str, str]:
|
|
274
|
+
return ("Subagent", "↳")
|
|
275
|
+
|
|
276
|
+
def _summary(self) -> str:
|
|
277
|
+
return (
|
|
278
|
+
self.record.label
|
|
279
|
+
if self.record
|
|
280
|
+
else str(
|
|
281
|
+
self.arguments.get("label")
|
|
282
|
+
or self.arguments.get("prompt")
|
|
283
|
+
or self.raw_arguments
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
def _body_rows(self) -> list[Any]:
|
|
288
|
+
prompt = (
|
|
289
|
+
self.record.prompt
|
|
290
|
+
if self.record
|
|
291
|
+
else str(self.arguments.get("prompt") or "")
|
|
292
|
+
)
|
|
293
|
+
rows: list[Any] = []
|
|
294
|
+
if prompt:
|
|
295
|
+
rows.append(Text(clip_text(prompt, 240), style="#8aa8a5"))
|
|
296
|
+
rows.append(Text("↳ click to open child transcript", style="underline #7186c7"))
|
|
297
|
+
return rows
|
|
298
|
+
|
|
299
|
+
def bind(self, record: SubagentRecord) -> None:
|
|
300
|
+
self.record = record
|
|
301
|
+
if self not in record._widgets:
|
|
302
|
+
record._widgets.append(self)
|
|
303
|
+
self.refresh_content()
|
|
304
|
+
|
|
305
|
+
def refresh_content(self) -> None:
|
|
306
|
+
if self.record is not None:
|
|
307
|
+
self.status = {"completed": "done", "running": "running"}.get(self.record.status, "failed")
|
|
308
|
+
super().refresh_content()
|
|
309
|
+
|
|
310
|
+
def on_bash_tool_header_toggle(self, event: BashToolHeader.Toggle) -> None:
|
|
311
|
+
event.stop()
|
|
312
|
+
self.open_screen()
|
|
313
|
+
|
|
314
|
+
def open_screen(self) -> bool:
|
|
315
|
+
if self.record is None:
|
|
316
|
+
self.app.notify("Child is starting; its session will be available shortly.")
|
|
317
|
+
return False
|
|
318
|
+
self.app.open_subagent(self.record)
|
|
319
|
+
return True
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class SubagentSurface:
|
|
323
|
+
"""Child-agent event wiring mixed into CodingAgentApp."""
|
|
324
|
+
|
|
325
|
+
def _bind_spawn_widget(self, record: SubagentRecord) -> None:
|
|
326
|
+
if record.tool_call_id:
|
|
327
|
+
match = self._tools.get(record.tool_call_id)
|
|
328
|
+
if isinstance(match, SubagentWidget):
|
|
329
|
+
match.bind(record)
|
|
330
|
+
return
|
|
331
|
+
candidates = [
|
|
332
|
+
item
|
|
333
|
+
for item in self._tools.values()
|
|
334
|
+
if isinstance(item, SubagentWidget) and item.record is None
|
|
335
|
+
]
|
|
336
|
+
if not candidates:
|
|
337
|
+
return
|
|
338
|
+
match = next(
|
|
339
|
+
(
|
|
340
|
+
item
|
|
341
|
+
for item in candidates
|
|
342
|
+
if (
|
|
343
|
+
not record.prompt
|
|
344
|
+
or item.arguments.get("prompt") == record.prompt
|
|
345
|
+
)
|
|
346
|
+
and (
|
|
347
|
+
not record.label
|
|
348
|
+
or item.arguments.get("label") in {record.label, None, ""}
|
|
349
|
+
)
|
|
350
|
+
),
|
|
351
|
+
None,
|
|
352
|
+
)
|
|
353
|
+
if match is not None:
|
|
354
|
+
match.bind(record)
|
|
355
|
+
|
|
356
|
+
def _on_agent_spawned(self, payload: dict[str, Any]) -> None:
|
|
357
|
+
child_id = str(payload.get("child_id") or "")
|
|
358
|
+
if not child_id:
|
|
359
|
+
return
|
|
360
|
+
record = self._subagents.get(child_id) or SubagentRecord(
|
|
361
|
+
agent_id=child_id,
|
|
362
|
+
parent_id=str(payload.get("agent_id") or ""),
|
|
363
|
+
label=str(payload.get("label") or "subagent"),
|
|
364
|
+
prompt=str(payload.get("prompt") or ""),
|
|
365
|
+
model_id=str(payload.get("model_id") or ""),
|
|
366
|
+
)
|
|
367
|
+
record.tool_call_id = str(payload.get("tool_call_id") or record.tool_call_id)
|
|
368
|
+
record.session_id = str(payload.get("child_session_id") or child_id)
|
|
369
|
+
record.label = str(payload.get("label") or record.label)
|
|
370
|
+
record.prompt = str(payload.get("prompt") or record.prompt)
|
|
371
|
+
record.config = {key: payload.get(key) for key in ("model_id", "max_turns", "reasoning_effort")}
|
|
372
|
+
if child_id:
|
|
373
|
+
self._subagents[child_id] = record
|
|
374
|
+
self._bind_spawn_widget(record)
|
|
375
|
+
self._refresh_subagent_screen(record)
|
|
376
|
+
self._refresh_subagent_tasks()
|
|
377
|
+
|
|
378
|
+
def _on_agent_finished(self, event_type: str, payload: dict[str, Any]) -> None:
|
|
379
|
+
child_id = str(payload.get("child_id") or "")
|
|
380
|
+
record = self._subagents.get(child_id)
|
|
381
|
+
if record is None:
|
|
382
|
+
return
|
|
383
|
+
if payload.get("agent_id") and payload["agent_id"] != record.parent_id:
|
|
384
|
+
return
|
|
385
|
+
before = len(record.events)
|
|
386
|
+
record.ingest(event_type, payload)
|
|
387
|
+
if len(record.events) == before:
|
|
388
|
+
return
|
|
389
|
+
if getattr(self, "_pending_question_agent_id", "") == child_id:
|
|
390
|
+
from coding_agent.tui.composer import PromptInput, SlashMenu
|
|
391
|
+
self._pending_question_id = None
|
|
392
|
+
self._pending_question_agent_id = ""
|
|
393
|
+
self._pending_question_default = ""
|
|
394
|
+
self.query_one("#slash-menu", SlashMenu).set_commands(())
|
|
395
|
+
self.query_one("#approval-menu", SlashMenu).set_commands(())
|
|
396
|
+
prompt = self.query_one("#prompt", PromptInput)
|
|
397
|
+
prompt.submit_on_enter = False
|
|
398
|
+
prompt.disabled = self._busy
|
|
399
|
+
self._update_composer_hint()
|
|
400
|
+
self._refresh_subagent_widgets(record)
|
|
401
|
+
self._refresh_subagent_screen(record)
|
|
402
|
+
self._refresh_subagent_tasks()
|
|
403
|
+
self.add_notice(f"Subagent {record.label} {record.status} · Ctrl+G to inspect")
|
|
404
|
+
|
|
405
|
+
def _on_child_event(self, event_type: str, payload: dict[str, Any]) -> None:
|
|
406
|
+
agent_id = str(payload.get("agent_id") or "")
|
|
407
|
+
if not agent_id or not payload.get("parent_id"):
|
|
408
|
+
return
|
|
409
|
+
record = self._subagents.get(agent_id)
|
|
410
|
+
if record is None:
|
|
411
|
+
record = SubagentRecord(
|
|
412
|
+
agent_id=agent_id,
|
|
413
|
+
parent_id=str(payload.get("parent_id") or ""),
|
|
414
|
+
label="subagent",
|
|
415
|
+
prompt="",
|
|
416
|
+
model_id=str(payload.get("model_id") or ""),
|
|
417
|
+
)
|
|
418
|
+
if agent_id:
|
|
419
|
+
self._subagents[agent_id] = record
|
|
420
|
+
self._bind_spawn_widget(record)
|
|
421
|
+
record.ingest(event_type, payload)
|
|
422
|
+
self._refresh_subagent_widgets(record)
|
|
423
|
+
self._refresh_subagent_screen(record)
|
|
424
|
+
|
|
425
|
+
def _show_child_question(self, payload: Mapping[str, Any]) -> None:
|
|
426
|
+
"""Surface child questions through the parent's interactive composer."""
|
|
427
|
+
if isinstance(self.screen, SubagentScreen):
|
|
428
|
+
from coding_agent.tui.screens.modal import dismiss_overlay
|
|
429
|
+
|
|
430
|
+
dismiss_overlay(self.screen)
|
|
431
|
+
self.call_after_refresh(self._show_question, dict(payload))
|
|
432
|
+
return
|
|
433
|
+
self._show_question(payload)
|
|
434
|
+
|
|
435
|
+
def _refresh_subagent_widgets(self, record: SubagentRecord) -> None:
|
|
436
|
+
"""Repaint parent cards only when their visible status changes.
|
|
437
|
+
|
|
438
|
+
Child text and tool events can arrive continuously from many agents.
|
|
439
|
+
Their parent cards show status, not the child transcript, so refreshing
|
|
440
|
+
them on every event creates avoidable layout work.
|
|
441
|
+
"""
|
|
442
|
+
for widget in record._widgets:
|
|
443
|
+
if isinstance(widget, SubagentWidget) and widget.record is record:
|
|
444
|
+
status = {"completed": "done", "running": "running"}.get(record.status, "failed")
|
|
445
|
+
if widget.status != status:
|
|
446
|
+
widget.refresh_content()
|
|
447
|
+
|
|
448
|
+
def _refresh_subagent_screen(self, record: SubagentRecord) -> None:
|
|
449
|
+
screen = self.screen
|
|
450
|
+
if isinstance(screen, SubagentScreen) and screen.child_id == record.agent_id:
|
|
451
|
+
screen.refresh_record()
|
|
452
|
+
|
|
453
|
+
def open_subagent(self, record: SubagentRecord) -> None:
|
|
454
|
+
self._subagents[record.agent_id] = record
|
|
455
|
+
self.push_screen(SubagentScreen(child_id=record.agent_id, parent_id=record.parent_id, workspace=self.workspace))
|
|
456
|
+
|
|
457
|
+
def action_subagents(self) -> None:
|
|
458
|
+
self.push_screen(SubagentTasksScreen())
|
|
459
|
+
|
|
460
|
+
def _refresh_subagent_tasks(self) -> None:
|
|
461
|
+
if isinstance(self.screen, SubagentTasksScreen):
|
|
462
|
+
self.screen.refresh_tasks()
|
|
463
|
+
|
|
464
|
+
async def cancel_subagent(self, child_id: str) -> None:
|
|
465
|
+
harness = getattr(self._agent, "harness", None)
|
|
466
|
+
record = getattr(harness, "child_tasks", {}).get(child_id)
|
|
467
|
+
if record is not None and record.task is not None and not record.task.done():
|
|
468
|
+
import asyncio
|
|
469
|
+
record.task.cancel()
|
|
470
|
+
await asyncio.gather(record.task, return_exceptions=True)
|
|
471
|
+
|
|
472
|
+
async def restore_subagents(self) -> None:
|
|
473
|
+
if self._agent is None:
|
|
474
|
+
return
|
|
475
|
+
store = self._agent.persistence
|
|
476
|
+
load = getattr(store, "load_children", None)
|
|
477
|
+
if not callable(load):
|
|
478
|
+
return
|
|
479
|
+
for metadata in await load(parent_session_id=self._agent.session_id):
|
|
480
|
+
child_id = metadata["child_id"]
|
|
481
|
+
if child_id in self._subagents:
|
|
482
|
+
continue
|
|
483
|
+
self._on_agent_spawned(metadata)
|
|
484
|
+
record = self._subagents[child_id]
|
|
485
|
+
for event_type, payload in await store.load_events(session_id=record.session_id):
|
|
486
|
+
record.ingest(event_type, payload)
|
|
487
|
+
record.status = metadata["status"]
|
|
488
|
+
record.output_text = metadata["output_text"] or record.output_text
|
|
489
|
+
if record.status == "running":
|
|
490
|
+
record.status = "interrupted"
|
|
491
|
+
self._refresh_subagent_widgets(record)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
class SubagentTasksScreen(ModalBase[None]):
|
|
495
|
+
"""Stable access to child sessions even after parent transcript compaction."""
|
|
496
|
+
|
|
497
|
+
CSS = """
|
|
498
|
+
SubagentTasksScreen { align: center middle; background: #000000 60%; }
|
|
499
|
+
#subagent-tasks { width: 90%; height: 70%; border: round #484F58; background: #0A0A0A; }
|
|
500
|
+
"""
|
|
501
|
+
|
|
502
|
+
def compose(self):
|
|
503
|
+
yield OptionList(id="subagent-tasks")
|
|
504
|
+
|
|
505
|
+
def on_mount(self) -> None:
|
|
506
|
+
self.query_one(OptionList).border_title = "Subagents · Enter inspect · Esc back"
|
|
507
|
+
self.refresh_tasks()
|
|
508
|
+
self.query_one(OptionList).focus()
|
|
509
|
+
|
|
510
|
+
def refresh_tasks(self) -> None:
|
|
511
|
+
options = self.query_one(OptionList)
|
|
512
|
+
highlighted = options.highlighted
|
|
513
|
+
options.clear_options()
|
|
514
|
+
records = list(getattr(self.app, "_subagents", {}).values())
|
|
515
|
+
for record in records:
|
|
516
|
+
color = {"running": "#d7a84b", "completed": "#72a57a"}.get(record.status, "#d66b73")
|
|
517
|
+
label = Text(f"{record.status:12} ", style=color)
|
|
518
|
+
label.append(f"{record.label} · {record.model_id} · {record.agent_id[:8]}")
|
|
519
|
+
options.add_option(Option(label, id=record.agent_id))
|
|
520
|
+
if not records:
|
|
521
|
+
options.add_option(Option("No child sessions yet.", disabled=True))
|
|
522
|
+
else:
|
|
523
|
+
options.highlighted = min(highlighted or 0, len(records) - 1)
|
|
524
|
+
|
|
525
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
526
|
+
record = self.app._subagents.get(event.option.id)
|
|
527
|
+
if record is not None:
|
|
528
|
+
from coding_agent.tui.screens.modal import dismiss_overlay
|
|
529
|
+
|
|
530
|
+
dismiss_overlay(self)
|
|
531
|
+
self.app.call_after_refresh(self.app.open_subagent, record)
|