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,458 @@
|
|
|
1
|
+
"""Restore persisted conversation messages into a TUI transcript."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any, Protocol
|
|
8
|
+
|
|
9
|
+
from coding_agent.agent import CodingAgent
|
|
10
|
+
from coding_agent.addons.persistence.collection import COLLECTABLE_EVENT_TYPES, is_collected
|
|
11
|
+
from coding_agent.tui.runtime.events import _duration
|
|
12
|
+
from coding_agent.tui.tools.activity import choose_completion_verb
|
|
13
|
+
from coding_agent.tui.tools.images import display_from_content
|
|
14
|
+
from coding_agent.tui.tools.snapshots import (
|
|
15
|
+
CompletedRunSummary,
|
|
16
|
+
ThoughtSnapshot,
|
|
17
|
+
ToolCallSnapshot,
|
|
18
|
+
ToolCallSummary,
|
|
19
|
+
snapshot_from_call,
|
|
20
|
+
)
|
|
21
|
+
from coding_agent.tui.transcript import UserMessage
|
|
22
|
+
from core_ai.content import text_from_content
|
|
23
|
+
from core_harness.context import COMPACTED_CONTEXT_MARK, estimate_prompt_tokens
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class HistoryView(Protocol):
|
|
27
|
+
def add_notice(self, text: str, tone: str = "info") -> None: ...
|
|
28
|
+
|
|
29
|
+
def mount_transcript(self, widget: Any) -> None: ...
|
|
30
|
+
|
|
31
|
+
def set_context_metrics(self, tokens_used: int, context_limit: int) -> None: ...
|
|
32
|
+
|
|
33
|
+
def finalize_transcript_history(self) -> None: ...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def load_session_history(agent: CodingAgent, view: HistoryView) -> None:
|
|
37
|
+
"""Restore compact history with interactive child and background-job cards."""
|
|
38
|
+
messages, context_messages = await agent.persistence.load_resume(session_id=agent.session_id)
|
|
39
|
+
# Footer usage describes the active context projection, matching /context
|
|
40
|
+
# and the messages the next request will send.
|
|
41
|
+
context_limit = agent.harness.state.context_limit(agent.harness.model_id)
|
|
42
|
+
view.set_context_metrics(estimate_prompt_tokens(context_messages), context_limit)
|
|
43
|
+
view.add_notice(f"Resumed session · {agent.session_id}")
|
|
44
|
+
|
|
45
|
+
load_events = getattr(agent.persistence, "load_events", None)
|
|
46
|
+
events = await load_events(session_id=agent.session_id) if callable(load_events) else []
|
|
47
|
+
restored = _history_widgets(messages, events)
|
|
48
|
+
tools = getattr(view, "_tools", None)
|
|
49
|
+
if isinstance(tools, dict):
|
|
50
|
+
from coding_agent.tui.tools.calls import ToolCallWidget
|
|
51
|
+
|
|
52
|
+
tools.update((widget.call_id, widget) for widget in restored if isinstance(widget, ToolCallWidget))
|
|
53
|
+
mount_batch = getattr(view, "mount_transcript_batch", None)
|
|
54
|
+
if callable(mount_batch):
|
|
55
|
+
mount_batch(restored)
|
|
56
|
+
else:
|
|
57
|
+
for widget in restored:
|
|
58
|
+
view.mount_transcript(widget)
|
|
59
|
+
view.finalize_transcript_history()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _history_widgets(
|
|
63
|
+
messages: list[Any],
|
|
64
|
+
events: list[tuple[str, dict[str, Any]]] | None = None,
|
|
65
|
+
) -> list[Any]:
|
|
66
|
+
from coding_agent.tui.tools.calls import make_tool_widget
|
|
67
|
+
from coding_agent.tui.transcript import AssistantMessage
|
|
68
|
+
|
|
69
|
+
# Parent journals can contain forwarded child events. They belong to the
|
|
70
|
+
# child screen, never to the parent's tool/thought reconstruction.
|
|
71
|
+
events = [
|
|
72
|
+
(kind, payload) for kind, payload in events or []
|
|
73
|
+
if not payload.get("parent_id") or kind.startswith("agent_")
|
|
74
|
+
]
|
|
75
|
+
notifications, job_results = _completion_notifications(messages, events)
|
|
76
|
+
restored: list[Any] = []
|
|
77
|
+
pending: dict[str, dict[str, Any]] = {}
|
|
78
|
+
batch: list[ToolCallSnapshot | ThoughtSnapshot] = []
|
|
79
|
+
thoughts = _thoughts_from_events(events or [])
|
|
80
|
+
event_calls = _tool_fields_from_events(events or [])
|
|
81
|
+
collected_runs = _collected_run_ids(events or [])
|
|
82
|
+
run_completions = _run_completions_from_events(events or [])
|
|
83
|
+
current_run: list[Any] = []
|
|
84
|
+
collecting = False
|
|
85
|
+
active_run_id = ""
|
|
86
|
+
|
|
87
|
+
def flush_batch() -> None:
|
|
88
|
+
if not batch:
|
|
89
|
+
return
|
|
90
|
+
summary = ToolCallSummary(tuple(batch))
|
|
91
|
+
if collecting:
|
|
92
|
+
current_run.append(summary)
|
|
93
|
+
else:
|
|
94
|
+
restored.append(summary)
|
|
95
|
+
batch.clear()
|
|
96
|
+
|
|
97
|
+
def add_snapshot(snapshot: ToolCallSnapshot | ThoughtSnapshot) -> None:
|
|
98
|
+
batch.append(snapshot)
|
|
99
|
+
|
|
100
|
+
def flush_run() -> None:
|
|
101
|
+
nonlocal collecting
|
|
102
|
+
flush_batch()
|
|
103
|
+
if current_run:
|
|
104
|
+
restored.extend(
|
|
105
|
+
_fold_collected_run(
|
|
106
|
+
current_run,
|
|
107
|
+
**(run_completions.pop(0) if run_completions else {}),
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
current_run.clear()
|
|
111
|
+
collecting = False
|
|
112
|
+
|
|
113
|
+
def add_visible(widget: Any) -> None:
|
|
114
|
+
if collecting:
|
|
115
|
+
current_run.append(widget)
|
|
116
|
+
else:
|
|
117
|
+
restored.append(widget)
|
|
118
|
+
|
|
119
|
+
def add_call(fields: dict[str, Any]) -> None:
|
|
120
|
+
nonlocal collecting, active_run_id
|
|
121
|
+
run_id = str(fields.get("run_id") or "")
|
|
122
|
+
if collected_runs and run_id and run_id != active_run_id:
|
|
123
|
+
flush_run()
|
|
124
|
+
active_run_id = run_id
|
|
125
|
+
if collected_runs == {""}:
|
|
126
|
+
collecting = True
|
|
127
|
+
elif collected_runs:
|
|
128
|
+
collecting = bool(run_id and run_id in collected_runs)
|
|
129
|
+
arguments = fields.get("arguments") or {}
|
|
130
|
+
if fields["tool_name"] in {"spawn_agent", "bash"}:
|
|
131
|
+
flush_batch()
|
|
132
|
+
widget = make_tool_widget(str(fields["call_id"]), str(fields["tool_name"]))
|
|
133
|
+
widget.set_arguments(arguments, str(fields.get("raw_arguments") or ""))
|
|
134
|
+
result = str(fields.get("result") or "")
|
|
135
|
+
job = re.search(r"started background job ([A-Za-z0-9_-]+)", result)
|
|
136
|
+
if job and job.group(1) in job_results:
|
|
137
|
+
result = job_results[job.group(1)]
|
|
138
|
+
widget.set_result(result)
|
|
139
|
+
if fields.get("status") == "failed":
|
|
140
|
+
widget.status = "failed"
|
|
141
|
+
add_visible(widget)
|
|
142
|
+
else:
|
|
143
|
+
add_snapshot(snapshot_from_call(**{key: value for key, value in fields.items() if key != "run_id"}))
|
|
144
|
+
|
|
145
|
+
for index, message in enumerate(messages):
|
|
146
|
+
if index in notifications:
|
|
147
|
+
continue
|
|
148
|
+
if message.role == "user":
|
|
149
|
+
if text_from_content(message.content).startswith(COMPACTED_CONTEXT_MARK):
|
|
150
|
+
continue
|
|
151
|
+
flush_run()
|
|
152
|
+
pending.clear()
|
|
153
|
+
text, images = display_from_content(message.content)
|
|
154
|
+
restored.append(UserMessage(text, images=images))
|
|
155
|
+
collecting = bool(collected_runs)
|
|
156
|
+
elif message.role == "assistant":
|
|
157
|
+
content = text_from_content(message.content)
|
|
158
|
+
calls = [_call_fields(call) for call in message.tool_calls or []]
|
|
159
|
+
for fields in calls:
|
|
160
|
+
call_id = str(fields["call_id"])
|
|
161
|
+
fields = _merge_call_fields(fields, event_calls.get(call_id, {}))
|
|
162
|
+
run_id = str(fields.get("run_id") or "")
|
|
163
|
+
if collected_runs and run_id and run_id != active_run_id:
|
|
164
|
+
flush_run()
|
|
165
|
+
active_run_id = run_id
|
|
166
|
+
if collected_runs == {""}:
|
|
167
|
+
collecting = True
|
|
168
|
+
elif collected_runs:
|
|
169
|
+
collecting = bool(run_id and run_id in collected_runs)
|
|
170
|
+
if content:
|
|
171
|
+
flush_batch()
|
|
172
|
+
add_visible(AssistantMessage(content, streaming=False))
|
|
173
|
+
if thoughts:
|
|
174
|
+
add_snapshot(thoughts.pop(0))
|
|
175
|
+
for fields in calls:
|
|
176
|
+
call_id = str(fields["call_id"])
|
|
177
|
+
pending[call_id] = _merge_call_fields(fields, event_calls.pop(call_id, {}))
|
|
178
|
+
elif message.role == "tool":
|
|
179
|
+
call_id = str(message.tool_call_id or "")
|
|
180
|
+
fields = pending.pop(call_id, None) or event_calls.pop(call_id, None) or {
|
|
181
|
+
"call_id": call_id or "history-tool",
|
|
182
|
+
"tool_name": "tool",
|
|
183
|
+
"arguments": {},
|
|
184
|
+
"raw_arguments": "",
|
|
185
|
+
}
|
|
186
|
+
result = text_from_content(message.content)
|
|
187
|
+
add_call({
|
|
188
|
+
**fields,
|
|
189
|
+
"result": result,
|
|
190
|
+
"status": "failed" if result.startswith("error:") else "done",
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
for fields in pending.values():
|
|
194
|
+
add_call({"status": "done", **fields})
|
|
195
|
+
for leftover_fields in event_calls.values():
|
|
196
|
+
add_call({"status": "done", **leftover_fields})
|
|
197
|
+
for leftover in thoughts:
|
|
198
|
+
add_snapshot(leftover)
|
|
199
|
+
flush_run()
|
|
200
|
+
return restored
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _completion_notifications(
|
|
204
|
+
messages: list[Any], events: list[tuple[str, dict[str, Any]]],
|
|
205
|
+
) -> tuple[set[int], dict[str, str]]:
|
|
206
|
+
"""Recognize legacy runtime user messages only for known children/jobs.
|
|
207
|
+
|
|
208
|
+
These messages remain in model context, but are not actual user prompts.
|
|
209
|
+
Correlation avoids hiding ordinary user text mentioning background work.
|
|
210
|
+
"""
|
|
211
|
+
children = {
|
|
212
|
+
str(payload["child_id"]) for kind, payload in events
|
|
213
|
+
if kind == "agent_spawned" and payload.get("child_id")
|
|
214
|
+
}
|
|
215
|
+
jobs: set[str] = set()
|
|
216
|
+
tool_results = {str(message.tool_call_id): message for message in messages if message.role == "tool"}
|
|
217
|
+
for message in messages:
|
|
218
|
+
if message.role == "assistant":
|
|
219
|
+
for call in message.tool_calls or []:
|
|
220
|
+
fields = _call_fields(call)
|
|
221
|
+
if fields["tool_name"] != "spawn_agent":
|
|
222
|
+
continue
|
|
223
|
+
# Older journals may lack agent_spawned events, but the spawn
|
|
224
|
+
# result still carries the child identity.
|
|
225
|
+
result = tool_results.get(fields["call_id"])
|
|
226
|
+
if result is None:
|
|
227
|
+
continue
|
|
228
|
+
try:
|
|
229
|
+
child = json.loads(text_from_content(result.content))
|
|
230
|
+
except (ValueError, TypeError):
|
|
231
|
+
continue
|
|
232
|
+
if isinstance(child, dict) and child.get("child_id"):
|
|
233
|
+
children.add(str(child["child_id"]))
|
|
234
|
+
if message.role == "tool":
|
|
235
|
+
match = re.match(r"started background job ([A-Za-z0-9_-]+)\nlog: ", text_from_content(message.content))
|
|
236
|
+
if match:
|
|
237
|
+
jobs.add(match.group(1))
|
|
238
|
+
hidden: set[int] = set()
|
|
239
|
+
results: dict[str, str] = {}
|
|
240
|
+
for index, message in enumerate(messages):
|
|
241
|
+
if message.role != "user":
|
|
242
|
+
continue
|
|
243
|
+
text = text_from_content(message.content)
|
|
244
|
+
child = re.match(r"Subagent .+ \(([^()]+)\) (?:completed|failed|cancelled|interrupted)\.\n", text)
|
|
245
|
+
job = re.match(r"Background job ([A-Za-z0-9_-]+) .+ after [\d.]+s\. Last output:\n", text)
|
|
246
|
+
if child and child.group(1) in children:
|
|
247
|
+
hidden.add(index)
|
|
248
|
+
elif job and job.group(1) in jobs and "\nFull log: " in text:
|
|
249
|
+
hidden.add(index)
|
|
250
|
+
results[job.group(1)] = text
|
|
251
|
+
return hidden, results
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _thoughts_from_events(events: list[tuple[str, dict[str, Any]]]) -> list[ThoughtSnapshot]:
|
|
255
|
+
"""Restore completed thoughts from persisted events when they exist.
|
|
256
|
+
|
|
257
|
+
Live reasoning deltas are skipped by session persistence, so this only
|
|
258
|
+
reconstructs compact snapshots from completed thought payloads. History
|
|
259
|
+
never remounts live ReasoningWidget cards.
|
|
260
|
+
"""
|
|
261
|
+
thoughts: list[ThoughtSnapshot] = []
|
|
262
|
+
for event_type, payload in events:
|
|
263
|
+
if event_type not in {"reasoning_completed", "thought_completed"}:
|
|
264
|
+
continue
|
|
265
|
+
content = str(payload.get("text") or payload.get("content") or "").strip()
|
|
266
|
+
if not content:
|
|
267
|
+
continue
|
|
268
|
+
title = str(payload.get("title") or "Thought").strip() or "Thought"
|
|
269
|
+
thoughts.append(ThoughtSnapshot(title=title, content=content))
|
|
270
|
+
return thoughts
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _collected_run_ids(events: list[tuple[str, dict[str, Any]]]) -> set[str]:
|
|
274
|
+
"""Runs whose persisted mid-run work was explicitly folded.
|
|
275
|
+
|
|
276
|
+
Empty means a legacy collected event had no run ID. Those journals cannot
|
|
277
|
+
distinguish runs, so resume keeps the historical session-wide fold.
|
|
278
|
+
"""
|
|
279
|
+
collected = [
|
|
280
|
+
payload for event_type, payload in events
|
|
281
|
+
if event_type in COLLECTABLE_EVENT_TYPES and is_collected(payload)
|
|
282
|
+
]
|
|
283
|
+
if not collected:
|
|
284
|
+
return set()
|
|
285
|
+
if any(not payload.get("run_id") for payload in collected):
|
|
286
|
+
return {""}
|
|
287
|
+
return {str(payload["run_id"]) for payload in collected}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _tool_fields_from_events(
|
|
291
|
+
events: list[tuple[str, dict[str, Any]]],
|
|
292
|
+
) -> dict[str, dict[str, Any]]:
|
|
293
|
+
"""Use persisted protocol arguments as the source of truth for history tools."""
|
|
294
|
+
calls: dict[str, dict[str, Any]] = {}
|
|
295
|
+
for event_type, payload in events:
|
|
296
|
+
if event_type not in {"tool_call_started", "tool_execution_started", "tool_execution_completed", "agent_spawned"}:
|
|
297
|
+
continue
|
|
298
|
+
call_id = str(payload.get("tool_call_id") or "")
|
|
299
|
+
if not call_id:
|
|
300
|
+
continue
|
|
301
|
+
current = calls.setdefault(
|
|
302
|
+
call_id,
|
|
303
|
+
{
|
|
304
|
+
"call_id": call_id,
|
|
305
|
+
"tool_name": "tool",
|
|
306
|
+
"arguments": {},
|
|
307
|
+
"raw_arguments": "",
|
|
308
|
+
"run_id": "",
|
|
309
|
+
},
|
|
310
|
+
)
|
|
311
|
+
tool_name = str(payload.get("tool_name") or "")
|
|
312
|
+
if tool_name:
|
|
313
|
+
current["tool_name"] = tool_name
|
|
314
|
+
arguments = payload.get("arguments")
|
|
315
|
+
if isinstance(arguments, dict) and arguments:
|
|
316
|
+
current["arguments"] = dict(arguments)
|
|
317
|
+
current["raw_arguments"] = json.dumps(arguments, ensure_ascii=False)
|
|
318
|
+
run_id = str(payload.get("run_id") or "")
|
|
319
|
+
if run_id:
|
|
320
|
+
current["run_id"] = run_id
|
|
321
|
+
if event_type == "agent_spawned":
|
|
322
|
+
call_id = str(payload.get("tool_call_id") or "")
|
|
323
|
+
if call_id:
|
|
324
|
+
calls.setdefault(call_id, {
|
|
325
|
+
"call_id": call_id,
|
|
326
|
+
"tool_name": "spawn_agent",
|
|
327
|
+
"arguments": {},
|
|
328
|
+
"raw_arguments": "",
|
|
329
|
+
"run_id": "",
|
|
330
|
+
})["run_id"] = str(payload.get("run_id") or calls[call_id]["run_id"])
|
|
331
|
+
continue
|
|
332
|
+
if event_type == "tool_execution_completed":
|
|
333
|
+
result = payload.get("result")
|
|
334
|
+
if result:
|
|
335
|
+
current["result"] = str(result)
|
|
336
|
+
status = str(payload.get("status") or "")
|
|
337
|
+
if status in {"error", "cancelled", "timeout"}:
|
|
338
|
+
current["status"] = "failed"
|
|
339
|
+
elif status:
|
|
340
|
+
current["status"] = "done"
|
|
341
|
+
return calls
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _merge_call_fields(preferred: dict[str, Any], fallback: dict[str, Any]) -> dict[str, Any]:
|
|
345
|
+
merged = dict(fallback)
|
|
346
|
+
for key, value in preferred.items():
|
|
347
|
+
if value in ("", None, {}, []) or key == "tool_name" and value == "tool":
|
|
348
|
+
continue
|
|
349
|
+
merged[key] = value
|
|
350
|
+
return merged
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _run_completions_from_events(
|
|
354
|
+
events: list[tuple[str, dict[str, Any]]],
|
|
355
|
+
) -> list[dict[str, str]]:
|
|
356
|
+
"""Restore completed-run verb and duration from persisted run events."""
|
|
357
|
+
completions: list[dict[str, str]] = []
|
|
358
|
+
started: dict[str, float] = {}
|
|
359
|
+
fallback_started: float | None = None
|
|
360
|
+
for event_type, payload in events:
|
|
361
|
+
run_id = str(payload.get("run_id") or "")
|
|
362
|
+
if event_type == "run_started":
|
|
363
|
+
ts = payload.get("ts")
|
|
364
|
+
if isinstance(ts, (int, float)):
|
|
365
|
+
if run_id:
|
|
366
|
+
started[run_id] = float(ts)
|
|
367
|
+
else:
|
|
368
|
+
fallback_started = float(ts)
|
|
369
|
+
continue
|
|
370
|
+
if event_type not in {"run_completed", "run_completed_meta"}:
|
|
371
|
+
continue
|
|
372
|
+
duration = str(payload.get("duration") or "").strip()
|
|
373
|
+
stored = payload.get("elapsed_seconds")
|
|
374
|
+
if not duration and isinstance(stored, (int, float)):
|
|
375
|
+
duration = _duration(float(stored))
|
|
376
|
+
if not duration and event_type == "run_completed":
|
|
377
|
+
start = started.get(run_id, fallback_started)
|
|
378
|
+
end = payload.get("ts")
|
|
379
|
+
if isinstance(start, (int, float)) and isinstance(end, (int, float)):
|
|
380
|
+
duration = _duration(max(0.0, float(end) - float(start)))
|
|
381
|
+
verb = str(payload.get("completion_verb") or "").strip()
|
|
382
|
+
if event_type == "run_completed_meta" and completions:
|
|
383
|
+
current = completions[-1]
|
|
384
|
+
if verb:
|
|
385
|
+
current["verb"] = verb
|
|
386
|
+
if duration:
|
|
387
|
+
current["duration"] = duration
|
|
388
|
+
continue
|
|
389
|
+
completions.append({"verb": verb, "duration": duration})
|
|
390
|
+
return completions
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _folds_on_completion(item: Any) -> bool:
|
|
394
|
+
"""Match the live completed-run rule for foreground cards."""
|
|
395
|
+
from coding_agent.tui.tools.calls import BashToolWidget, PatchDiffWidget, ToolCallWidget
|
|
396
|
+
|
|
397
|
+
return isinstance(item, ToolCallWidget) and (
|
|
398
|
+
not item.keep_in_transcript
|
|
399
|
+
or isinstance(item, PatchDiffWidget)
|
|
400
|
+
or isinstance(item, BashToolWidget) and not item.arguments.get("background")
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _fold_collected_run(
|
|
405
|
+
items: list[Any],
|
|
406
|
+
*,
|
|
407
|
+
verb: str = "",
|
|
408
|
+
duration: str = "",
|
|
409
|
+
) -> list[Any]:
|
|
410
|
+
"""Fold mid-run history into a completed-run collection, keep the final reply."""
|
|
411
|
+
from coding_agent.tui.transcript import AssistantMessage
|
|
412
|
+
|
|
413
|
+
summary = CompletedRunSummary(
|
|
414
|
+
verb=verb or choose_completion_verb(),
|
|
415
|
+
duration=duration,
|
|
416
|
+
)
|
|
417
|
+
assistants: list[Any] = []
|
|
418
|
+
cards: list[Any] = []
|
|
419
|
+
for item in items:
|
|
420
|
+
if isinstance(item, AssistantMessage):
|
|
421
|
+
assistants.append(item)
|
|
422
|
+
continue
|
|
423
|
+
if isinstance(item, ToolCallSummary):
|
|
424
|
+
for entry in item.entries:
|
|
425
|
+
if isinstance(entry, ThoughtSnapshot):
|
|
426
|
+
summary.add_thought(entry.title, entry.content, layout=False)
|
|
427
|
+
else:
|
|
428
|
+
summary.add_call(entry, layout=False)
|
|
429
|
+
elif _folds_on_completion(item):
|
|
430
|
+
summary.add_call(item.snapshot(), layout=False)
|
|
431
|
+
else:
|
|
432
|
+
cards.append(item)
|
|
433
|
+
folded: list[Any] = list(cards)
|
|
434
|
+
if summary.entries:
|
|
435
|
+
folded.append(summary)
|
|
436
|
+
if assistants:
|
|
437
|
+
folded.append(assistants[-1])
|
|
438
|
+
elif not folded:
|
|
439
|
+
folded.extend(items)
|
|
440
|
+
return folded
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _call_fields(call: Any) -> dict[str, Any]:
|
|
444
|
+
function = call.get("function") or {}
|
|
445
|
+
raw = function.get("arguments") or call.get("arguments") or ""
|
|
446
|
+
try:
|
|
447
|
+
args = json.loads(raw) if isinstance(raw, str) else raw
|
|
448
|
+
except json.JSONDecodeError:
|
|
449
|
+
args = {}
|
|
450
|
+
if not isinstance(args, dict):
|
|
451
|
+
args = {}
|
|
452
|
+
return {
|
|
453
|
+
"call_id": str(call.get("id") or "history-tool"),
|
|
454
|
+
"run_id": "",
|
|
455
|
+
"tool_name": str(function.get("name") or call.get("name") or "tool"),
|
|
456
|
+
"arguments": args,
|
|
457
|
+
"raw_arguments": raw if isinstance(raw, str) else "",
|
|
458
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Small in-session setup form for Langfuse credentials."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.containers import Container
|
|
9
|
+
from textual.widgets import Input, Static
|
|
10
|
+
|
|
11
|
+
from coding_agent.config.credentials import global_env_path, upsert_dotenv
|
|
12
|
+
from coding_agent.tui.screens.modal import ModalBase, ModalCloseButton
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LangfuseSetupScreen(ModalBase[bool]):
|
|
16
|
+
"""Collect Langfuse credentials without displaying the secret key."""
|
|
17
|
+
|
|
18
|
+
# Match the provider onboarding modal: a full-size, dark modal pane with
|
|
19
|
+
# the same header, input, error, and footer treatment.
|
|
20
|
+
CSS = """
|
|
21
|
+
LangfuseSetupScreen {
|
|
22
|
+
align: center middle;
|
|
23
|
+
background: rgba(0, 0, 0, 0.52);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#langfuse-pane {
|
|
27
|
+
width: 98%;
|
|
28
|
+
max-width: 180;
|
|
29
|
+
height: 94%;
|
|
30
|
+
padding: 0 1;
|
|
31
|
+
background: #0A0A0A;
|
|
32
|
+
border: round #262626;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#langfuse-pane > #modal-close {
|
|
36
|
+
background: #0A0A0A;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#langfuse-title {
|
|
40
|
+
width: 100%;
|
|
41
|
+
height: 3;
|
|
42
|
+
min-height: 3;
|
|
43
|
+
padding: 1 8 0 1;
|
|
44
|
+
color: #d9dde0;
|
|
45
|
+
text-style: bold;
|
|
46
|
+
background: #0A0A0A;
|
|
47
|
+
border-bottom: solid #262626;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
#langfuse-subtitle {
|
|
51
|
+
width: 100%;
|
|
52
|
+
height: 2;
|
|
53
|
+
padding: 1 1 0 1;
|
|
54
|
+
color: #858d91;
|
|
55
|
+
background: #0A0A0A;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#langfuse-pane Input {
|
|
59
|
+
width: 100%;
|
|
60
|
+
height: 3;
|
|
61
|
+
margin: 1 1 0 1;
|
|
62
|
+
background: #141414;
|
|
63
|
+
color: #ededed;
|
|
64
|
+
border: tall #2a2a2a;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#langfuse-pane Input:focus {
|
|
68
|
+
border: tall #e1c16e;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
#langfuse-error {
|
|
72
|
+
height: 1;
|
|
73
|
+
margin: 1 1 0 1;
|
|
74
|
+
color: #db6767;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#langfuse-hint {
|
|
78
|
+
width: 100%;
|
|
79
|
+
height: 2;
|
|
80
|
+
padding: 1 1 0 1;
|
|
81
|
+
color: #686868;
|
|
82
|
+
background: #0A0A0A;
|
|
83
|
+
border-top: solid #262626;
|
|
84
|
+
}
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def compose(self) -> ComposeResult:
|
|
88
|
+
with Container(id="langfuse-pane", classes="modal-pane"):
|
|
89
|
+
yield ModalCloseButton("Esc", id="modal-close")
|
|
90
|
+
yield Static("Langfuse setup", id="langfuse-title")
|
|
91
|
+
yield Static(
|
|
92
|
+
"Send run traces to Langfuse. Keys are saved to ~/.symphony/.env (0600).",
|
|
93
|
+
id="langfuse-subtitle",
|
|
94
|
+
)
|
|
95
|
+
yield Input(
|
|
96
|
+
value=os.environ.get("LANGFUSE_PUBLIC_KEY", ""),
|
|
97
|
+
placeholder="pk-lf-...",
|
|
98
|
+
id="langfuse-public-key",
|
|
99
|
+
)
|
|
100
|
+
yield Input(
|
|
101
|
+
placeholder="sk-lf-...",
|
|
102
|
+
password=True,
|
|
103
|
+
id="langfuse-secret-key",
|
|
104
|
+
)
|
|
105
|
+
yield Input(
|
|
106
|
+
value=os.environ.get("LANGFUSE_BASE_URL", "https://cloud.langfuse.com"),
|
|
107
|
+
placeholder="https://cloud.langfuse.com",
|
|
108
|
+
id="langfuse-base-url",
|
|
109
|
+
)
|
|
110
|
+
yield Static("Enter submits · Esc cancels", id="langfuse-hint")
|
|
111
|
+
yield Static("", id="langfuse-error")
|
|
112
|
+
|
|
113
|
+
def on_mount(self) -> None:
|
|
114
|
+
self.query_one("#langfuse-public-key", Input).focus()
|
|
115
|
+
|
|
116
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
117
|
+
if event.input.id == "langfuse-public-key":
|
|
118
|
+
self.query_one("#langfuse-secret-key", Input).focus()
|
|
119
|
+
return
|
|
120
|
+
if event.input.id == "langfuse-secret-key":
|
|
121
|
+
self.query_one("#langfuse-base-url", Input).focus()
|
|
122
|
+
return
|
|
123
|
+
public_key = self.query_one("#langfuse-public-key", Input).value.strip()
|
|
124
|
+
secret_key = self.query_one("#langfuse-secret-key", Input).value.strip()
|
|
125
|
+
base_url = self.query_one("#langfuse-base-url", Input).value.strip()
|
|
126
|
+
if not public_key or not secret_key:
|
|
127
|
+
self.query_one("#langfuse-error", Static).update(
|
|
128
|
+
"Public and secret keys are required."
|
|
129
|
+
)
|
|
130
|
+
return
|
|
131
|
+
updates = {
|
|
132
|
+
"LANGFUSE_PUBLIC_KEY": public_key,
|
|
133
|
+
"LANGFUSE_SECRET_KEY": secret_key,
|
|
134
|
+
}
|
|
135
|
+
if base_url:
|
|
136
|
+
updates["LANGFUSE_BASE_URL"] = base_url
|
|
137
|
+
upsert_dotenv(global_env_path(), updates)
|
|
138
|
+
os.environ.update(updates)
|
|
139
|
+
self.dismiss(True)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
__all__ = ["LangfuseSetupScreen"]
|