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,33 @@
|
|
|
1
|
+
"""Plain-text body of the /addons command (TUI and stdio)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from core_harness.extensions import AddonFailure
|
|
6
|
+
|
|
7
|
+
from coding_agent.addons.manager.bridge import AddonSurface
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def addons_report(surface: AddonSurface, hook_failures: tuple[AddonFailure, ...] = ()) -> str:
|
|
11
|
+
exposed_tools = surface.tool_names
|
|
12
|
+
exposed_commands = {item.name for item in surface.commands}
|
|
13
|
+
lines = ["Loaded addons:"]
|
|
14
|
+
for addon in surface.snapshot.loaded:
|
|
15
|
+
tools = ", ".join(tool.name for tool in addon.tools if tool.name in exposed_tools) or "none"
|
|
16
|
+
commands = ", ".join(
|
|
17
|
+
f"/{item.name}" for item in addon.commands if item.name in exposed_commands
|
|
18
|
+
) or "none"
|
|
19
|
+
lines.append(f" {addon.name} {addon.version} · tools: {tools} · commands: {commands}")
|
|
20
|
+
if not surface.snapshot.loaded:
|
|
21
|
+
lines.append(" none")
|
|
22
|
+
failures = surface.snapshot.failed + hook_failures
|
|
23
|
+
lines.append("Failed addons:")
|
|
24
|
+
lines.extend(f" {item.name} · {item.phase} · {item.error}" for item in failures)
|
|
25
|
+
if not failures:
|
|
26
|
+
lines.append(" none")
|
|
27
|
+
if surface.notices:
|
|
28
|
+
lines.append("Notices:")
|
|
29
|
+
lines.extend(f" {notice}" for notice in surface.notices)
|
|
30
|
+
return "\n".join(lines)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
__all__ = ["addons_report"]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""The live user-addon state of one agent.
|
|
2
|
+
|
|
3
|
+
Holds the current ``AddonSurface``, forwards sink events to addon
|
|
4
|
+
handlers, and runs the /reload swap: build the new snapshot, dispose the
|
|
5
|
+
old addons, then swap. A reload asked for during a run waits for the run
|
|
6
|
+
to end.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import inspect
|
|
12
|
+
from typing import Any, Callable, Collection, Mapping
|
|
13
|
+
|
|
14
|
+
from core_harness.extensions import AddonFailure, dispatch_event, dispose_addons
|
|
15
|
+
|
|
16
|
+
from coding_agent.addons.manager.bridge import AddonSurface, build_surface
|
|
17
|
+
from coding_agent.addons.manager.discovery import load_user_addons
|
|
18
|
+
from coding_agent.addons.manager.tool_changes import tool_change_notice
|
|
19
|
+
|
|
20
|
+
Loader = Callable[[Collection[str]], Any]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class UserAddonRuntime:
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
*,
|
|
27
|
+
builtin_tool_names: Collection[str],
|
|
28
|
+
reserved_names: Collection[str] = (),
|
|
29
|
+
loader: Loader = load_user_addons,
|
|
30
|
+
) -> None:
|
|
31
|
+
self.builtin_tool_names = frozenset(builtin_tool_names)
|
|
32
|
+
self.reserved_names = frozenset(reserved_names)
|
|
33
|
+
self.loader = loader
|
|
34
|
+
self.surface: AddonSurface = self.build()
|
|
35
|
+
self.hook_failures: tuple[AddonFailure, ...] = ()
|
|
36
|
+
self.reload_pending = False
|
|
37
|
+
|
|
38
|
+
def build(self) -> AddonSurface:
|
|
39
|
+
return build_surface(self.loader(self.reserved_names), builtin_tool_names=self.builtin_tool_names)
|
|
40
|
+
|
|
41
|
+
def on_event(self, event: str, payload: Mapping[str, Any]) -> None:
|
|
42
|
+
"""Sink listener. Never raises; failures are kept for /addons."""
|
|
43
|
+
failures = dispatch_event(self.surface.snapshot, event, payload)
|
|
44
|
+
if failures:
|
|
45
|
+
self.hook_failures = (self.hook_failures + failures)[-20:]
|
|
46
|
+
|
|
47
|
+
def dispose(self) -> tuple[AddonFailure, ...]:
|
|
48
|
+
failures = dispose_addons(self.surface.snapshot.loaded)
|
|
49
|
+
self.hook_failures = (self.hook_failures + failures)[-20:]
|
|
50
|
+
return failures
|
|
51
|
+
|
|
52
|
+
def swap(self, surface: AddonSurface) -> str:
|
|
53
|
+
"""Dispose the current addons, install ``surface`` and describe the tool change."""
|
|
54
|
+
before = self.surface.tool_names
|
|
55
|
+
self.dispose()
|
|
56
|
+
self.surface = surface
|
|
57
|
+
return tool_change_notice(before, surface.tool_names)
|
|
58
|
+
|
|
59
|
+
async def run_command(self, name: str, argument: str) -> str | None:
|
|
60
|
+
"""Run an addon command. ``None`` when no addon owns ``name``."""
|
|
61
|
+
command = self.surface.command(name)
|
|
62
|
+
if command is None:
|
|
63
|
+
return None
|
|
64
|
+
try:
|
|
65
|
+
reply = command.handler(argument)
|
|
66
|
+
if inspect.isawaitable(reply):
|
|
67
|
+
reply = await reply
|
|
68
|
+
except Exception as exc: # noqa: BLE001 - addon code is untrusted
|
|
69
|
+
self.hook_failures = (self.hook_failures + (
|
|
70
|
+
AddonFailure(command.name, "command", f"{type(exc).__name__}: {exc}"),
|
|
71
|
+
))[-20:]
|
|
72
|
+
return f"/{name} failed · {type(exc).__name__}: {exc}"
|
|
73
|
+
return str(reply)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
__all__ = ["UserAddonRuntime"]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""The notice shown after /reload swaps user addons."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Collection
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def tool_change_notice(before: Collection[str], after: Collection[str]) -> str:
|
|
9
|
+
added = len(set(after) - set(before))
|
|
10
|
+
removed = len(set(before) - set(after))
|
|
11
|
+
if not added and not removed:
|
|
12
|
+
return "no tool changes"
|
|
13
|
+
return f"tools changed (+{added}/-{removed}): prompt cache will rebuild"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
__all__ = ["tool_change_notice"]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Coding-agent persistence backends."""
|
|
2
|
+
|
|
3
|
+
from coding_agent.addons.persistence.collection import (
|
|
4
|
+
COLLECTABLE_EVENT_TYPES,
|
|
5
|
+
TERMINAL_EVENT_TYPES,
|
|
6
|
+
apply_collection,
|
|
7
|
+
collectable_from_events,
|
|
8
|
+
collected_keys,
|
|
9
|
+
event_key,
|
|
10
|
+
is_collectable_event,
|
|
11
|
+
is_collected,
|
|
12
|
+
)
|
|
13
|
+
from coding_agent.addons.persistence.active import (
|
|
14
|
+
ActiveSession,
|
|
15
|
+
active_dir,
|
|
16
|
+
active_session_ids,
|
|
17
|
+
list_active,
|
|
18
|
+
register_active,
|
|
19
|
+
release_active,
|
|
20
|
+
)
|
|
21
|
+
from coding_agent.addons.persistence.jsonl import JsonlPersistence, SessionSummary, sessions_dir
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"ActiveSession",
|
|
25
|
+
"COLLECTABLE_EVENT_TYPES",
|
|
26
|
+
"JsonlPersistence",
|
|
27
|
+
"SessionSummary",
|
|
28
|
+
"TERMINAL_EVENT_TYPES",
|
|
29
|
+
"active_dir",
|
|
30
|
+
"active_session_ids",
|
|
31
|
+
"apply_collection",
|
|
32
|
+
"collectable_from_events",
|
|
33
|
+
"collected_keys",
|
|
34
|
+
"event_key",
|
|
35
|
+
"is_collectable_event",
|
|
36
|
+
"is_collected",
|
|
37
|
+
"list_active",
|
|
38
|
+
"register_active",
|
|
39
|
+
"release_active",
|
|
40
|
+
"sessions_dir",
|
|
41
|
+
]
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Live registry of Symphony processes that currently own a session.
|
|
2
|
+
|
|
3
|
+
Resume should not offer a conversation another process is still using. Each
|
|
4
|
+
running agent writes one small JSON file under ``~/.symphony/active`` and
|
|
5
|
+
removes it on a clean exit. A file whose process is gone is stale and ignored.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def active_dir() -> Path:
|
|
18
|
+
"""Directory that holds one registry file per live Symphony process."""
|
|
19
|
+
target = (Path.home() / ".symphony" / "active").expanduser()
|
|
20
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
return target
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _path(pid: int) -> Path:
|
|
25
|
+
return active_dir() / f"{pid}.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class ActiveSession:
|
|
30
|
+
"""One Symphony process and the session it currently owns."""
|
|
31
|
+
|
|
32
|
+
session_id: str
|
|
33
|
+
pid: int
|
|
34
|
+
kind: str
|
|
35
|
+
workspace: str
|
|
36
|
+
model_id: str = ""
|
|
37
|
+
label: str = ""
|
|
38
|
+
|
|
39
|
+
def as_dict(self) -> dict[str, Any]:
|
|
40
|
+
return {
|
|
41
|
+
"session_id": self.session_id,
|
|
42
|
+
"pid": self.pid,
|
|
43
|
+
"kind": self.kind,
|
|
44
|
+
"workspace": self.workspace,
|
|
45
|
+
"model_id": self.model_id,
|
|
46
|
+
"label": self.label,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def register_active(
|
|
51
|
+
session_id: str,
|
|
52
|
+
*,
|
|
53
|
+
workspace: str | Path,
|
|
54
|
+
kind: str,
|
|
55
|
+
model_id: str = "",
|
|
56
|
+
label: str = "",
|
|
57
|
+
pid: int | None = None,
|
|
58
|
+
) -> ActiveSession:
|
|
59
|
+
"""Record that this process owns ``session_id``. Replaces any prior record."""
|
|
60
|
+
record = ActiveSession(
|
|
61
|
+
session_id=session_id,
|
|
62
|
+
pid=os.getpid() if pid is None else pid,
|
|
63
|
+
kind=kind,
|
|
64
|
+
workspace=str(workspace),
|
|
65
|
+
model_id=model_id,
|
|
66
|
+
label=label,
|
|
67
|
+
)
|
|
68
|
+
path = _path(record.pid)
|
|
69
|
+
temporary = path.with_suffix(".json.tmp")
|
|
70
|
+
temporary.write_text(json.dumps(record.as_dict()), encoding="utf-8")
|
|
71
|
+
temporary.replace(path)
|
|
72
|
+
return record
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def release_active(pid: int | None = None) -> None:
|
|
76
|
+
"""Drop this process's registry file. Missing files are fine."""
|
|
77
|
+
path = _path(os.getpid() if pid is None else pid)
|
|
78
|
+
try:
|
|
79
|
+
path.unlink()
|
|
80
|
+
except FileNotFoundError:
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _load(path: Path) -> ActiveSession | None:
|
|
85
|
+
try:
|
|
86
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
87
|
+
except (OSError, json.JSONDecodeError):
|
|
88
|
+
return None
|
|
89
|
+
if not isinstance(payload, dict):
|
|
90
|
+
return None
|
|
91
|
+
session_id = str(payload.get("session_id") or "")
|
|
92
|
+
try:
|
|
93
|
+
pid = int(payload.get("pid") or 0)
|
|
94
|
+
except (TypeError, ValueError):
|
|
95
|
+
return None
|
|
96
|
+
if not session_id or pid <= 0:
|
|
97
|
+
return None
|
|
98
|
+
return ActiveSession(
|
|
99
|
+
session_id=session_id,
|
|
100
|
+
pid=pid,
|
|
101
|
+
kind=str(payload.get("kind") or "agent"),
|
|
102
|
+
workspace=str(payload.get("workspace") or ""),
|
|
103
|
+
model_id=str(payload.get("model_id") or ""),
|
|
104
|
+
label=str(payload.get("label") or ""),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def list_active(*, include_dead: bool = False) -> list[ActiveSession]:
|
|
109
|
+
"""Return live sessions, dropping registry files whose process has exited."""
|
|
110
|
+
from coding_agent.resources.usage import process_alive
|
|
111
|
+
|
|
112
|
+
records: list[ActiveSession] = []
|
|
113
|
+
for path in sorted(active_dir().glob("*.json")):
|
|
114
|
+
record = _load(path)
|
|
115
|
+
if record is None:
|
|
116
|
+
continue
|
|
117
|
+
if process_alive(record.pid):
|
|
118
|
+
records.append(record)
|
|
119
|
+
continue
|
|
120
|
+
if include_dead:
|
|
121
|
+
records.append(record)
|
|
122
|
+
continue
|
|
123
|
+
try:
|
|
124
|
+
path.unlink()
|
|
125
|
+
except OSError:
|
|
126
|
+
pass
|
|
127
|
+
return records
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def active_session_ids() -> set[str]:
|
|
131
|
+
"""Session ids currently owned by a live Symphony process."""
|
|
132
|
+
return {record.session_id for record in list_active()}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Safe session artifact paths and atomic publication helpers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def safe_path(root: Path, *parts: str) -> Path:
|
|
12
|
+
"""Reject symlinks, including links that happen to point inside the root."""
|
|
13
|
+
path = root
|
|
14
|
+
if root.is_symlink():
|
|
15
|
+
raise ValueError(f"unsafe artifact directory: {root}")
|
|
16
|
+
for part in parts:
|
|
17
|
+
component = Path(part)
|
|
18
|
+
if component.is_absolute() or any(p in {".", ".."} for p in component.parts):
|
|
19
|
+
raise ValueError(f"unsafe artifact path: {part!r}")
|
|
20
|
+
for name in component.parts:
|
|
21
|
+
path = path / name
|
|
22
|
+
if path.is_symlink():
|
|
23
|
+
raise ValueError(f"unsafe artifact symlink: {path}")
|
|
24
|
+
if not path.resolve().is_relative_to(root.resolve()):
|
|
25
|
+
raise ValueError(f"artifact path escapes root: {path}")
|
|
26
|
+
return path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def fsync_dir(directory: Path) -> None:
|
|
30
|
+
"""Make a rename or create durable. The file fsync alone does not."""
|
|
31
|
+
fd = os.open(directory, os.O_RDONLY)
|
|
32
|
+
try:
|
|
33
|
+
os.fsync(fd)
|
|
34
|
+
finally:
|
|
35
|
+
os.close(fd)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def publish(path: Path, payload: bytes) -> None:
|
|
39
|
+
"""Publish a complete file using a unique sibling temporary file."""
|
|
40
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
if path.is_symlink():
|
|
42
|
+
raise ValueError(f"unsafe artifact symlink: {path}")
|
|
43
|
+
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
|
|
44
|
+
replaced = False
|
|
45
|
+
try:
|
|
46
|
+
with os.fdopen(fd, "wb") as handle:
|
|
47
|
+
handle.write(payload)
|
|
48
|
+
handle.flush()
|
|
49
|
+
os.fsync(handle.fileno())
|
|
50
|
+
os.replace(temporary, path)
|
|
51
|
+
replaced = True
|
|
52
|
+
fsync_dir(path.parent)
|
|
53
|
+
finally:
|
|
54
|
+
if not replaced:
|
|
55
|
+
Path(temporary).unlink(missing_ok=True)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def append_bytes(path: Path, payload: bytes) -> None:
|
|
59
|
+
"""Append one durable record. The line is fsynced before this returns.
|
|
60
|
+
|
|
61
|
+
The directory is fsynced only when this call created the file: appending
|
|
62
|
+
to an existing file adds no directory entry, and the file fsync already
|
|
63
|
+
makes the new length durable.
|
|
64
|
+
"""
|
|
65
|
+
if not payload:
|
|
66
|
+
return
|
|
67
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
if path.is_symlink():
|
|
69
|
+
raise ValueError(f"unsafe artifact symlink: {path}")
|
|
70
|
+
created = not path.exists()
|
|
71
|
+
flags = os.O_APPEND | os.O_CREAT | os.O_WRONLY
|
|
72
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
73
|
+
flags |= os.O_NOFOLLOW
|
|
74
|
+
fd = os.open(path, flags, 0o644)
|
|
75
|
+
try:
|
|
76
|
+
view = payload
|
|
77
|
+
while view:
|
|
78
|
+
written = os.write(fd, view)
|
|
79
|
+
if written <= 0:
|
|
80
|
+
raise OSError(f"short write to {path}")
|
|
81
|
+
view = view[written:]
|
|
82
|
+
os.fsync(fd)
|
|
83
|
+
finally:
|
|
84
|
+
os.close(fd)
|
|
85
|
+
if created:
|
|
86
|
+
fsync_dir(path.parent)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _snapshot_bytes(payload: dict[str, Any]) -> bytes:
|
|
90
|
+
return (json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def snapshot(path: Path, payload: dict[str, Any]) -> None:
|
|
94
|
+
publish(path, _snapshot_bytes(payload))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def refresh_snapshot(path: Path, payload: dict[str, Any]) -> None:
|
|
98
|
+
"""Publish ``payload`` only when the file is missing or holds other bytes.
|
|
99
|
+
|
|
100
|
+
Re-reading a journal repairs artifacts a crash left stale. When they are
|
|
101
|
+
already current this costs one small read instead of two fsyncs.
|
|
102
|
+
"""
|
|
103
|
+
encoded = _snapshot_bytes(payload)
|
|
104
|
+
if not path.is_symlink():
|
|
105
|
+
try:
|
|
106
|
+
if path.read_bytes() == encoded:
|
|
107
|
+
return
|
|
108
|
+
except OSError:
|
|
109
|
+
pass
|
|
110
|
+
publish(path, encoded)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Mark mid-run control-plane events as collected after a run finishes.
|
|
2
|
+
|
|
3
|
+
The JSONL journal is append-only, so collection never rewrites historical
|
|
4
|
+
entries. A later ``collected`` record points at the original ``(run_id, seq)``
|
|
5
|
+
and stays sticky: once tagged, an event remains collected on every later load.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Iterable, Mapping, Sequence
|
|
11
|
+
|
|
12
|
+
# Events that belong to the work between the user message and the final
|
|
13
|
+
# assistant reply. Token-delta types are never persisted, so they are omitted.
|
|
14
|
+
COLLECTABLE_EVENT_TYPES = frozenset(
|
|
15
|
+
{
|
|
16
|
+
"turn_started",
|
|
17
|
+
"turn_completed",
|
|
18
|
+
"model_retry_scheduled",
|
|
19
|
+
"tool_call_started",
|
|
20
|
+
"tool_execution_started",
|
|
21
|
+
"tool_execution_completed",
|
|
22
|
+
"usage",
|
|
23
|
+
"context",
|
|
24
|
+
"context_warning",
|
|
25
|
+
"compaction_started",
|
|
26
|
+
"compaction_completed",
|
|
27
|
+
"message_injected",
|
|
28
|
+
"waiting_for_children",
|
|
29
|
+
"agent_spawned",
|
|
30
|
+
"agent_completed",
|
|
31
|
+
"agent_failed",
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
TERMINAL_EVENT_TYPES = frozenset(
|
|
36
|
+
{
|
|
37
|
+
"run_completed",
|
|
38
|
+
"run_failed",
|
|
39
|
+
"run_cancelled",
|
|
40
|
+
"run_limit_exceeded",
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def is_collected(payload: Mapping[str, Any] | None) -> bool:
|
|
46
|
+
"""True when a payload (or a later overlay) has ``collected: true``."""
|
|
47
|
+
return bool(payload and payload.get("collected") is True)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def event_key(payload: Mapping[str, Any] | None) -> tuple[str, int] | None:
|
|
51
|
+
"""Stable identity for a stamped harness event."""
|
|
52
|
+
if not payload:
|
|
53
|
+
return None
|
|
54
|
+
run_id = payload.get("run_id")
|
|
55
|
+
seq = payload.get("seq")
|
|
56
|
+
if run_id and isinstance(seq, int):
|
|
57
|
+
return (str(run_id), seq)
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def is_collectable_event(event_type: str, payload: Mapping[str, Any] | None = None) -> bool:
|
|
62
|
+
"""True for mid-run events that the TUI folds after the final reply."""
|
|
63
|
+
if event_type not in COLLECTABLE_EVENT_TYPES:
|
|
64
|
+
return False
|
|
65
|
+
if is_collected(payload):
|
|
66
|
+
return False
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def collected_keys(events: Iterable[tuple[str, Mapping[str, Any]]]) -> set[tuple[str, int]]:
|
|
71
|
+
"""Return every ``(run_id, seq)`` that has been tagged collected."""
|
|
72
|
+
keys: set[tuple[str, int]] = set()
|
|
73
|
+
for event_type, payload in events:
|
|
74
|
+
key = event_key(payload)
|
|
75
|
+
if key is None:
|
|
76
|
+
continue
|
|
77
|
+
if event_type == "collected" or is_collected(payload):
|
|
78
|
+
keys.add(key)
|
|
79
|
+
return keys
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def apply_collection(
|
|
83
|
+
events: Sequence[tuple[str, Mapping[str, Any]]],
|
|
84
|
+
*,
|
|
85
|
+
collected: Iterable[tuple[str, int]] | None = None,
|
|
86
|
+
) -> list[tuple[str, dict[str, Any]]]:
|
|
87
|
+
"""Overlay ``collected: true`` onto events that have been tagged.
|
|
88
|
+
|
|
89
|
+
Overlay records (``event_type == "collected"``) are consumed and not
|
|
90
|
+
returned; the original event keeps its type and gains the flag.
|
|
91
|
+
"""
|
|
92
|
+
tagged = set(collected) if collected is not None else collected_keys(events)
|
|
93
|
+
applied: list[tuple[str, dict[str, Any]]] = []
|
|
94
|
+
for event_type, payload in events:
|
|
95
|
+
if event_type == "collected":
|
|
96
|
+
continue
|
|
97
|
+
outgoing = dict(payload)
|
|
98
|
+
key = event_key(outgoing)
|
|
99
|
+
if key is not None and key in tagged:
|
|
100
|
+
outgoing["collected"] = True
|
|
101
|
+
applied.append((event_type, outgoing))
|
|
102
|
+
return applied
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def collectable_from_events(
|
|
106
|
+
events: Sequence[tuple[str, Mapping[str, Any]]],
|
|
107
|
+
*,
|
|
108
|
+
run_id: str | None = None,
|
|
109
|
+
) -> list[tuple[str, int]]:
|
|
110
|
+
"""Keys of mid-run events that should be collected for a finished run.
|
|
111
|
+
|
|
112
|
+
Collection covers events from ``run_started`` (exclusive) up to the
|
|
113
|
+
terminal run event (exclusive). The user prompt and the final assistant
|
|
114
|
+
output stay uncollected so the TUI can keep them as visible transcript
|
|
115
|
+
rows. When ``run_id`` is omitted, the most recent terminal event's run
|
|
116
|
+
is collected.
|
|
117
|
+
"""
|
|
118
|
+
if run_id is None:
|
|
119
|
+
run_id = _latest_terminal_run_id(events)
|
|
120
|
+
if not run_id:
|
|
121
|
+
return []
|
|
122
|
+
started = False
|
|
123
|
+
keys: list[tuple[str, int]] = []
|
|
124
|
+
seen: set[tuple[str, int]] = set()
|
|
125
|
+
for event_type, payload in events:
|
|
126
|
+
payload_run = str(payload.get("run_id") or "")
|
|
127
|
+
if payload_run != run_id:
|
|
128
|
+
continue
|
|
129
|
+
if event_type == "run_started":
|
|
130
|
+
started = True
|
|
131
|
+
continue
|
|
132
|
+
if not started:
|
|
133
|
+
continue
|
|
134
|
+
if event_type in TERMINAL_EVENT_TYPES:
|
|
135
|
+
break
|
|
136
|
+
if not is_collectable_event(event_type, payload):
|
|
137
|
+
continue
|
|
138
|
+
key = event_key(payload)
|
|
139
|
+
if key is None or key in seen:
|
|
140
|
+
continue
|
|
141
|
+
seen.add(key)
|
|
142
|
+
keys.append(key)
|
|
143
|
+
return keys
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _latest_terminal_run_id(
|
|
147
|
+
events: Sequence[tuple[str, Mapping[str, Any]]],
|
|
148
|
+
) -> str | None:
|
|
149
|
+
for event_type, payload in reversed(list(events)):
|
|
150
|
+
if event_type in TERMINAL_EVENT_TYPES:
|
|
151
|
+
run_id = payload.get("run_id")
|
|
152
|
+
if run_id:
|
|
153
|
+
return str(run_id)
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
__all__ = [
|
|
158
|
+
"COLLECTABLE_EVENT_TYPES",
|
|
159
|
+
"TERMINAL_EVENT_TYPES",
|
|
160
|
+
"apply_collection",
|
|
161
|
+
"collectable_from_events",
|
|
162
|
+
"collected_keys",
|
|
163
|
+
"event_key",
|
|
164
|
+
"is_collectable_event",
|
|
165
|
+
"is_collected",
|
|
166
|
+
]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Fail closed when resuming sessions saved with retired execution modes."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from coding_agent.config import symphony_dir
|
|
8
|
+
from coding_agent.addons.persistence.artifacts import safe_path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def validate_session_workflow(directory: Path | None, session_id: str) -> None:
|
|
12
|
+
"""Check before any model/tool execution, including direct and headless runs.
|
|
13
|
+
|
|
14
|
+
Never rewrite saved state or delete the user's archived plans. The bundle
|
|
15
|
+
takes precedence over the legacy protocol state, as in protocol loading.
|
|
16
|
+
"""
|
|
17
|
+
path = safe_path(directory, "state.json") if directory is not None else None
|
|
18
|
+
if path is None or not path.exists():
|
|
19
|
+
try:
|
|
20
|
+
path = symphony_dir() / "protocols" / f"{UUID(session_id)}.json"
|
|
21
|
+
except ValueError:
|
|
22
|
+
return
|
|
23
|
+
if not path.exists():
|
|
24
|
+
return
|
|
25
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
26
|
+
if not isinstance(data, dict) or data.get("mode", "build") != "build":
|
|
27
|
+
raise ValueError(
|
|
28
|
+
"This session was saved with a removed or unsupported mode. "
|
|
29
|
+
"It cannot be resumed safely; explicitly start a fresh session."
|
|
30
|
+
)
|