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,181 @@
|
|
|
1
|
+
"""One-turn stdio command loop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from typing import Any, Mapping
|
|
12
|
+
|
|
13
|
+
from core_harness import Answer, Interrupt, UserInput
|
|
14
|
+
|
|
15
|
+
from coding_agent.commands.catalog import EFFORTS
|
|
16
|
+
from coding_agent.config import ensure_spawn_settings
|
|
17
|
+
from coding_agent.protocols.stdio import commands as command_module
|
|
18
|
+
from coding_agent.protocols.stdio.commands import load_state
|
|
19
|
+
from coding_agent.protocols.stdio.transport import CAPABILITIES, PROTOCOL_VERSION, StdioSink
|
|
20
|
+
|
|
21
|
+
# Look these up on the package so tests can patch ``stdio.write_frame`` and
|
|
22
|
+
# ``stdio.build_agent`` without reaching into the implementation modules.
|
|
23
|
+
def _package() -> Any:
|
|
24
|
+
from coding_agent.protocols import stdio
|
|
25
|
+
|
|
26
|
+
return stdio
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _request_id(command: Mapping[str, Any], kind: str) -> str | None:
|
|
30
|
+
request_id = command.get("request_id")
|
|
31
|
+
if isinstance(request_id, str) and request_id:
|
|
32
|
+
return request_id
|
|
33
|
+
_package().write_frame({"type": "error", "message": f"{kind} requires request_id"})
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def _run_turn(agent: Any, prompt: str, attachments: Sequence[str], effort: str | None) -> None:
|
|
38
|
+
try:
|
|
39
|
+
if effort is not None:
|
|
40
|
+
if effort not in EFFORTS:
|
|
41
|
+
raise ValueError(f"Unsupported reasoning effort: {effort}")
|
|
42
|
+
agent.harness.reasoning_effort = None if effort == "default" else effort
|
|
43
|
+
stripped = prompt.lstrip()
|
|
44
|
+
parts = stripped[1:].split(None, 1) if stripped.startswith("/") else []
|
|
45
|
+
name = parts[0].lower() if parts else ""
|
|
46
|
+
stdio = _package()
|
|
47
|
+
names = {item["name"] for item in command_module.catalog(extra=agent.user_addons.surface.slash_commands)}
|
|
48
|
+
if name in names:
|
|
49
|
+
if attachments:
|
|
50
|
+
raise ValueError("Slash commands do not accept image attachments")
|
|
51
|
+
result = await command_module.execute(agent, stripped)
|
|
52
|
+
stdio.write_frame({"type": "event", "event": "text_delta", "payload": {"delta": result}})
|
|
53
|
+
else:
|
|
54
|
+
await agent.run(stdio.user_content(prompt, attachments))
|
|
55
|
+
stdio.write_frame({"type": "done", "status": "completed"})
|
|
56
|
+
except asyncio.CancelledError:
|
|
57
|
+
_package().write_frame({"type": "done", "status": "interrupted"})
|
|
58
|
+
except Exception as exc:
|
|
59
|
+
stdio = _package()
|
|
60
|
+
stdio.write_frame({"type": "error", "message": f"{type(exc).__name__}: {exc}"})
|
|
61
|
+
stdio.write_frame({"type": "done", "status": "errored"})
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def serve(
|
|
65
|
+
workspace: Path,
|
|
66
|
+
model: str | None,
|
|
67
|
+
session_id: str | None,
|
|
68
|
+
*,
|
|
69
|
+
unattended: bool = False,
|
|
70
|
+
) -> int:
|
|
71
|
+
stdio = _package()
|
|
72
|
+
stdio.load_provider_env(workspace)
|
|
73
|
+
sink = StdioSink()
|
|
74
|
+
try:
|
|
75
|
+
agent = stdio.build_agent(
|
|
76
|
+
workspace=workspace,
|
|
77
|
+
model_id=model,
|
|
78
|
+
session_id=session_id,
|
|
79
|
+
sink=sink,
|
|
80
|
+
config=ensure_spawn_settings(workspace, overrides={"unattended": True}) if unattended else None,
|
|
81
|
+
client="zeron",
|
|
82
|
+
)
|
|
83
|
+
load_state(agent)
|
|
84
|
+
except Exception as exc:
|
|
85
|
+
_package().write_frame({"type": "error", "message": f"{type(exc).__name__}: {exc}"})
|
|
86
|
+
return 1
|
|
87
|
+
|
|
88
|
+
stdio.write_frame({
|
|
89
|
+
"type": "ready",
|
|
90
|
+
"session_id": agent.session_id,
|
|
91
|
+
"model": getattr(agent.harness, "model_id", model or ""),
|
|
92
|
+
"protocol_version": PROTOCOL_VERSION,
|
|
93
|
+
"capabilities": CAPABILITIES,
|
|
94
|
+
})
|
|
95
|
+
run: asyncio.Task[Any] | None = None
|
|
96
|
+
while True:
|
|
97
|
+
line = await asyncio.to_thread(sys.stdin.readline)
|
|
98
|
+
if not line:
|
|
99
|
+
if run is not None and not run.done():
|
|
100
|
+
run.cancel()
|
|
101
|
+
await asyncio.gather(run, return_exceptions=True)
|
|
102
|
+
return 0
|
|
103
|
+
try:
|
|
104
|
+
command = json.loads(line)
|
|
105
|
+
except json.JSONDecodeError:
|
|
106
|
+
stdio.write_frame({"type": "error", "message": "invalid JSON command"})
|
|
107
|
+
continue
|
|
108
|
+
if not isinstance(command, dict):
|
|
109
|
+
stdio.write_frame({"type": "error", "message": "invalid command: not an object"})
|
|
110
|
+
continue
|
|
111
|
+
kind = command.get("type")
|
|
112
|
+
if kind == "model/list":
|
|
113
|
+
request_id = _request_id(command, kind)
|
|
114
|
+
if request_id is None:
|
|
115
|
+
continue
|
|
116
|
+
try:
|
|
117
|
+
stdio.write_frame(stdio.model_catalog(agent.registry, request_id))
|
|
118
|
+
except Exception as exc:
|
|
119
|
+
stdio.write_frame({
|
|
120
|
+
"type": "error", "request_id": request_id, "message": f"{type(exc).__name__}: {exc}",
|
|
121
|
+
})
|
|
122
|
+
elif kind == "command/list":
|
|
123
|
+
request_id = _request_id(command, kind)
|
|
124
|
+
if request_id is None:
|
|
125
|
+
continue
|
|
126
|
+
try:
|
|
127
|
+
stdio.write_frame({
|
|
128
|
+
"type": "commands",
|
|
129
|
+
"request_id": request_id,
|
|
130
|
+
"commands": command_module.catalog(workspace, agent.user_addons.surface.slash_commands),
|
|
131
|
+
"protocol_version": PROTOCOL_VERSION,
|
|
132
|
+
})
|
|
133
|
+
except Exception as exc:
|
|
134
|
+
stdio.write_frame({
|
|
135
|
+
"type": "error", "request_id": request_id, "message": f"{type(exc).__name__}: {exc}",
|
|
136
|
+
})
|
|
137
|
+
elif kind == "answer":
|
|
138
|
+
agent.harness.submit(Answer(str(command.get("request_id") or ""), str(command.get("value", ""))))
|
|
139
|
+
elif kind == "interrupt":
|
|
140
|
+
# A slash command runs outside the harness; cancel its task instead.
|
|
141
|
+
if run is not None and not run.done() and not agent.harness.submit(Interrupt()):
|
|
142
|
+
run.cancel()
|
|
143
|
+
elif kind == "steer":
|
|
144
|
+
prompt = command.get("prompt")
|
|
145
|
+
if not isinstance(prompt, str) or not prompt.strip():
|
|
146
|
+
stdio.write_frame({"type": "error", "message": "prompt is required"})
|
|
147
|
+
elif not agent.harness.submit(UserInput(prompt)):
|
|
148
|
+
stdio.write_frame({"type": "error", "message": "steer requires an active run"})
|
|
149
|
+
elif kind == "run" and (run is None or run.done()):
|
|
150
|
+
prompt = command.get("prompt")
|
|
151
|
+
if not isinstance(prompt, str) or not prompt.strip():
|
|
152
|
+
stdio.write_frame({"type": "error", "message": "prompt is required"})
|
|
153
|
+
continue
|
|
154
|
+
paths = command.get("attachments") or []
|
|
155
|
+
if not isinstance(paths, list) or not all(isinstance(path, str) for path in paths):
|
|
156
|
+
stdio.write_frame({"type": "error", "message": "attachments must be a list of paths"})
|
|
157
|
+
continue
|
|
158
|
+
effort = command.get("reasoning_effort")
|
|
159
|
+
run = asyncio.create_task(_run_turn(agent, prompt, paths, effort if isinstance(effort, str) else None))
|
|
160
|
+
else:
|
|
161
|
+
stdio.write_frame({"type": "error", "message": f"invalid command: {kind}"})
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def main(argv: Sequence[str]) -> int:
|
|
165
|
+
parser = argparse.ArgumentParser(prog="symphony stdio")
|
|
166
|
+
parser.add_argument("--workspace", default=".")
|
|
167
|
+
parser.add_argument("--model")
|
|
168
|
+
parser.add_argument("--session-id")
|
|
169
|
+
parser.add_argument("--unattended", action="store_true", help="Auto-approve tools subject to Symphony deny rules")
|
|
170
|
+
args = parser.parse_args(argv)
|
|
171
|
+
# Libraries may print diagnostics; stdout belongs exclusively to JSONL.
|
|
172
|
+
sys.stdout = sys.stderr
|
|
173
|
+
return asyncio.run(serve(
|
|
174
|
+
Path(args.workspace).expanduser().resolve(),
|
|
175
|
+
args.model,
|
|
176
|
+
args.session_id,
|
|
177
|
+
unattended=args.unattended,
|
|
178
|
+
))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
__all__ = ["main", "serve"]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""JSONL frames and the event sink that writes them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, Mapping, Sequence
|
|
9
|
+
|
|
10
|
+
from core_ai.content import image_part_from_bytes, sniff_image_media_type
|
|
11
|
+
from core_ai.types import Content
|
|
12
|
+
from core_harness import EventSink, HumanInteraction
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _write(frame: Mapping[str, Any]) -> None:
|
|
16
|
+
"""Write through the package so ``stdio.write_frame`` stays patchable."""
|
|
17
|
+
from coding_agent.protocols import stdio
|
|
18
|
+
|
|
19
|
+
stdio.write_frame(frame)
|
|
20
|
+
|
|
21
|
+
PROTOCOL_VERSION = 2
|
|
22
|
+
CAPABILITIES = ["runs", "resume", "interrupt", "input", "steer", "models", "images", "subagents", "commands"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def write_frame(frame: Mapping[str, Any]) -> None:
|
|
26
|
+
sys.__stdout__.write(json.dumps(frame, default=str, ensure_ascii=False) + "\n")
|
|
27
|
+
sys.__stdout__.flush()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def user_content(prompt: str, attachments: Sequence[str]) -> Content:
|
|
31
|
+
"""Inline staged image bytes using the same canonical parts as the TUI."""
|
|
32
|
+
if not attachments:
|
|
33
|
+
return prompt
|
|
34
|
+
if len(attachments) > 8:
|
|
35
|
+
raise ValueError("at most eight images can be attached")
|
|
36
|
+
parts: list[dict[str, Any]] = [{"type": "text", "text": prompt}]
|
|
37
|
+
for raw in attachments:
|
|
38
|
+
path = Path(raw).expanduser().resolve(strict=True)
|
|
39
|
+
if not path.is_file() or path.stat().st_size > 20 * 1024 * 1024:
|
|
40
|
+
raise ValueError("attachment must be a regular image under 20 MiB")
|
|
41
|
+
payload = path.read_bytes()
|
|
42
|
+
media = sniff_image_media_type(payload, filename=path.name)
|
|
43
|
+
if media is None:
|
|
44
|
+
raise ValueError(f"unsupported image: {path.name}")
|
|
45
|
+
parts.append(image_part_from_bytes(payload, media_type=media, filename=path.name))
|
|
46
|
+
return parts
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class StdioSink(EventSink):
|
|
50
|
+
"""Write each event as a frame.
|
|
51
|
+
|
|
52
|
+
A question is the ``question_asked`` event like in every other host.
|
|
53
|
+
Protocol 2 hosts read ``input_requested``, so that frame follows it.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self) -> None:
|
|
57
|
+
super().__init__()
|
|
58
|
+
self.interaction = HumanInteraction(self)
|
|
59
|
+
|
|
60
|
+
async def deliver(self, event_type: str, payload: Dict[str, Any]) -> None:
|
|
61
|
+
_write({"type": "event", "event": event_type, "payload": payload})
|
|
62
|
+
if event_type == "question_asked":
|
|
63
|
+
known = {"request_id", "question", "choices", "default", "kind"}
|
|
64
|
+
_write({
|
|
65
|
+
"type": "input_requested",
|
|
66
|
+
**{key: payload.get(key) for key in ("request_id", "question", "choices", "default", "kind")},
|
|
67
|
+
"metadata": {key: value for key, value in payload.items() if key not in known},
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
__all__ = ["CAPABILITIES", "PROTOCOL_VERSION", "StdioSink", "user_content", "write_frame"]
|
coding_agent/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""CPU, memory, and disk snapshots for a live agent process.
|
|
2
|
+
|
|
3
|
+
The helpers stay in the standard library so the dashboard works on macOS,
|
|
4
|
+
Linux, and Windows without an extra dependency. Each OS reads the numbers it
|
|
5
|
+
publishes and leaves the rest unknown rather than guessing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class AgentUsage:
|
|
19
|
+
"""One sample of a process. ``None`` means the OS did not report it."""
|
|
20
|
+
|
|
21
|
+
pid: int
|
|
22
|
+
alive: bool
|
|
23
|
+
cpu_percent: float | None = None
|
|
24
|
+
rss_bytes: int | None = None
|
|
25
|
+
disk_bytes: int | None = None
|
|
26
|
+
|
|
27
|
+
def line(self) -> str:
|
|
28
|
+
"""Single-row summary: ``cpu 12% ram 48.0 MB disk 1.2 KB``."""
|
|
29
|
+
return (
|
|
30
|
+
f"cpu {_percent(self.cpu_percent):>4} "
|
|
31
|
+
f"ram {_bytes(self.rss_bytes):>8} "
|
|
32
|
+
f"disk {_bytes(self.disk_bytes):>8}"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _percent(value: float | None) -> str:
|
|
37
|
+
if value is None:
|
|
38
|
+
return "n/a"
|
|
39
|
+
return f"{value:.0f}%"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _bytes(value: int | None) -> str:
|
|
43
|
+
if value is None:
|
|
44
|
+
return "n/a"
|
|
45
|
+
size = float(value)
|
|
46
|
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
47
|
+
if size < 1024 or unit == "TB":
|
|
48
|
+
return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
|
|
49
|
+
size /= 1024
|
|
50
|
+
return f"{size:.1f} TB"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def process_alive(pid: int) -> bool:
|
|
54
|
+
"""True when ``pid`` is still a process. ``pid`` 0 and 1 are never ours."""
|
|
55
|
+
if pid <= 1:
|
|
56
|
+
return False
|
|
57
|
+
if sys.platform == "win32":
|
|
58
|
+
return _windows_alive(pid)
|
|
59
|
+
try:
|
|
60
|
+
os.kill(pid, 0)
|
|
61
|
+
except ProcessLookupError:
|
|
62
|
+
return False
|
|
63
|
+
except PermissionError:
|
|
64
|
+
return True
|
|
65
|
+
except OSError:
|
|
66
|
+
return False
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _windows_alive(pid: int) -> bool:
|
|
71
|
+
import ctypes
|
|
72
|
+
|
|
73
|
+
process = ctypes.windll.kernel32.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
|
|
74
|
+
if not process:
|
|
75
|
+
return False
|
|
76
|
+
ctypes.windll.kernel32.CloseHandle(process)
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def sample_usage(
|
|
81
|
+
pid: int,
|
|
82
|
+
*,
|
|
83
|
+
workspace: str | Path | None = None,
|
|
84
|
+
previous_cpu_seconds: float | None = None,
|
|
85
|
+
previous_at: float | None = None,
|
|
86
|
+
) -> tuple[AgentUsage, float | None, float]:
|
|
87
|
+
"""Sample one process and return ``(usage, cpu_seconds, sampled_at)``.
|
|
88
|
+
|
|
89
|
+
CPU percent needs two samples. The first call returns ``cpu_percent=None``
|
|
90
|
+
and a ``cpu_seconds`` value the caller should pass back next time.
|
|
91
|
+
"""
|
|
92
|
+
sampled_at = time.monotonic()
|
|
93
|
+
if not process_alive(pid):
|
|
94
|
+
return AgentUsage(pid=pid, alive=False), None, sampled_at
|
|
95
|
+
cpu_seconds, rss = _process_counters(pid)
|
|
96
|
+
cpu_percent = _cpu_percent(cpu_seconds, previous_cpu_seconds, sampled_at, previous_at)
|
|
97
|
+
disk = directory_size(workspace) if workspace else None
|
|
98
|
+
return (
|
|
99
|
+
AgentUsage(pid=pid, alive=True, cpu_percent=cpu_percent, rss_bytes=rss, disk_bytes=disk),
|
|
100
|
+
cpu_seconds,
|
|
101
|
+
sampled_at,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _cpu_percent(
|
|
106
|
+
cpu_seconds: float | None,
|
|
107
|
+
previous_cpu_seconds: float | None,
|
|
108
|
+
sampled_at: float,
|
|
109
|
+
previous_at: float | None,
|
|
110
|
+
) -> float | None:
|
|
111
|
+
if (
|
|
112
|
+
cpu_seconds is None
|
|
113
|
+
or previous_cpu_seconds is None
|
|
114
|
+
or previous_at is None
|
|
115
|
+
or sampled_at <= previous_at
|
|
116
|
+
):
|
|
117
|
+
return None
|
|
118
|
+
elapsed = sampled_at - previous_at
|
|
119
|
+
cores = os.cpu_count() or 1
|
|
120
|
+
used = max(0.0, cpu_seconds - previous_cpu_seconds)
|
|
121
|
+
return min(100.0 * cores, (used / elapsed) * 100.0)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def directory_size(path: str | Path | None) -> int | None:
|
|
125
|
+
"""Bytes used by ``path``. A file uses its size; a directory is walked."""
|
|
126
|
+
if path is None:
|
|
127
|
+
return None
|
|
128
|
+
target = Path(path)
|
|
129
|
+
try:
|
|
130
|
+
if target.is_file():
|
|
131
|
+
return target.stat().st_size
|
|
132
|
+
if not target.is_dir():
|
|
133
|
+
return None
|
|
134
|
+
total = 0
|
|
135
|
+
for root, _dirs, files in os.walk(target):
|
|
136
|
+
for name in files:
|
|
137
|
+
try:
|
|
138
|
+
total += (Path(root) / name).stat().st_size
|
|
139
|
+
except OSError:
|
|
140
|
+
continue
|
|
141
|
+
return total
|
|
142
|
+
except OSError:
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _process_counters(pid: int) -> tuple[float | None, int | None]:
|
|
147
|
+
if sys.platform == "linux":
|
|
148
|
+
return _linux_counters(pid)
|
|
149
|
+
if sys.platform == "darwin":
|
|
150
|
+
return _darwin_counters(pid)
|
|
151
|
+
if sys.platform == "win32":
|
|
152
|
+
return _windows_counters(pid)
|
|
153
|
+
return None, None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _linux_counters(pid: int) -> tuple[float | None, int | None]:
|
|
157
|
+
try:
|
|
158
|
+
text = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
|
|
159
|
+
except OSError:
|
|
160
|
+
return None, None
|
|
161
|
+
# comm can contain spaces and parentheses; fields after the last ")".
|
|
162
|
+
tail = text.rsplit(")", 1)[-1].split()
|
|
163
|
+
cpu_seconds = None
|
|
164
|
+
if len(tail) >= 13:
|
|
165
|
+
# utime and stime are fields 14 and 15 of stat, indexes 11 and 12 here.
|
|
166
|
+
ticks = os.sysconf("SC_CLK_TCK") or 100
|
|
167
|
+
cpu_seconds = (int(tail[11]) + int(tail[12])) / ticks
|
|
168
|
+
rss = None
|
|
169
|
+
if len(tail) >= 22:
|
|
170
|
+
rss = int(tail[21]) * (os.sysconf("SC_PAGE_SIZE") or 4096)
|
|
171
|
+
return cpu_seconds, rss
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _darwin_counters(pid: int) -> tuple[float | None, int | None]:
|
|
175
|
+
import subprocess
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
completed = subprocess.run(
|
|
179
|
+
["ps", "-o", "time=,rss=", "-p", str(pid)],
|
|
180
|
+
check=False,
|
|
181
|
+
capture_output=True,
|
|
182
|
+
text=True,
|
|
183
|
+
timeout=2,
|
|
184
|
+
)
|
|
185
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
186
|
+
return None, None
|
|
187
|
+
parts = completed.stdout.split()
|
|
188
|
+
if len(parts) < 2:
|
|
189
|
+
return None, None
|
|
190
|
+
return _parse_ps_time(parts[0]), int(parts[1]) * 1024
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _parse_ps_time(value: str) -> float | None:
|
|
194
|
+
"""Parse ``ps`` TIME, which is ``[[dd-]hh:]mm:ss``."""
|
|
195
|
+
days = 0
|
|
196
|
+
clock = value
|
|
197
|
+
if "-" in value:
|
|
198
|
+
day_text, clock = value.split("-", 1)
|
|
199
|
+
try:
|
|
200
|
+
days = int(day_text)
|
|
201
|
+
except ValueError:
|
|
202
|
+
return None
|
|
203
|
+
pieces = clock.split(":")
|
|
204
|
+
try:
|
|
205
|
+
numbers = [int(piece) for piece in pieces]
|
|
206
|
+
except ValueError:
|
|
207
|
+
return None
|
|
208
|
+
if len(numbers) == 2:
|
|
209
|
+
minutes, seconds = numbers
|
|
210
|
+
hours = 0
|
|
211
|
+
elif len(numbers) == 3:
|
|
212
|
+
hours, minutes, seconds = numbers
|
|
213
|
+
else:
|
|
214
|
+
return None
|
|
215
|
+
return ((days * 24 + hours) * 60 + minutes) * 60 + seconds
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _windows_counters(pid: int) -> tuple[float | None, int | None]:
|
|
219
|
+
import ctypes
|
|
220
|
+
from ctypes import wintypes
|
|
221
|
+
|
|
222
|
+
class ProcessMemoryCounters(ctypes.Structure):
|
|
223
|
+
_fields_ = [
|
|
224
|
+
("cb", wintypes.DWORD),
|
|
225
|
+
("PageFaultCount", wintypes.DWORD),
|
|
226
|
+
("PeakWorkingSetSize", ctypes.c_size_t),
|
|
227
|
+
("WorkingSetSize", ctypes.c_size_t),
|
|
228
|
+
("QuotaPeakPagedPoolUsage", ctypes.c_size_t),
|
|
229
|
+
("QuotaPagedPoolUsage", ctypes.c_size_t),
|
|
230
|
+
("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t),
|
|
231
|
+
("QuotaNonPagedPoolUsage", ctypes.c_size_t),
|
|
232
|
+
("PagefileUsage", ctypes.c_size_t),
|
|
233
|
+
("PeakPagefileUsage", ctypes.c_size_t),
|
|
234
|
+
]
|
|
235
|
+
|
|
236
|
+
kernel = ctypes.windll.kernel32
|
|
237
|
+
process = kernel.OpenProcess(0x1000 | 0x0400, False, pid)
|
|
238
|
+
if not process:
|
|
239
|
+
return None, None
|
|
240
|
+
rss = None
|
|
241
|
+
counters = ProcessMemoryCounters()
|
|
242
|
+
counters.cb = ctypes.sizeof(counters)
|
|
243
|
+
psapi = ctypes.windll.psapi
|
|
244
|
+
if psapi.GetProcessMemoryInfo(process, ctypes.byref(counters), counters.cb):
|
|
245
|
+
rss = int(counters.WorkingSetSize)
|
|
246
|
+
cpu_seconds = None
|
|
247
|
+
creation = wintypes.FILETIME()
|
|
248
|
+
exit_time = wintypes.FILETIME()
|
|
249
|
+
kernel_time = wintypes.FILETIME()
|
|
250
|
+
user_time = wintypes.FILETIME()
|
|
251
|
+
if kernel.GetProcessTimes(
|
|
252
|
+
process,
|
|
253
|
+
ctypes.byref(creation),
|
|
254
|
+
ctypes.byref(exit_time),
|
|
255
|
+
ctypes.byref(kernel_time),
|
|
256
|
+
ctypes.byref(user_time),
|
|
257
|
+
):
|
|
258
|
+
cpu_seconds = (_filetime(kernel_time) + _filetime(user_time)) / 10_000_000
|
|
259
|
+
kernel.CloseHandle(process)
|
|
260
|
+
return cpu_seconds, rss
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _filetime(value: object) -> int:
|
|
264
|
+
return (int(value.dwHighDateTime) << 32) + int(value.dwLowDateTime) # type: ignore[attr-defined]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Headless unattended runs: ``symphony run``, ``--detach``, and ``--resume --continue``."""
|
coding_agent/run/cli.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""``symphony run --unattended [--detach] "<task>"`` and ``symphony --resume --continue``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional, Sequence
|
|
10
|
+
|
|
11
|
+
from core_ai import has_configured_provider
|
|
12
|
+
|
|
13
|
+
from coding_agent.agent import build_agent
|
|
14
|
+
from coding_agent.config import ensure_spawn_settings
|
|
15
|
+
from coding_agent.config.credentials import load_provider_env
|
|
16
|
+
from coding_agent.addons.persistence import JsonlPersistence, register_active, release_active, sessions_dir
|
|
17
|
+
from coding_agent.resources.usage import process_alive
|
|
18
|
+
from coding_agent.run.detach import detach
|
|
19
|
+
from coding_agent.run.interrupted import detached_run_pid, interrupted_session, resume_note, stop_orphaned_jobs
|
|
20
|
+
from coding_agent.run.log_sink import LogLineSink
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
24
|
+
parser = argparse.ArgumentParser(prog="symphony run", description="Run one task headless, with no human.")
|
|
25
|
+
parser.add_argument("task", help="Task prompt")
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--unattended",
|
|
28
|
+
action="store_true",
|
|
29
|
+
required=True,
|
|
30
|
+
help="Required: auto-approve tools (approvals.deny still applies) and auto-answer ask_user",
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument("--detach", action="store_true", help="Run in the background; print pid, log, session id")
|
|
33
|
+
parser.add_argument("--model", default=None, help="provider:model id")
|
|
34
|
+
parser.add_argument("--workspace", default=".", help="Working directory (default: .)")
|
|
35
|
+
parser.add_argument("--session-id", default=None, help="Session id to write (default: new)")
|
|
36
|
+
return parser
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run_task(workspace: Path, task: str, *, model: Optional[str] = None, session_id: Optional[str] = None) -> int:
|
|
40
|
+
"""Headless unattended run with JSONL persistence and the user's ~/.symphony config and rules."""
|
|
41
|
+
load_provider_env(workspace)
|
|
42
|
+
if not has_configured_provider():
|
|
43
|
+
print("error: no provider credentials configured", file=sys.stderr)
|
|
44
|
+
return 1
|
|
45
|
+
agent = build_agent(
|
|
46
|
+
workspace=workspace,
|
|
47
|
+
sink=LogLineSink(),
|
|
48
|
+
model_id=model,
|
|
49
|
+
session_id=session_id,
|
|
50
|
+
config=ensure_spawn_settings(workspace, overrides={"unattended": True}),
|
|
51
|
+
client="run",
|
|
52
|
+
)
|
|
53
|
+
print(f"session {agent.session_id}", flush=True)
|
|
54
|
+
register_active(
|
|
55
|
+
agent.session_id,
|
|
56
|
+
workspace=workspace,
|
|
57
|
+
kind="run",
|
|
58
|
+
model_id=getattr(agent.harness, "model_id", "") or (model or ""),
|
|
59
|
+
label=task,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
async def _run() -> str:
|
|
63
|
+
result = await agent.run(task)
|
|
64
|
+
await agent.wait_for_learning()
|
|
65
|
+
return result.output_text
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
print(asyncio.run(_run()), flush=True)
|
|
69
|
+
except Exception as exc:
|
|
70
|
+
print(f"run failed: {type(exc).__name__}: {exc}", file=sys.stderr, flush=True)
|
|
71
|
+
return 1
|
|
72
|
+
finally:
|
|
73
|
+
release_active()
|
|
74
|
+
return 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def continue_interrupted(workspace: Path, *, model: Optional[str] = None) -> int:
|
|
78
|
+
persistence = JsonlPersistence(sessions_dir(workspace))
|
|
79
|
+
found = asyncio.run(interrupted_session(persistence))
|
|
80
|
+
if found is None:
|
|
81
|
+
print("nothing to continue: the most recent session was not interrupted mid-run")
|
|
82
|
+
return 0
|
|
83
|
+
summary, checkpoint = found
|
|
84
|
+
pid = detached_run_pid(persistence, summary.session_id)
|
|
85
|
+
if pid is not None and process_alive(pid):
|
|
86
|
+
print(f"refusing to continue: detached run pid {pid} is still alive", file=sys.stderr)
|
|
87
|
+
return 1
|
|
88
|
+
stopped = stop_orphaned_jobs(checkpoint.metadata.get("background_jobs"))
|
|
89
|
+
print(f"continuing interrupted session {summary.session_id} unattended", flush=True)
|
|
90
|
+
return run_task(workspace, resume_note(summary, checkpoint, stopped), model=model, session_id=summary.session_id)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def main(argv: Sequence[str]) -> int:
|
|
94
|
+
argv = list(argv)
|
|
95
|
+
args = build_parser().parse_args(argv)
|
|
96
|
+
workspace = Path(args.workspace).expanduser().resolve()
|
|
97
|
+
if args.detach:
|
|
98
|
+
return detach([arg for arg in argv if arg != "--detach"], workspace)
|
|
99
|
+
return run_task(workspace, args.task, model=args.model, session_id=args.session_id)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Start ``symphony run`` in its own session and return at once (no daemon manager)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import uuid
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from coding_agent.addons.persistence import JsonlPersistence, sessions_dir
|
|
13
|
+
from coding_agent.addons.persistence.artifacts import publish
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def detach(run_argv: list[str], workspace: Path) -> int:
|
|
17
|
+
"""Re-exec ``run`` minus ``--detach`` with a pre-generated session id; write ``<sid>/run.json``."""
|
|
18
|
+
session_id = str(uuid.uuid4())
|
|
19
|
+
root = JsonlPersistence(sessions_dir(workspace)).session_dir(session_id)
|
|
20
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
log_path = root / "run.log"
|
|
22
|
+
command = [sys.executable, "-m", "coding_agent.tui", "run", *run_argv, "--session-id", session_id]
|
|
23
|
+
with log_path.open("ab") as log:
|
|
24
|
+
proc = subprocess.Popen(
|
|
25
|
+
command, start_new_session=True, stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT
|
|
26
|
+
)
|
|
27
|
+
info = {
|
|
28
|
+
"pid": proc.pid,
|
|
29
|
+
"session_id": session_id,
|
|
30
|
+
"log": str(log_path),
|
|
31
|
+
"workspace": str(workspace),
|
|
32
|
+
"argv": command,
|
|
33
|
+
"started_at": datetime.now(timezone.utc).isoformat(),
|
|
34
|
+
}
|
|
35
|
+
publish(root / "run.json", (json.dumps(info, indent=2) + "\n").encode("utf-8"))
|
|
36
|
+
print(f"pid {proc.pid}\nlog {log_path}\nsession {session_id}")
|
|
37
|
+
return 0
|