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.
Files changed (169) hide show
  1. coding_agent/__init__.py +20 -0
  2. coding_agent/_lazy.py +17 -0
  3. coding_agent/addons/__init__.py +35 -0
  4. coding_agent/addons/approvals.py +224 -0
  5. coding_agent/addons/compaction/__init__.py +36 -0
  6. coding_agent/addons/compaction/addon.py +88 -0
  7. coding_agent/addons/compaction/compactor.py +138 -0
  8. coding_agent/addons/compaction/prompts.py +20 -0
  9. coding_agent/addons/compaction/transcript.py +118 -0
  10. coding_agent/addons/evaluation/__init__.py +68 -0
  11. coding_agent/addons/evaluation/addon.py +199 -0
  12. coding_agent/addons/evaluation/evaluators.py +245 -0
  13. coding_agent/addons/evaluation/handlers.py +66 -0
  14. coding_agent/addons/evaluation/policy.py +277 -0
  15. coding_agent/addons/evaluation/protocol.py +192 -0
  16. coding_agent/addons/evaluation/state.py +284 -0
  17. coding_agent/addons/langfuse/__init__.py +24 -0
  18. coding_agent/addons/langfuse/addon.py +465 -0
  19. coding_agent/addons/langfuse/install.py +79 -0
  20. coding_agent/addons/langfuse/serialize.py +158 -0
  21. coding_agent/addons/learning/__init__.py +33 -0
  22. coding_agent/addons/learning/addon.py +181 -0
  23. coding_agent/addons/learning/loop.py +247 -0
  24. coding_agent/addons/learning/prompts.py +26 -0
  25. coding_agent/addons/learning/sanitize.py +64 -0
  26. coding_agent/addons/learning/store.py +522 -0
  27. coding_agent/addons/manager/__init__.py +18 -0
  28. coding_agent/addons/manager/bridge.py +74 -0
  29. coding_agent/addons/manager/discovery.py +34 -0
  30. coding_agent/addons/manager/report.py +33 -0
  31. coding_agent/addons/manager/runtime.py +76 -0
  32. coding_agent/addons/manager/tool_changes.py +16 -0
  33. coding_agent/addons/persistence/__init__.py +41 -0
  34. coding_agent/addons/persistence/active.py +132 -0
  35. coding_agent/addons/persistence/artifacts.py +110 -0
  36. coding_agent/addons/persistence/collection.py +166 -0
  37. coding_agent/addons/persistence/compatibility.py +30 -0
  38. coding_agent/addons/persistence/images.py +133 -0
  39. coding_agent/addons/persistence/jsonl.py +1195 -0
  40. coding_agent/addons/persistence/presentation.py +104 -0
  41. coding_agent/addons/plugins/__init__.py +19 -0
  42. coding_agent/addons/plugins/install.py +37 -0
  43. coding_agent/addons/plugins/manager.py +140 -0
  44. coding_agent/addons/plugins/models.py +50 -0
  45. coding_agent/addons/skills/__init__.py +28 -0
  46. coding_agent/addons/skills/addon.py +26 -0
  47. coding_agent/addons/skills/args.py +78 -0
  48. coding_agent/addons/skills/bundled_skills/addon-authoring/SKILL.md +77 -0
  49. coding_agent/addons/skills/bundled_skills/context-budget/SKILL.md +12 -0
  50. coding_agent/addons/skills/bundled_skills/orient-first/SKILL.md +16 -0
  51. coding_agent/addons/skills/bundled_skills/security-boundary/SKILL.md +16 -0
  52. coding_agent/addons/skills/bundled_skills/skill-discipline/SKILL.md +13 -0
  53. coding_agent/addons/skills/bundled_skills/surgical-edit/SKILL.md +11 -0
  54. coding_agent/addons/skills/bundled_skills/systematic-debug/SKILL.md +18 -0
  55. coding_agent/addons/skills/bundled_skills/verify-before-done/SKILL.md +11 -0
  56. coding_agent/addons/skills/frontmatter.py +57 -0
  57. coding_agent/addons/skills/models.py +57 -0
  58. coding_agent/addons/skills/registry.py +109 -0
  59. coding_agent/addons/skills/tool.py +54 -0
  60. coding_agent/addons/voice/__init__.py +5 -0
  61. coding_agent/addons/voice/addon.py +106 -0
  62. coding_agent/addons/voice/credentials.py +42 -0
  63. coding_agent/addons/voice/playback.py +42 -0
  64. coding_agent/addons/voice/speak.py +287 -0
  65. coding_agent/addons/voice/stream.py +651 -0
  66. coding_agent/addons/voice/summary.py +126 -0
  67. coding_agent/agent.py +523 -0
  68. coding_agent/bench/__init__.py +1 -0
  69. coding_agent/bench/cli.py +200 -0
  70. coding_agent/bench/patch.py +78 -0
  71. coding_agent/commands/__init__.py +5 -0
  72. coding_agent/commands/catalog.py +179 -0
  73. coding_agent/commands/jev_argument.py +40 -0
  74. coding_agent/config/__init__.py +51 -0
  75. coding_agent/config/config.py +376 -0
  76. coding_agent/config/credentials.py +153 -0
  77. coding_agent/config/personalities.json +34 -0
  78. coding_agent/config/personalities.py +170 -0
  79. coding_agent/config/prompts.py +67 -0
  80. coding_agent/protocols/__init__.py +5 -0
  81. coding_agent/protocols/acp/README.md +36 -0
  82. coding_agent/protocols/acp/__init__.py +27 -0
  83. coding_agent/protocols/acp/mapping.py +164 -0
  84. coding_agent/protocols/acp/server.py +261 -0
  85. coding_agent/protocols/acp/transport.py +72 -0
  86. coding_agent/protocols/stdio/README.md +36 -0
  87. coding_agent/protocols/stdio/__init__.py +37 -0
  88. coding_agent/protocols/stdio/catalog.py +49 -0
  89. coding_agent/protocols/stdio/commands.py +218 -0
  90. coding_agent/protocols/stdio/server.py +181 -0
  91. coding_agent/protocols/stdio/transport.py +71 -0
  92. coding_agent/py.typed +0 -0
  93. coding_agent/resources/__init__.py +5 -0
  94. coding_agent/resources/usage.py +264 -0
  95. coding_agent/run/__init__.py +1 -0
  96. coding_agent/run/cli.py +99 -0
  97. coding_agent/run/detach.py +37 -0
  98. coding_agent/run/interrupted.py +67 -0
  99. coding_agent/run/log_sink.py +46 -0
  100. coding_agent/tools/__init__.py +73 -0
  101. coding_agent/tools/ask_user.py +61 -0
  102. coding_agent/tools/base.py +136 -0
  103. coding_agent/tools/bash.py +288 -0
  104. coding_agent/tools/bash_jobs.py +116 -0
  105. coding_agent/tools/generate_image.py +123 -0
  106. coding_agent/tools/memory.py +100 -0
  107. coding_agent/tools/patch.py +218 -0
  108. coding_agent/tools/read_file.py +167 -0
  109. coding_agent/tools/search.py +433 -0
  110. coding_agent/tools/web_search.py +151 -0
  111. coding_agent/tools/write_file.py +40 -0
  112. coding_agent/tui/EFFICIENCY_PLAN.md +25 -0
  113. coding_agent/tui/__init__.py +22 -0
  114. coding_agent/tui/__main__.py +122 -0
  115. coding_agent/tui/app.py +456 -0
  116. coding_agent/tui/chrome/__init__.py +33 -0
  117. coding_agent/tui/chrome/footer.py +274 -0
  118. coding_agent/tui/chrome/topbar.py +259 -0
  119. coding_agent/tui/commands/__init__.py +45 -0
  120. coding_agent/tui/commands/catalog.py +35 -0
  121. coding_agent/tui/commands/manager.py +557 -0
  122. coding_agent/tui/composer/__init__.py +18 -0
  123. coding_agent/tui/composer/input.py +219 -0
  124. coding_agent/tui/composer/slash_menu.py +341 -0
  125. coding_agent/tui/composer/surface.py +330 -0
  126. coding_agent/tui/composer/voice.py +267 -0
  127. coding_agent/tui/composer/voice_mode.py +491 -0
  128. coding_agent/tui/runtime/__init__.py +23 -0
  129. coding_agent/tui/runtime/events.py +728 -0
  130. coding_agent/tui/runtime/sink.py +115 -0
  131. coding_agent/tui/runtime/state.py +145 -0
  132. coding_agent/tui/runtime/subagent.py +531 -0
  133. coding_agent/tui/runtime/turn.py +206 -0
  134. coding_agent/tui/screens/__init__.py +54 -0
  135. coding_agent/tui/screens/ask.py +69 -0
  136. coding_agent/tui/screens/context.py +195 -0
  137. coding_agent/tui/screens/dashboard.py +91 -0
  138. coding_agent/tui/screens/diff.py +302 -0
  139. coding_agent/tui/screens/extensions.py +68 -0
  140. coding_agent/tui/screens/file_selector.py +167 -0
  141. coding_agent/tui/screens/history.py +458 -0
  142. coding_agent/tui/screens/langfuse.py +142 -0
  143. coding_agent/tui/screens/learning.py +113 -0
  144. coding_agent/tui/screens/modal.py +131 -0
  145. coding_agent/tui/screens/onboard.py +455 -0
  146. coding_agent/tui/screens/resume.py +163 -0
  147. coding_agent/tui/theme/__init__.py +49 -0
  148. coding_agent/tui/theme/load.py +325 -0
  149. coding_agent/tui/theme/style.py +95 -0
  150. coding_agent/tui/theme/theme.toml +1687 -0
  151. coding_agent/tui/tools/__init__.py +53 -0
  152. coding_agent/tui/tools/activity.py +184 -0
  153. coding_agent/tui/tools/calls.py +560 -0
  154. coding_agent/tui/tools/code_view.py +52 -0
  155. coding_agent/tui/tools/images.py +250 -0
  156. coding_agent/tui/tools/labels.py +135 -0
  157. coding_agent/tui/tools/modal.py +137 -0
  158. coding_agent/tui/tools/partial_args.py +19 -0
  159. coding_agent/tui/tools/snapshots.py +382 -0
  160. coding_agent/tui/transcript/__init__.py +36 -0
  161. coding_agent/tui/transcript/messages.py +408 -0
  162. coding_agent/tui/transcript/process.py +504 -0
  163. coding_agent/tui/transcript/surface.py +345 -0
  164. coding_agent/tui/transcript/thinking_markdown.py +314 -0
  165. symphony_code-0.0.1.dist-info/METADATA +66 -0
  166. symphony_code-0.0.1.dist-info/RECORD +169 -0
  167. symphony_code-0.0.1.dist-info/WHEEL +4 -0
  168. symphony_code-0.0.1.dist-info/entry_points.txt +5 -0
  169. 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.")