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,261 @@
|
|
|
1
|
+
"""Agent Client Protocol host: one stdio process, any number of sessions.
|
|
2
|
+
|
|
3
|
+
A thin translator. Events become ``session/update`` notifications, a
|
|
4
|
+
question becomes a ``session/request_permission`` request, and the client's
|
|
5
|
+
prompt, cancel, and permission reply become ``CoreHarness.submit`` ops.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import asyncio
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Dict, Mapping
|
|
18
|
+
|
|
19
|
+
from core_harness import (
|
|
20
|
+
Answer,
|
|
21
|
+
EventSink,
|
|
22
|
+
HarnessCancelled,
|
|
23
|
+
HarnessLimitExceeded,
|
|
24
|
+
HumanInteraction,
|
|
25
|
+
Interrupt,
|
|
26
|
+
UserInput,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from coding_agent.config import ensure_spawn_settings
|
|
30
|
+
from coding_agent.protocols.acp.mapping import (
|
|
31
|
+
PROTOCOL_VERSION,
|
|
32
|
+
permission_answer,
|
|
33
|
+
permission_request,
|
|
34
|
+
prompt_text,
|
|
35
|
+
session_updates,
|
|
36
|
+
stop_reason_for_limit,
|
|
37
|
+
)
|
|
38
|
+
from coding_agent.protocols.acp.transport import (
|
|
39
|
+
INTERNAL_ERROR,
|
|
40
|
+
INVALID_PARAMS,
|
|
41
|
+
INVALID_REQUEST,
|
|
42
|
+
METHOD_NOT_FOUND,
|
|
43
|
+
PARSE_ERROR,
|
|
44
|
+
Connection,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _package() -> Any:
|
|
49
|
+
"""Look names up on the package so tests can patch ``acp.build_agent``."""
|
|
50
|
+
from coding_agent.protocols import acp
|
|
51
|
+
|
|
52
|
+
return acp
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class AcpSink(EventSink):
|
|
56
|
+
"""Send each event to the client in ACP terms."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, connection: Connection) -> None:
|
|
59
|
+
super().__init__()
|
|
60
|
+
self.interaction = HumanInteraction(self)
|
|
61
|
+
self.connection = connection
|
|
62
|
+
self.session_id = ""
|
|
63
|
+
self.submit: Any = None
|
|
64
|
+
self._asking: set[asyncio.Task[None]] = set()
|
|
65
|
+
|
|
66
|
+
async def deliver(self, event_type: str, payload: Dict[str, Any]) -> None:
|
|
67
|
+
if event_type == "question_asked":
|
|
68
|
+
task = asyncio.create_task(self._ask(payload))
|
|
69
|
+
self._asking.add(task)
|
|
70
|
+
task.add_done_callback(self._asking.discard)
|
|
71
|
+
return
|
|
72
|
+
for update in session_updates(event_type, payload):
|
|
73
|
+
self.connection.notify("session/update", {"sessionId": self.session_id, "update": update})
|
|
74
|
+
|
|
75
|
+
async def _ask(self, payload: Mapping[str, Any]) -> None:
|
|
76
|
+
"""Put one question to the client and send its answer back as an op."""
|
|
77
|
+
outcome: Any = None
|
|
78
|
+
# ACP permission requests carry options. A free-text question has
|
|
79
|
+
# none to offer, so it takes its default.
|
|
80
|
+
if payload.get("choices"):
|
|
81
|
+
reply = await self.connection.request(
|
|
82
|
+
"session/request_permission", permission_request(self.session_id, payload)
|
|
83
|
+
)
|
|
84
|
+
outcome = reply.get("outcome") if isinstance(reply, Mapping) else None
|
|
85
|
+
if callable(self.submit):
|
|
86
|
+
self.submit(Answer(str(payload.get("request_id") or ""), permission_answer(outcome, payload)))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Session:
|
|
90
|
+
def __init__(self, agent: Any, sink: AcpSink) -> None:
|
|
91
|
+
self.agent = agent
|
|
92
|
+
self.sink = sink
|
|
93
|
+
self.run: asyncio.Task[None] | None = None
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def busy(self) -> bool:
|
|
97
|
+
return self.run is not None and not self.run.done()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class Server:
|
|
101
|
+
def __init__(self, *, model: str | None = None, unattended: bool = False) -> None:
|
|
102
|
+
self.connection = Connection()
|
|
103
|
+
self.sessions: Dict[str, Session] = {}
|
|
104
|
+
self.model = model
|
|
105
|
+
self.unattended = unattended
|
|
106
|
+
|
|
107
|
+
async def handle(self, message: Mapping[str, Any]) -> None:
|
|
108
|
+
"""Dispatch one message from the client."""
|
|
109
|
+
method = message.get("method")
|
|
110
|
+
if method is None:
|
|
111
|
+
self.connection.resolve(message)
|
|
112
|
+
return
|
|
113
|
+
request_id = message.get("id")
|
|
114
|
+
params = message.get("params")
|
|
115
|
+
params = params if isinstance(params, Mapping) else {}
|
|
116
|
+
handler = {
|
|
117
|
+
"initialize": self._initialize,
|
|
118
|
+
"session/new": self._new_session,
|
|
119
|
+
"session/prompt": self._prompt,
|
|
120
|
+
"session/cancel": self._cancel,
|
|
121
|
+
"_symphony/steer": self._steer,
|
|
122
|
+
}.get(str(method))
|
|
123
|
+
if handler is None:
|
|
124
|
+
if request_id is not None:
|
|
125
|
+
self.connection.fail(request_id, METHOD_NOT_FOUND, f"method not found: {method}")
|
|
126
|
+
return
|
|
127
|
+
try:
|
|
128
|
+
result = await handler(params, request_id)
|
|
129
|
+
except ValueError as exc:
|
|
130
|
+
if request_id is not None:
|
|
131
|
+
self.connection.fail(request_id, INVALID_PARAMS, str(exc))
|
|
132
|
+
return
|
|
133
|
+
except Exception as exc:
|
|
134
|
+
if request_id is not None:
|
|
135
|
+
self.connection.fail(request_id, INTERNAL_ERROR, f"{type(exc).__name__}: {exc}")
|
|
136
|
+
return
|
|
137
|
+
if result is not None and request_id is not None:
|
|
138
|
+
self.connection.respond(request_id, result)
|
|
139
|
+
|
|
140
|
+
async def _initialize(self, params: Mapping[str, Any], request_id: Any) -> Dict[str, Any]:
|
|
141
|
+
del params, request_id
|
|
142
|
+
try:
|
|
143
|
+
release = version("symphony-code")
|
|
144
|
+
except PackageNotFoundError:
|
|
145
|
+
release = "unknown"
|
|
146
|
+
return {
|
|
147
|
+
"protocolVersion": PROTOCOL_VERSION,
|
|
148
|
+
"agentCapabilities": {
|
|
149
|
+
"loadSession": False,
|
|
150
|
+
"promptCapabilities": {"image": False, "audio": False, "embeddedContext": True},
|
|
151
|
+
"_meta": {"symphony": {"steer": True}},
|
|
152
|
+
},
|
|
153
|
+
"agentInfo": {"name": "symphony", "title": "Symphony", "version": release},
|
|
154
|
+
"authMethods": [],
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async def _new_session(self, params: Mapping[str, Any], request_id: Any) -> Dict[str, Any]:
|
|
158
|
+
del request_id
|
|
159
|
+
cwd = params.get("cwd")
|
|
160
|
+
if not isinstance(cwd, str) or not Path(cwd).is_absolute():
|
|
161
|
+
raise ValueError("cwd must be an absolute path")
|
|
162
|
+
workspace = Path(cwd)
|
|
163
|
+
acp = _package()
|
|
164
|
+
acp.load_provider_env(workspace)
|
|
165
|
+
sink = AcpSink(self.connection)
|
|
166
|
+
agent = acp.build_agent(
|
|
167
|
+
workspace=workspace,
|
|
168
|
+
model_id=self.model,
|
|
169
|
+
sink=sink,
|
|
170
|
+
config=ensure_spawn_settings(workspace, overrides={"unattended": True}) if self.unattended else None,
|
|
171
|
+
client="acp",
|
|
172
|
+
)
|
|
173
|
+
sink.session_id = str(agent.session_id)
|
|
174
|
+
sink.submit = agent.harness.submit
|
|
175
|
+
self.sessions[sink.session_id] = Session(agent, sink)
|
|
176
|
+
return {"sessionId": sink.session_id}
|
|
177
|
+
|
|
178
|
+
def _session(self, params: Mapping[str, Any]) -> Session:
|
|
179
|
+
session = self.sessions.get(str(params.get("sessionId") or ""))
|
|
180
|
+
if session is None:
|
|
181
|
+
raise ValueError("unknown sessionId")
|
|
182
|
+
return session
|
|
183
|
+
|
|
184
|
+
async def _prompt(self, params: Mapping[str, Any], request_id: Any) -> None:
|
|
185
|
+
session = self._session(params)
|
|
186
|
+
blocks = params.get("prompt")
|
|
187
|
+
text = prompt_text(blocks) if isinstance(blocks, list) else ""
|
|
188
|
+
if not text.strip():
|
|
189
|
+
raise ValueError("prompt has no text")
|
|
190
|
+
if session.busy:
|
|
191
|
+
self.connection.fail(request_id, INVALID_REQUEST, "a prompt is already running in this session")
|
|
192
|
+
return None
|
|
193
|
+
session.run = asyncio.create_task(self._run(session, text, request_id))
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
async def _run(self, session: Session, text: str, request_id: Any) -> None:
|
|
197
|
+
"""Run one prompt turn and answer the request with its stop reason."""
|
|
198
|
+
try:
|
|
199
|
+
await session.agent.run(text)
|
|
200
|
+
stop = "end_turn"
|
|
201
|
+
except (HarnessCancelled, asyncio.CancelledError):
|
|
202
|
+
stop = "cancelled"
|
|
203
|
+
except HarnessLimitExceeded as exc:
|
|
204
|
+
stop = stop_reason_for_limit(exc)
|
|
205
|
+
except Exception as exc:
|
|
206
|
+
self.connection.fail(request_id, INTERNAL_ERROR, f"{type(exc).__name__}: {exc}")
|
|
207
|
+
return
|
|
208
|
+
self.connection.respond(request_id, {"stopReason": stop})
|
|
209
|
+
|
|
210
|
+
async def _cancel(self, params: Mapping[str, Any], request_id: Any) -> None:
|
|
211
|
+
del request_id
|
|
212
|
+
self._session(params).agent.harness.submit(Interrupt("user_cancel"))
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
async def _steer(self, params: Mapping[str, Any], request_id: Any) -> Dict[str, Any]:
|
|
216
|
+
del request_id
|
|
217
|
+
blocks = params.get("prompt")
|
|
218
|
+
text = prompt_text(blocks) if isinstance(blocks, list) else ""
|
|
219
|
+
if not text.strip():
|
|
220
|
+
raise ValueError("prompt has no text")
|
|
221
|
+
return {"accepted": bool(self._session(params).agent.harness.submit(UserInput(text)))}
|
|
222
|
+
|
|
223
|
+
async def shutdown(self) -> None:
|
|
224
|
+
runs = [session.run for session in self.sessions.values() if session.busy]
|
|
225
|
+
for run in runs:
|
|
226
|
+
run.cancel()
|
|
227
|
+
await asyncio.gather(*runs, return_exceptions=True)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
async def serve(*, model: str | None = None, unattended: bool = False) -> int:
|
|
231
|
+
server = Server(model=model, unattended=unattended)
|
|
232
|
+
acp = _package()
|
|
233
|
+
while True:
|
|
234
|
+
line = await asyncio.to_thread(acp.sys.stdin.readline)
|
|
235
|
+
if not line:
|
|
236
|
+
await server.shutdown()
|
|
237
|
+
return 0
|
|
238
|
+
if not line.strip():
|
|
239
|
+
continue
|
|
240
|
+
try:
|
|
241
|
+
message = json.loads(line)
|
|
242
|
+
except json.JSONDecodeError:
|
|
243
|
+
server.connection.fail(None, PARSE_ERROR, "invalid JSON")
|
|
244
|
+
continue
|
|
245
|
+
if not isinstance(message, dict):
|
|
246
|
+
server.connection.fail(None, INVALID_REQUEST, "message must be an object")
|
|
247
|
+
continue
|
|
248
|
+
await server.handle(message)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def main(argv: Sequence[str]) -> int:
|
|
252
|
+
parser = argparse.ArgumentParser(prog="symphony acp")
|
|
253
|
+
parser.add_argument("--model")
|
|
254
|
+
parser.add_argument("--unattended", action="store_true", help="Auto-approve tools subject to Symphony deny rules")
|
|
255
|
+
args = parser.parse_args(argv)
|
|
256
|
+
# Libraries may print diagnostics; stdout belongs exclusively to JSON-RPC.
|
|
257
|
+
sys.stdout = sys.stderr
|
|
258
|
+
return asyncio.run(serve(model=args.model, unattended=args.unattended))
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
__all__ = ["AcpSink", "Server", "main", "serve"]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""JSON-RPC 2.0 over stdio, one message per line."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Any, Dict, Mapping
|
|
9
|
+
|
|
10
|
+
PARSE_ERROR = -32700
|
|
11
|
+
INVALID_REQUEST = -32600
|
|
12
|
+
METHOD_NOT_FOUND = -32601
|
|
13
|
+
INVALID_PARAMS = -32602
|
|
14
|
+
INTERNAL_ERROR = -32603
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def write_message(message: Mapping[str, Any]) -> None:
|
|
18
|
+
sys.__stdout__.write(json.dumps(message, default=str, ensure_ascii=False) + "\n")
|
|
19
|
+
sys.__stdout__.flush()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _write(message: Mapping[str, Any]) -> None:
|
|
23
|
+
"""Write through the package so ``acp.write_message`` stays patchable."""
|
|
24
|
+
from coding_agent.protocols import acp
|
|
25
|
+
|
|
26
|
+
acp.write_message(message)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Connection:
|
|
30
|
+
"""Outbound half of the connection, and the replies to our own requests."""
|
|
31
|
+
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
self._next_id = 0
|
|
34
|
+
self._waiting: Dict[int, asyncio.Future[Any]] = {}
|
|
35
|
+
|
|
36
|
+
def notify(self, method: str, params: Mapping[str, Any]) -> None:
|
|
37
|
+
_write({"jsonrpc": "2.0", "method": method, "params": params})
|
|
38
|
+
|
|
39
|
+
def respond(self, request_id: Any, result: Mapping[str, Any]) -> None:
|
|
40
|
+
_write({"jsonrpc": "2.0", "id": request_id, "result": result})
|
|
41
|
+
|
|
42
|
+
def fail(self, request_id: Any, code: int, message: str) -> None:
|
|
43
|
+
_write({"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}})
|
|
44
|
+
|
|
45
|
+
async def request(self, method: str, params: Mapping[str, Any]) -> Any:
|
|
46
|
+
"""Ask the client and wait for its result. ``None`` if it errored."""
|
|
47
|
+
self._next_id += 1
|
|
48
|
+
request_id = self._next_id
|
|
49
|
+
reply = asyncio.get_running_loop().create_future()
|
|
50
|
+
self._waiting[request_id] = reply
|
|
51
|
+
_write({"jsonrpc": "2.0", "id": request_id, "method": method, "params": params})
|
|
52
|
+
try:
|
|
53
|
+
return await reply
|
|
54
|
+
finally:
|
|
55
|
+
self._waiting.pop(request_id, None)
|
|
56
|
+
|
|
57
|
+
def resolve(self, message: Mapping[str, Any]) -> None:
|
|
58
|
+
"""Deliver the client's reply to one of our requests."""
|
|
59
|
+
reply = self._waiting.get(message.get("id")) # type: ignore[arg-type]
|
|
60
|
+
if reply is not None and not reply.done():
|
|
61
|
+
reply.set_result(message.get("result"))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
__all__ = [
|
|
65
|
+
"Connection",
|
|
66
|
+
"INTERNAL_ERROR",
|
|
67
|
+
"INVALID_PARAMS",
|
|
68
|
+
"INVALID_REQUEST",
|
|
69
|
+
"METHOD_NOT_FOUND",
|
|
70
|
+
"PARSE_ERROR",
|
|
71
|
+
"write_message",
|
|
72
|
+
]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Coding agent host protocols
|
|
2
|
+
|
|
3
|
+
`stdio.py` is the versioned JSONL boundary for desktop and other external
|
|
4
|
+
hosts. The coding agent owns execution and persisted checkpoints. A host owns
|
|
5
|
+
its composer queue and submits a single prompt per active turn.
|
|
6
|
+
|
|
7
|
+
Protocol version 2 advertises `runs`, `resume`, `interrupt`, `input`, `steer`,
|
|
8
|
+
`models`, `images`, `subagents`, and `commands` in its `ready` frame. A host
|
|
9
|
+
sends `run`, `answer`, `interrupt`, or `steer` commands, or `model/list` with a
|
|
10
|
+
`request_id`, and reads `event`, `input_requested`, `error`, `done`, and
|
|
11
|
+
`models` frames. `answer`, `interrupt`, and `steer` are passed to
|
|
12
|
+
`CoreHarness.submit` as `Answer`, `Interrupt`, and `UserInput` operations.
|
|
13
|
+
`{"type":"steer","prompt":"..."}` adds a user message to the active run before
|
|
14
|
+
its next model call and is an error when no run is active.
|
|
15
|
+
|
|
16
|
+
A question arrives as the ordinary `question_asked` event, the same as in
|
|
17
|
+
every other host, followed by the protocol 2 `input_requested` frame with the
|
|
18
|
+
same `request_id`. Reply to either with one `answer` command.
|
|
19
|
+
Unknown control-plane events can be ignored without losing the terminal
|
|
20
|
+
`done` frame. A later version must change the version number when it changes
|
|
21
|
+
required frame semantics.
|
|
22
|
+
|
|
23
|
+
After `ready`, send `{"type":"model/list","request_id":"catalog-1"}` on stdin.
|
|
24
|
+
Symphony responds with a `models` frame carrying the same `request_id`. Each
|
|
25
|
+
row has a qualified
|
|
26
|
+
`id` (`provider:model`), display `label`, `provider`, `description`,
|
|
27
|
+
`context_limit` (tokens, or null), and `reasoning_levels`. The default ID is
|
|
28
|
+
also returned as `default`; hosts should keep the qualified ID when selecting
|
|
29
|
+
a model. The provider is the API route, so gateway models identify the gateway
|
|
30
|
+
even when its upstream model has another vendor.
|
|
31
|
+
|
|
32
|
+
Subagents use the ordinary `event` envelope: the parent emits
|
|
33
|
+
`agent_spawned` with `child_id` and `tool_call_id`; child events carry that
|
|
34
|
+
`child_id` as `agent_id`; the parent emits `agent_completed` or `agent_failed`
|
|
35
|
+
with `child_id` when it settles. Hosts can route child traffic into a nested
|
|
36
|
+
view using the spawning tool call ID.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Line-delimited JSON transport for external agent managers.
|
|
2
|
+
|
|
3
|
+
Only protocol frames go to stdout. Each process handles one turn; later turns
|
|
4
|
+
resume the persisted Symphony session by its id.
|
|
5
|
+
|
|
6
|
+
``transport`` writes frames, ``catalog`` answers model lists, and ``server``
|
|
7
|
+
runs the command loop. The names below stay on this package because hosts and
|
|
8
|
+
tests patch them here.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from coding_agent.agent import build_agent
|
|
14
|
+
from coding_agent.config.credentials import load_provider_env
|
|
15
|
+
from coding_agent.protocols.stdio.catalog import model_catalog
|
|
16
|
+
from coding_agent.protocols.stdio.server import main, serve
|
|
17
|
+
from coding_agent.protocols.stdio.transport import (
|
|
18
|
+
CAPABILITIES,
|
|
19
|
+
PROTOCOL_VERSION,
|
|
20
|
+
StdioSink,
|
|
21
|
+
user_content,
|
|
22
|
+
write_frame,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"CAPABILITIES",
|
|
27
|
+
"PROTOCOL_VERSION",
|
|
28
|
+
"StdioSink",
|
|
29
|
+
"build_agent",
|
|
30
|
+
"load_provider_env",
|
|
31
|
+
"main",
|
|
32
|
+
"model_catalog",
|
|
33
|
+
"serve",
|
|
34
|
+
"sys",
|
|
35
|
+
"user_content",
|
|
36
|
+
"write_frame",
|
|
37
|
+
]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Model catalog frames for the stdio host."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from core_ai.registry import ModelRegistry
|
|
6
|
+
|
|
7
|
+
from coding_agent.protocols.stdio.transport import PROTOCOL_VERSION
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def model_catalog(registry: ModelRegistry, request_id: str) -> dict[str, object]:
|
|
11
|
+
import core_ai
|
|
12
|
+
|
|
13
|
+
default_model_id = core_ai.default_model_id
|
|
14
|
+
|
|
15
|
+
namespaces = registry.namespaces()
|
|
16
|
+
models = [
|
|
17
|
+
{
|
|
18
|
+
"id": info.full_id,
|
|
19
|
+
"label": info.id,
|
|
20
|
+
"provider": info.provider,
|
|
21
|
+
"description": info.provider,
|
|
22
|
+
"context_limit": info.context_limit,
|
|
23
|
+
"reasoning_levels": [level for level, _ in info.thinking_level_map if level != "off"],
|
|
24
|
+
}
|
|
25
|
+
for info in registry.models()
|
|
26
|
+
if info.provider in namespaces
|
|
27
|
+
]
|
|
28
|
+
default = default_model_id(registry)
|
|
29
|
+
selected = next((item for item in models if item["id"] == default), None)
|
|
30
|
+
if selected is None:
|
|
31
|
+
provider, _, label = default.partition(":")
|
|
32
|
+
selected = {
|
|
33
|
+
"id": default,
|
|
34
|
+
"label": label or default,
|
|
35
|
+
"provider": provider or default,
|
|
36
|
+
"description": "Symphony default",
|
|
37
|
+
"context_limit": None,
|
|
38
|
+
"reasoning_levels": [],
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
"type": "models",
|
|
42
|
+
"request_id": request_id,
|
|
43
|
+
"models": [selected, *[item for item in models if item["id"] != default]],
|
|
44
|
+
"default": default,
|
|
45
|
+
"protocol_version": PROTOCOL_VERSION,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
__all__ = ["model_catalog"]
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Slash commands shared by external managers and the Symphony TUI catalog."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Sequence
|
|
9
|
+
|
|
10
|
+
from coding_agent.config import ensure_spawn_settings, symphony_dir
|
|
11
|
+
from coding_agent.commands.jev_argument import parse_jev_argument
|
|
12
|
+
from coding_agent.config.personalities import load_personalities
|
|
13
|
+
from coding_agent.addons.persistence.jsonl import JsonlPersistence
|
|
14
|
+
from coding_agent.addons.persistence.artifacts import publish, safe_path
|
|
15
|
+
from coding_agent.commands.catalog import EFFORTS, SLASH_COMMANDS, EFFORT_CATALOG, SlashCommand
|
|
16
|
+
from coding_agent.addons.manager import addons_report
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def catalog(
|
|
20
|
+
workspace: Path | None = None,
|
|
21
|
+
extra: Sequence[SlashCommand] = (),
|
|
22
|
+
) -> list[dict[str, Any]]:
|
|
23
|
+
"""Built-in commands plus ``extra`` (the live agent's user-addon commands)."""
|
|
24
|
+
choices = {
|
|
25
|
+
"personality": [
|
|
26
|
+
{"value": item.id, "label": item.name, "description": item.description or ""}
|
|
27
|
+
for item in load_personalities()
|
|
28
|
+
],
|
|
29
|
+
"effort": [{"value": item.id, "label": item.label, "description": item.description} for item in EFFORT_CATALOG],
|
|
30
|
+
"jev": [{"value": value, "label": value.title(), "description": description} for value, description in
|
|
31
|
+
[("on", "Enable the finish rule"), ("off", "Disable the finish rule"), ("toggle", "Toggle the finish rule")]],
|
|
32
|
+
}
|
|
33
|
+
descriptions = {
|
|
34
|
+
"provider": "Show provider setup instructions",
|
|
35
|
+
"langfuse": "Show telemetry setup instructions",
|
|
36
|
+
"clear": "Show how to clear the transcript in Zeron",
|
|
37
|
+
"quit": "Show how to exit Zeron",
|
|
38
|
+
"dashboard": "List active Symphony agents",
|
|
39
|
+
"reload": "Reload configuration and environment for the live agent",
|
|
40
|
+
}
|
|
41
|
+
return [
|
|
42
|
+
{"name": item.name, "description": descriptions.get(item.name, item.description),
|
|
43
|
+
"inputHint": item.argument, "options": choices.get(item.name, [])}
|
|
44
|
+
for item in (*SLASH_COMMANDS, *extra)
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _state_path(session_id: str) -> Path:
|
|
49
|
+
# The session id is generated by Symphony, but avoid treating it as a path.
|
|
50
|
+
import uuid
|
|
51
|
+
|
|
52
|
+
return symphony_dir() / "protocols" / f"{uuid.UUID(session_id)}.json"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _agent_state_path(agent: Any) -> Path:
|
|
56
|
+
try:
|
|
57
|
+
persistence = agent.persistence
|
|
58
|
+
except AttributeError:
|
|
59
|
+
return _state_path(agent.session_id)
|
|
60
|
+
if not isinstance(persistence, JsonlPersistence):
|
|
61
|
+
return _state_path(agent.session_id)
|
|
62
|
+
|
|
63
|
+
path = safe_path(persistence.session_dir(agent.session_id), "state.json")
|
|
64
|
+
if not path.exists():
|
|
65
|
+
try:
|
|
66
|
+
legacy = _state_path(agent.session_id)
|
|
67
|
+
except ValueError:
|
|
68
|
+
# JSONL sessions also support non-UUID identifiers.
|
|
69
|
+
return path
|
|
70
|
+
if legacy.is_file():
|
|
71
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
publish(path, legacy.read_bytes())
|
|
73
|
+
legacy.unlink()
|
|
74
|
+
return path
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _reject_legacy_plan_state(data: dict[str, Any]) -> None:
|
|
78
|
+
if data.get("mode", "build") != "build":
|
|
79
|
+
raise ValueError(
|
|
80
|
+
"This session was saved with a removed or unsupported mode. "
|
|
81
|
+
"It cannot be resumed safely; explicitly start a fresh session."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def load_state(agent: Any) -> None:
|
|
86
|
+
try:
|
|
87
|
+
path = _agent_state_path(agent)
|
|
88
|
+
except ValueError:
|
|
89
|
+
return
|
|
90
|
+
if not path.exists():
|
|
91
|
+
return
|
|
92
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
93
|
+
_reject_legacy_plan_state(data)
|
|
94
|
+
effort = data.get("effort")
|
|
95
|
+
if effort in EFFORTS:
|
|
96
|
+
agent.harness.reasoning_effort = None if effort == "default" else effort
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _save_state(agent: Any, **changes: str) -> None:
|
|
100
|
+
path = _agent_state_path(agent)
|
|
101
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
data = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
|
|
103
|
+
_reject_legacy_plan_state(data)
|
|
104
|
+
data.pop("mode", None)
|
|
105
|
+
data.update(changes)
|
|
106
|
+
publish(path, (json.dumps(data, indent=2) + "\n").encode("utf-8"))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _git_diff(workspace: Path) -> str:
|
|
110
|
+
result = subprocess.run(
|
|
111
|
+
["git", "diff", "--stat", "HEAD"], cwd=workspace, capture_output=True,
|
|
112
|
+
text=True, timeout=10, check=False,
|
|
113
|
+
)
|
|
114
|
+
return result.stdout.strip() or result.stderr.strip() or "No changes in tracked files."
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def execute(agent: Any, value: str) -> str:
|
|
118
|
+
"""Execute a command without sending it to a model."""
|
|
119
|
+
parts = value.strip()[1:].split(None, 1)
|
|
120
|
+
command = parts[0].lower() if parts else ""
|
|
121
|
+
argument = parts[1].strip() if len(parts) > 1 else ""
|
|
122
|
+
if command in {"help"}:
|
|
123
|
+
commands = (*SLASH_COMMANDS, *agent.user_addons.surface.slash_commands)
|
|
124
|
+
return "\n".join(f"{item.usage} — {item.description}" for item in commands)
|
|
125
|
+
if command == "status":
|
|
126
|
+
return (
|
|
127
|
+
f"Model: {agent.harness.model_id}\nSession: {agent.session_id}\n"
|
|
128
|
+
f"Personality: {agent.config.personality}\n"
|
|
129
|
+
f"Effort: {agent.harness.reasoning_effort or 'default'}"
|
|
130
|
+
)
|
|
131
|
+
if command == "session":
|
|
132
|
+
from coding_agent.addons.persistence.presentation import session_report
|
|
133
|
+
|
|
134
|
+
return await session_report(agent)
|
|
135
|
+
if command == "context":
|
|
136
|
+
report = await agent.context_report()
|
|
137
|
+
return str(report)
|
|
138
|
+
if command == "compact":
|
|
139
|
+
before, after = await agent.compact_conversation()
|
|
140
|
+
return f"Compacted conversation: {before} → {after} messages."
|
|
141
|
+
if command == "effort":
|
|
142
|
+
if not argument:
|
|
143
|
+
return f"Current effort: {agent.harness.reasoning_effort or 'default'}. Options: {', '.join(EFFORTS)}."
|
|
144
|
+
if argument.lower() not in EFFORTS:
|
|
145
|
+
return f"Unknown effort. Options: {', '.join(EFFORTS)}."
|
|
146
|
+
agent.harness.reasoning_effort = None if argument.lower() == "default" else argument.lower()
|
|
147
|
+
_save_state(agent, effort=argument.lower())
|
|
148
|
+
return f"Reasoning effort: {argument.lower()}."
|
|
149
|
+
if command == "personality":
|
|
150
|
+
choices = {item.id: item for item in load_personalities()}
|
|
151
|
+
if not argument:
|
|
152
|
+
return f"Current: {agent.config.personality}. Options: {', '.join(choices)}."
|
|
153
|
+
if argument not in choices:
|
|
154
|
+
return f"Unknown personality: {argument}. Options: {', '.join(choices)}."
|
|
155
|
+
agent.config = ensure_spawn_settings(agent.workspace, overrides={"personality": argument})
|
|
156
|
+
agent.apply_system_prompt()
|
|
157
|
+
return f"Personality: {choices[argument].name}."
|
|
158
|
+
if command == "jev":
|
|
159
|
+
change = parse_jev_argument(agent.config.evaluation.enabled, argument)
|
|
160
|
+
enabled = change.enabled
|
|
161
|
+
settings: dict[str, Any] = {"enabled": enabled}
|
|
162
|
+
if change.rule is not None:
|
|
163
|
+
settings["rule"] = change.rule
|
|
164
|
+
agent.config = ensure_spawn_settings(agent.workspace, overrides={"evaluation": settings})
|
|
165
|
+
agent.apply_system_prompt()
|
|
166
|
+
return f"Jev {'enabled' if enabled else 'disabled'} for the next turn."
|
|
167
|
+
if command == "model":
|
|
168
|
+
models = [item.full_id for item in agent.registry.models() if item.provider in agent.registry.namespaces()]
|
|
169
|
+
if argument:
|
|
170
|
+
return "Select the model in Zeron's model picker; it controls the model for this chat."
|
|
171
|
+
return f"Current model: {agent.harness.model_id}\nAvailable: {', '.join(models)}"
|
|
172
|
+
if command == "learning":
|
|
173
|
+
try:
|
|
174
|
+
store = agent.learning_store
|
|
175
|
+
except AttributeError:
|
|
176
|
+
store = None
|
|
177
|
+
if store is None:
|
|
178
|
+
from coding_agent.addons.learning.store import LearningStore
|
|
179
|
+
|
|
180
|
+
# Lightweight protocol hosts/tests may not expose the agent's store.
|
|
181
|
+
store = LearningStore(agent.workspace)
|
|
182
|
+
return store.to_markdown()
|
|
183
|
+
if command == "installed":
|
|
184
|
+
skills = [item.name for item in agent.skill_registry.skills]
|
|
185
|
+
plugins = [str(getattr(item, "name", item)) for item in agent.loaded_plugins]
|
|
186
|
+
return f"Skills: {', '.join(skills) or 'none'}\nPlugins: {', '.join(plugins) or 'none'}"
|
|
187
|
+
if command == "diff":
|
|
188
|
+
return _git_diff(agent.workspace)
|
|
189
|
+
if command == "dashboard":
|
|
190
|
+
root = symphony_dir() / "active"
|
|
191
|
+
entries = sorted(root.iterdir()) if root.exists() else []
|
|
192
|
+
return "\n".join(path.name for path in entries) or "No active agents."
|
|
193
|
+
if command == "reload":
|
|
194
|
+
from coding_agent.config.credentials import load_provider_env
|
|
195
|
+
|
|
196
|
+
load_provider_env(agent.workspace, reload=True)
|
|
197
|
+
agent.config = ensure_spawn_settings(agent.workspace)
|
|
198
|
+
agent.apply_system_prompt()
|
|
199
|
+
addons = agent.reload_user_addons()
|
|
200
|
+
return f"Reloaded Symphony configuration and environment for this session.\nAddons: {addons}"
|
|
201
|
+
if command == "addons":
|
|
202
|
+
return addons_report(agent.user_addons.surface, agent.user_addons.hook_failures)
|
|
203
|
+
if command == "provider":
|
|
204
|
+
return "Configure a provider with its API key in Symphony's environment, then use /model."
|
|
205
|
+
if command == "langfuse":
|
|
206
|
+
return "Configure Langfuse credentials in Symphony's .env, then restart the session to initialize telemetry."
|
|
207
|
+
if command == "new":
|
|
208
|
+
return "Use Zeron's /zeron:new to open a fresh conversation."
|
|
209
|
+
if command == "clear":
|
|
210
|
+
return "Use Zeron's conversation controls to clear the visible transcript."
|
|
211
|
+
if command == "quit":
|
|
212
|
+
return "Close the Zeron window to exit."
|
|
213
|
+
if command == "voice":
|
|
214
|
+
return "Voice mode listens in the Symphony TUI. Press ctrl+a or type /voice there."
|
|
215
|
+
reply = await agent.user_addons.run_command(command, argument)
|
|
216
|
+
if reply is not None:
|
|
217
|
+
return reply
|
|
218
|
+
raise ValueError(f"Unknown command: /{command}. Type /help to see commands.")
|