rlmness-cli 0.2.0__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 (203) hide show
  1. rlmagent/__init__.py +1 -0
  2. rlmagent/__main__.py +8 -0
  3. rlmagent/acp/__init__.py +1 -0
  4. rlmagent/acp/runtime.py +71 -0
  5. rlmagent/acp/server.py +598 -0
  6. rlmagent/acp/updates.py +128 -0
  7. rlmagent/agentnet.py +237 -0
  8. rlmagent/agents/__init__.py +1 -0
  9. rlmagent/agents/approval.py +90 -0
  10. rlmagent/agents/bridge.py +109 -0
  11. rlmagent/agents/budget.py +212 -0
  12. rlmagent/agents/child_handles.py +46 -0
  13. rlmagent/agents/child_mailbox.py +30 -0
  14. rlmagent/agents/kernel_api.py +156 -0
  15. rlmagent/agents/output.py +34 -0
  16. rlmagent/agents/patch.py +107 -0
  17. rlmagent/agents/prompts.py +156 -0
  18. rlmagent/agents/trace.py +290 -0
  19. rlmagent/agents/tree.py +682 -0
  20. rlmagent/agents/wasm_api.py +105 -0
  21. rlmagent/agents/workspace.py +357 -0
  22. rlmagent/autopilot.py +447 -0
  23. rlmagent/backend.py +946 -0
  24. rlmagent/backend_context.py +74 -0
  25. rlmagent/cli/__init__.py +5 -0
  26. rlmagent/cli/config_cmd.py +177 -0
  27. rlmagent/cli/health.py +156 -0
  28. rlmagent/cli/main.py +1194 -0
  29. rlmagent/cli/update.py +188 -0
  30. rlmagent/clipboard.py +63 -0
  31. rlmagent/config/__init__.py +37 -0
  32. rlmagent/config/config.py +297 -0
  33. rlmagent/config/loader.py +119 -0
  34. rlmagent/config/prompts.py +124 -0
  35. rlmagent/config/secure_input.py +213 -0
  36. rlmagent/config/store.py +275 -0
  37. rlmagent/context/__init__.py +47 -0
  38. rlmagent/context/budget.py +557 -0
  39. rlmagent/contract.py +311 -0
  40. rlmagent/contract_run.py +232 -0
  41. rlmagent/conversation.py +2547 -0
  42. rlmagent/cron.py +77 -0
  43. rlmagent/directives.py +893 -0
  44. rlmagent/discovery.py +356 -0
  45. rlmagent/execution_log.py +66 -0
  46. rlmagent/factory.py +189 -0
  47. rlmagent/factory_graph.py +413 -0
  48. rlmagent/harness/__init__.py +63 -0
  49. rlmagent/harness/contracts/__init__.py +1 -0
  50. rlmagent/harness/contracts/stream.py +129 -0
  51. rlmagent/harness/contracts/tooling.py +127 -0
  52. rlmagent/harness/contracts/transcript/__init__.py +68 -0
  53. rlmagent/harness/contracts/transcript/_base.py +29 -0
  54. rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
  55. rlmagent/harness/contracts/transcript/entries.py +180 -0
  56. rlmagent/harness/contracts/transcript/segments.py +64 -0
  57. rlmagent/harness/contracts/values.py +6 -0
  58. rlmagent/harness/driver.py +397 -0
  59. rlmagent/harness/engine.py +489 -0
  60. rlmagent/harness/mending.py +153 -0
  61. rlmagent/harness/provider/__init__.py +1 -0
  62. rlmagent/harness/provider/base.py +27 -0
  63. rlmagent/harness/provider/wire.py +189 -0
  64. rlmagent/harness/py.typed +0 -0
  65. rlmagent/harness/session/__init__.py +5 -0
  66. rlmagent/harness/session/index.py +218 -0
  67. rlmagent/harness/session/lease.py +66 -0
  68. rlmagent/harness/session/records.py +118 -0
  69. rlmagent/harness/session/replay.py +165 -0
  70. rlmagent/harness/session/store.py +155 -0
  71. rlmagent/harness/session/summary.py +46 -0
  72. rlmagent/hints.py +47 -0
  73. rlmagent/history.py +92 -0
  74. rlmagent/hooks/__init__.py +1 -0
  75. rlmagent/hooks/hooks.py +1 -0
  76. rlmagent/instructions.py +157 -0
  77. rlmagent/job_runner.py +117 -0
  78. rlmagent/jobs.py +365 -0
  79. rlmagent/kept_agents.py +397 -0
  80. rlmagent/kernel/__init__.py +5 -0
  81. rlmagent/kernel/session.py +205 -0
  82. rlmagent/kernel/wasm.py +169 -0
  83. rlmagent/logs.py +208 -0
  84. rlmagent/managed_release.py +368 -0
  85. rlmagent/mcp.py +411 -0
  86. rlmagent/mcp_oauth.py +213 -0
  87. rlmagent/model/__init__.py +29 -0
  88. rlmagent/model/_claude/__init__.py +1 -0
  89. rlmagent/model/_claude/courier.py +117 -0
  90. rlmagent/model/_claude/emitter.py +378 -0
  91. rlmagent/model/_oai/__init__.py +1 -0
  92. rlmagent/model/_oai/helpers.py +256 -0
  93. rlmagent/model/_oai/normalize.py +263 -0
  94. rlmagent/model/_oai/parsers.py +317 -0
  95. rlmagent/model/_oai/payloads.py +226 -0
  96. rlmagent/model/_oai/transport.py +127 -0
  97. rlmagent/model/claude.py +552 -0
  98. rlmagent/model/copilot.py +142 -0
  99. rlmagent/model/correlation.py +21 -0
  100. rlmagent/model/deadline.py +19 -0
  101. rlmagent/model/discovery.py +181 -0
  102. rlmagent/model/external/__init__.py +11 -0
  103. rlmagent/model/external/acp.py +202 -0
  104. rlmagent/model/external/acp_install.py +109 -0
  105. rlmagent/model/external/cli.py +248 -0
  106. rlmagent/model/external/textcalls.py +57 -0
  107. rlmagent/model/failover.py +129 -0
  108. rlmagent/model/google.py +199 -0
  109. rlmagent/model/limits.py +46 -0
  110. rlmagent/model/oai_compatible.py +414 -0
  111. rlmagent/model/preferences.py +56 -0
  112. rlmagent/model/py.typed +0 -0
  113. rlmagent/model/registered_oauth.py +195 -0
  114. rlmagent/model/scripted.py +55 -0
  115. rlmagent/model/settings.py +271 -0
  116. rlmagent/model/subscription.py +398 -0
  117. rlmagent/model/token_bounds.py +15 -0
  118. rlmagent/model/transport/__init__.py +1 -0
  119. rlmagent/model/transport/backoff.py +87 -0
  120. rlmagent/model/transport/client.py +87 -0
  121. rlmagent/model/transport/faults.py +65 -0
  122. rlmagent/model/transport/responses_socket.py +223 -0
  123. rlmagent/model/vertex.py +93 -0
  124. rlmagent/network.py +123 -0
  125. rlmagent/packages.py +246 -0
  126. rlmagent/planning.py +84 -0
  127. rlmagent/playbook/__init__.py +1 -0
  128. rlmagent/playbook/apply.py +106 -0
  129. rlmagent/playbook/digest.py +63 -0
  130. rlmagent/playbook/improver.py +272 -0
  131. rlmagent/playbook/plan.py +99 -0
  132. rlmagent/playbook/store.py +67 -0
  133. rlmagent/plugins.py +327 -0
  134. rlmagent/policy.py +105 -0
  135. rlmagent/prompt_refs.py +57 -0
  136. rlmagent/prompts.py +331 -0
  137. rlmagent/prune.py +60 -0
  138. rlmagent/py.typed +0 -0
  139. rlmagent/reasoning.py +135 -0
  140. rlmagent/refinement/__init__.py +1 -0
  141. rlmagent/refinement/controller.py +448 -0
  142. rlmagent/refinement/engine.py +220 -0
  143. rlmagent/refinement/evidence.py +131 -0
  144. rlmagent/refinement/scoped.py +90 -0
  145. rlmagent/refinement/store.py +193 -0
  146. rlmagent/refresh.py +202 -0
  147. rlmagent/routing.py +52 -0
  148. rlmagent/runtime.py +575 -0
  149. rlmagent/safety.py +267 -0
  150. rlmagent/session_html.py +57 -0
  151. rlmagent/session_import.py +52 -0
  152. rlmagent/session_share.py +55 -0
  153. rlmagent/sessions.py +249 -0
  154. rlmagent/shell_sandbox.py +88 -0
  155. rlmagent/skillset.py +320 -0
  156. rlmagent/steering.py +148 -0
  157. rlmagent/stops.py +62 -0
  158. rlmagent/subscriptions.py +219 -0
  159. rlmagent/tasks.py +98 -0
  160. rlmagent/toolhost.py +310 -0
  161. rlmagent/tools/__init__.py +56 -0
  162. rlmagent/tools/_shared.py +210 -0
  163. rlmagent/tools/files.py +462 -0
  164. rlmagent/tools/kernel.py +93 -0
  165. rlmagent/tools/patch_tool.py +68 -0
  166. rlmagent/tools/search.py +188 -0
  167. rlmagent/trust.py +383 -0
  168. rlmagent/tui/__init__.py +1 -0
  169. rlmagent/tui/adapter.py +506 -0
  170. rlmagent/tui/app.py +711 -0
  171. rlmagent/tui/approval_screen.py +57 -0
  172. rlmagent/tui/choice_screen.py +47 -0
  173. rlmagent/tui/completions.py +116 -0
  174. rlmagent/tui/components.py +25 -0
  175. rlmagent/tui/composer.py +40 -0
  176. rlmagent/tui/factory_view.py +86 -0
  177. rlmagent/tui/inspect.py +193 -0
  178. rlmagent/tui/messages.py +215 -0
  179. rlmagent/tui/model_screen.py +307 -0
  180. rlmagent/tui/prompt.py +98 -0
  181. rlmagent/tui/question_screen.py +52 -0
  182. rlmagent/tui/replay.py +141 -0
  183. rlmagent/tui/rlm_agent.tcss +405 -0
  184. rlmagent/tui/search_box.py +91 -0
  185. rlmagent/tui/sidebar.py +182 -0
  186. rlmagent/tui/status.py +49 -0
  187. rlmagent/tui/steps.py +283 -0
  188. rlmagent/tui/suggest.py +778 -0
  189. rlmagent/tui/transcript.py +172 -0
  190. rlmagent/tui/trust_dialog.py +58 -0
  191. rlmagent/tui/worker_attach.py +141 -0
  192. rlmagent/ui/__init__.py +1 -0
  193. rlmagent/ui/render.py +211 -0
  194. rlmagent/undo.py +109 -0
  195. rlmagent/utils/__init__.py +1 -0
  196. rlmagent/websearch.py +75 -0
  197. rlmagent/worker.py +1275 -0
  198. rlmagent/worker_service.py +203 -0
  199. rlmness_cli-0.2.0.dist-info/METADATA +882 -0
  200. rlmness_cli-0.2.0.dist-info/RECORD +203 -0
  201. rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
  202. rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
  203. rlmness_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
rlmagent/acp/server.py ADDED
@@ -0,0 +1,598 @@
1
+ """The ACP methods over JSON-RPC on stdio, one CodingSession per ACP session."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import json
8
+ import os
9
+ import sys
10
+ import uuid
11
+ from collections.abc import Awaitable, Callable
12
+
13
+ from rlmagent.acp import updates
14
+ from rlmagent.agents.approval import ALWAYS, DENY, ONCE
15
+
16
+ PROTOCOL_VERSION = 1
17
+ OPTIONS = [
18
+ {"optionId": ONCE, "name": "Allow once", "kind": "allow_once"},
19
+ {"optionId": ALWAYS, "name": "Always this session", "kind": "allow_always"},
20
+ {"optionId": DENY, "name": "Deny", "kind": "reject_once"},
21
+ ]
22
+ MODES = [
23
+ {"id": "default", "name": "Default", "description": "Reads, writes and runs (writes and commands ask first)"},
24
+ {"id": "plan", "name": "Plan", "description": "Reads and plans; changes nothing"},
25
+ ]
26
+
27
+ ReadLine = Callable[[], Awaitable[bytes]]
28
+ Write = Callable[[bytes], None]
29
+
30
+
31
+ def _ids_file():
32
+ from rlmagent.plugins import rlm_agent_home
33
+
34
+ return rlm_agent_home() / "acp_sessions.json"
35
+
36
+
37
+ def _recall(sid: str) -> str:
38
+ """Our saved-session id for an editor's session id (they differ for sessions started here)."""
39
+ with contextlib.suppress(OSError, ValueError):
40
+ return json.loads(_ids_file().read_text(encoding="utf-8")).get(sid, sid)
41
+ return sid
42
+
43
+
44
+ def _remember(sid: str, session_id: str) -> None:
45
+ path = _ids_file()
46
+ known: dict = {}
47
+ with contextlib.suppress(OSError, ValueError):
48
+ known = json.loads(path.read_text(encoding="utf-8"))
49
+ known[sid] = session_id
50
+ with contextlib.suppress(OSError):
51
+ path.parent.mkdir(parents=True, exist_ok=True)
52
+ path.write_text(json.dumps(known), encoding="utf-8")
53
+
54
+
55
+ class RpcError(Exception):
56
+ def __init__(self, code: int, message: str) -> None:
57
+ super().__init__(message)
58
+ self.code = code
59
+
60
+
61
+ def flatten(prompt: list[dict]) -> str:
62
+ """Prompt blocks as one text: resources become fenced file text."""
63
+ parts = []
64
+ for block in prompt:
65
+ kind = block.get("type")
66
+ if kind == "text":
67
+ parts.append(block.get("text", ""))
68
+ elif kind == "resource":
69
+ res = block.get("resource", {})
70
+ parts.append(f"{res.get('uri', '')}\n```\n{res.get('text', '')}\n```")
71
+ elif kind == "resource_link":
72
+ parts.append(block.get("uri", ""))
73
+ return "\n\n".join(p for p in parts if p)
74
+
75
+
76
+ def _mcp_configs(entries) -> dict:
77
+ from rlmagent.mcp import ServerConfig
78
+
79
+ if not isinstance(entries, list):
80
+ raise RpcError(-32602, "mcpServers must be a list")
81
+ servers = {}
82
+ for entry in entries:
83
+ if not isinstance(entry, dict):
84
+ raise RpcError(-32602, "invalid MCP server entry")
85
+ name = entry.get("name")
86
+ if not isinstance(name, str) or not name.strip() or name in servers:
87
+ raise RpcError(-32602, "MCP servers need unique nonempty names")
88
+ kind = entry.get("type", "stdio")
89
+ if kind == "http":
90
+ url = entry.get("url")
91
+ from urllib.parse import urlsplit
92
+
93
+ try:
94
+ parsed = urlsplit(url) if isinstance(url, str) else None
95
+ valid = parsed and parsed.scheme in ("http", "https") and parsed.hostname
96
+ except ValueError:
97
+ valid = False
98
+ if not valid or parsed.username or parsed.fragment:
99
+ raise RpcError(-32602, f"invalid HTTP MCP URL for {name}")
100
+ headers = _mcp_pairs(entry.get("headers", []))
101
+ servers[name] = ServerConfig(name, url=url, headers=headers, expand_env=False)
102
+ elif kind == "stdio":
103
+ command, args = entry.get("command"), entry.get("args", [])
104
+ if (not isinstance(command, str) or not command.strip()
105
+ or not isinstance(args, list) or not all(isinstance(arg, str) for arg in args)):
106
+ raise RpcError(-32602, f"invalid stdio MCP command for {name}")
107
+ env = _mcp_pairs(entry.get("env", []))
108
+ servers[name] = ServerConfig(name, command=command, args=args, env=env, expand_env=False)
109
+ else:
110
+ raise RpcError(-32602, f"unsupported MCP transport: {kind}")
111
+ return servers
112
+
113
+
114
+ def _mcp_pairs(entries) -> dict[str, str]:
115
+ if not isinstance(entries, list) or any(
116
+ not isinstance(entry, dict) or not isinstance(entry.get("name"), str)
117
+ or not entry["name"] or not isinstance(entry.get("value"), str) for entry in entries
118
+ ):
119
+ raise RpcError(-32602, "MCP headers and environment entries need name/value strings")
120
+ return {entry["name"]: entry["value"] for entry in entries}
121
+
122
+
123
+ class Server:
124
+ def __init__(self, read_line: ReadLine, write: Write, argv: list[str] | None = None,
125
+ build=None) -> None:
126
+ self.read_line = read_line
127
+ self.write = write
128
+ self.argv = argv or []
129
+ self._build = build
130
+ self.sessions: dict[str, object] = {}
131
+ self.pending: dict[str, str] = {}
132
+ self.mcp_servers: dict[str, dict] = {}
133
+ self.runtimes: dict[str, object] = {}
134
+ self.closing: set[str] = set()
135
+ self.steering_ids: dict[str, dict[str, str]] = {}
136
+ self.modes: dict[str, str] = {}
137
+ self._starting = asyncio.Lock()
138
+ self.running: dict[str, asyncio.Task] = {}
139
+ self._next_id = 0
140
+ self._waiting: dict[int, tuple[str | None, asyncio.Future]] = {}
141
+ self._tasks: set[asyncio.Task] = set()
142
+
143
+ # -- wire ---------------------------------------------------------------
144
+
145
+ def _send(self, message: dict) -> None:
146
+ self.write((json.dumps({"jsonrpc": "2.0", **message}) + "\n").encode())
147
+
148
+ def notify(self, method: str, params: dict) -> None:
149
+ self._send({"method": method, "params": params})
150
+
151
+ async def request(self, method: str, params: dict) -> dict:
152
+ self._next_id += 1
153
+ future = asyncio.get_running_loop().create_future()
154
+ request_id = self._next_id
155
+ self._waiting[request_id] = (params.get("sessionId"), future)
156
+ try:
157
+ self._send({"id": request_id, "method": method, "params": params})
158
+ return await future
159
+ finally:
160
+ self._waiting.pop(request_id, None)
161
+
162
+ async def serve(self) -> None:
163
+ while True:
164
+ line = await self.read_line()
165
+ if not line:
166
+ break
167
+ if not line.strip():
168
+ continue
169
+ try:
170
+ message = json.loads(line)
171
+ except json.JSONDecodeError:
172
+ self._send({"id": None, "error": {"code": -32700, "message": "parse error"}})
173
+ continue
174
+ if "method" not in message:
175
+ waiting = self._waiting.get(message.get("id"))
176
+ if waiting is not None and not waiting[1].done():
177
+ waiting[1].set_result(message.get("result") or {})
178
+ continue
179
+ task = asyncio.create_task(self._dispatch(message))
180
+ self._tasks.add(task)
181
+ task.add_done_callback(self._tasks.discard)
182
+ await self.close()
183
+
184
+ async def _dispatch(self, message: dict) -> None:
185
+ handler = getattr(self, "on_" + message["method"].replace("/", "_"), None)
186
+ msg_id = message.get("id")
187
+ try:
188
+ if handler is None:
189
+ raise RpcError(-32601, f"method not found: {message['method']}")
190
+ result = await handler(message.get("params") or {})
191
+ except RpcError as exc:
192
+ if msg_id is not None:
193
+ self._send({"id": msg_id, "error": {"code": exc.code, "message": str(exc)}})
194
+ return
195
+ except Exception as exc: # noqa: BLE001 - reported to the editor, not raised
196
+ if msg_id is not None:
197
+ self._send({"id": msg_id, "error": {"code": -32603, "message": repr(exc)}})
198
+ return
199
+ if msg_id is not None:
200
+ self._send({"id": msg_id, "result": result if result is not None else {}})
201
+
202
+ # -- methods ------------------------------------------------------------
203
+
204
+ async def on_initialize(self, params: dict) -> dict:
205
+ return {"protocolVersion": PROTOCOL_VERSION,
206
+ "agentCapabilities": {"loadSession": True,
207
+ "mcpCapabilities": {"http": True, "sse": False},
208
+ "promptCapabilities": {"embeddedContext": True}},
209
+ "authMethods": [], "_meta": {"steering": {"supported": True},
210
+ "rlmagent/runtime-v1": True}}
211
+
212
+ async def _open(self, sid: str, cwd: str, resume: str | None):
213
+ from rlmagent.cli.main import _build_run_parser
214
+
215
+ ns = _build_run_parser().parse_args(self.argv)
216
+ ns.resume = resume
217
+ ns.acp_mcp = self.mcp_servers.get(sid, {})
218
+ ns.trust_ask = lambda request: self._ask_trust(sid, request)
219
+ build = self._build
220
+ runtime = self.runtimes.get(sid)
221
+ if runtime is not None:
222
+ from rlmagent.acp.runtime import build as build_runtime
223
+
224
+ if resume is not None:
225
+ raise RpcError(-32602, "explicit runtime sessions start with session/new")
226
+ if cwd and os.path.realpath(cwd) != os.path.realpath(runtime.contract.workdir):
227
+ raise RpcError(-32602, "session cwd must match runtime contract workdir")
228
+ build = lambda _ns: build_runtime(runtime)
229
+ if build is None:
230
+ from rlmagent.runtime import build_session as build
231
+ # Start-up reads the process's working folder, so sessions start one at a time.
232
+ async with self._starting:
233
+ previous = os.getcwd()
234
+ try:
235
+ if cwd:
236
+ os.chdir(cwd)
237
+ session = await build(ns)
238
+ except SystemExit as exc:
239
+ # Start-up failures exit the terminal app; here they answer this request only.
240
+ raise RpcError(-32603, f"could not start a session (exit {exc.code}); "
241
+ "see the agent's stderr") from None
242
+ finally:
243
+ os.chdir(previous)
244
+ tree = getattr(session, "agent_tree", None)
245
+ if tree is not None and tree.approver.policy == "ask":
246
+ tree.approver.ask = lambda request: self._ask(sid, request)
247
+ if tree is not None:
248
+ tree.approver.question = lambda text, choices, multiple=False: self._question(
249
+ sid, text, choices, multiple)
250
+ if self.modes.get(sid) == "plan":
251
+ session.set_plan_mode(True)
252
+ self.sessions[sid] = session
253
+ if session.session_id != sid:
254
+ _remember(sid, session.session_id)
255
+ return session
256
+
257
+ async def _ask_trust(self, sid: str, request):
258
+ from rlmagent.runtime import trust_choices, trust_question
259
+
260
+ menu = dict((choice.value, choice) for choice, _ in trust_choices(request))
261
+ options = [{"optionId": choice.value, "name": label,
262
+ "kind": ("reject_" if choice.value.startswith("distrust") else "allow_")
263
+ + ("once" if choice.value.endswith("once") else "always")}
264
+ for choice, label in trust_choices(request)]
265
+ reply = await self.request("session/request_permission", {
266
+ "sessionId": sid,
267
+ "toolCall": {"toolCallId": "project-trust", "title": trust_question(request),
268
+ "kind": "other", "status": "pending"},
269
+ "options": options})
270
+ outcome = reply.get("outcome", {})
271
+ return menu.get(outcome.get("optionId")) if outcome.get("outcome") == "selected" else None
272
+
273
+ async def _question(self, sid: str, text: str, choices: list[str] | None,
274
+ multiple: bool = False) -> str | None:
275
+ """Editors take a pick from a list over ACP; they have no free-text answer."""
276
+ if not choices or multiple:
277
+ raise ValueError("this editor can only pick one answer from a list of choices")
278
+ reply = await self.request("session/request_permission", {
279
+ "sessionId": sid,
280
+ "toolCall": {"toolCallId": f"question-{uuid.uuid4().hex[:12]}", "title": text,
281
+ "kind": "other", "status": "pending"},
282
+ "options": [{"optionId": str(i), "name": c, "kind": "allow_once"}
283
+ for i, c in enumerate(choices)]})
284
+ outcome = reply.get("outcome", {})
285
+ if outcome.get("outcome") != "selected":
286
+ return None
287
+ return choices[int(outcome["optionId"])]
288
+
289
+ async def _ask(self, session_id: str, request) -> str:
290
+ reply = await self.request("session/request_permission", {
291
+ "sessionId": session_id,
292
+ "toolCall": {"toolCallId": f"approval-{request.digest[:12]}",
293
+ "title": request.describe(), "kind": "edit" if request.kind == "write"
294
+ else "execute", "status": "pending"},
295
+ "options": OPTIONS})
296
+ outcome = reply.get("outcome", {})
297
+ return outcome.get("optionId", DENY) if outcome.get("outcome") == "selected" else DENY
298
+
299
+ def _modes(self, sid: str) -> dict:
300
+ return {"currentModeId": self.modes.get(sid, "default"), "availableModes": MODES}
301
+
302
+ async def on_session_new(self, params: dict) -> dict:
303
+ # The session starts with its first prompt, so the folder-trust question (if any)
304
+ # reaches the editor while a prompt is running, where editors show permission requests.
305
+ sid = uuid.uuid4().hex[:12]
306
+ from rlmagent.acp.runtime import KEY, parse
307
+ from rlmagent.contract import ContractError
308
+
309
+ meta = params.get("_meta", {})
310
+ if not isinstance(meta, dict):
311
+ raise RpcError(-32602, "session metadata must be an object")
312
+ if KEY in meta:
313
+ try:
314
+ runtime = parse(meta[KEY])
315
+ except (ContractError, ValueError, TypeError):
316
+ raise RpcError(-32602, "invalid explicit runtime configuration") from None
317
+ if params.get("mcpServers"):
318
+ raise RpcError(-32602, "explicit runtime contracts do not admit ambient MCP servers")
319
+ self.runtimes[sid] = runtime
320
+ self.mcp_servers[sid] = _mcp_configs(params.get("mcpServers", []))
321
+ self.pending[sid] = params.get("cwd", "")
322
+ return {"sessionId": sid, "modes": self._modes(sid)}
323
+
324
+ async def on_session_load(self, params: dict) -> dict:
325
+ sid = params["sessionId"]
326
+ if sid in self.running or sid in self.sessions or sid in self.pending or sid in self.closing:
327
+ raise RpcError(-32602, "session already open")
328
+ self.mcp_servers[sid] = _mcp_configs(params.get("mcpServers", []))
329
+ self.running[sid] = asyncio.current_task()
330
+ try:
331
+ session = await self._open(sid, params.get("cwd", ""), _recall(sid))
332
+ except BaseException:
333
+ self.mcp_servers.pop(sid, None)
334
+ raise
335
+ finally:
336
+ self.running.pop(sid, None)
337
+ for update in updates.replay(session.transcript):
338
+ self.notify("session/update", {"sessionId": sid, "update": update})
339
+ return {"modes": self._modes(sid)}
340
+
341
+ async def on_session_set_mode(self, params: dict) -> dict:
342
+ sid, mode = params.get("sessionId", ""), params.get("modeId", "")
343
+ if mode not in {m["id"] for m in MODES}:
344
+ raise RpcError(-32602, f"unknown mode: {mode}")
345
+ if sid not in self.sessions and sid not in self.pending:
346
+ raise RpcError(-32602, f"unknown session: {sid}")
347
+ self.modes[sid] = mode
348
+ if sid in self.sessions:
349
+ self.sessions[sid].set_plan_mode(mode == "plan")
350
+ self.notify("session/update", {"sessionId": sid, "update": {
351
+ "sessionUpdate": "current_mode_update", "currentModeId": mode}})
352
+ return {}
353
+
354
+ async def on_session_prompt(self, params: dict) -> dict:
355
+ from rlmagent.tools._shared import set_workspace
356
+
357
+ sid = params["sessionId"]
358
+ if sid in self.running or sid in self.closing:
359
+ raise RpcError(-32602, "a prompt is already running in this session")
360
+ self.running[sid] = asyncio.current_task()
361
+ try:
362
+ if sid in self.pending:
363
+ await self._open(sid, self.pending[sid], None)
364
+ self.pending.pop(sid, None)
365
+ session = self.sessions.get(sid)
366
+ if session is None:
367
+ raise RpcError(-32602, f"unknown session: {sid}")
368
+ set_workspace(session.cwd) # this task's file tools stay in this session's folder
369
+ return await self._prompt(sid, session, params)
370
+ except asyncio.CancelledError:
371
+ result = {"stopReason": "cancelled"}
372
+ if sid in self.runtimes and sid in self.sessions:
373
+ result["_meta"] = {"rlmagent/session-v1": self._snapshot(self.sessions[sid], "cancelled")}
374
+ return result
375
+ finally:
376
+ self.running.pop(sid, None)
377
+
378
+ async def _prompt(self, sid: str, session, params: dict) -> dict:
379
+ translator = updates.Translator()
380
+ watcher = asyncio.create_task(self._watch_plan(sid, session))
381
+ messages: list = []
382
+ try:
383
+ async for event in session.submit(flatten(params.get("prompt", []))):
384
+ for update in translator.updates(event):
385
+ self.notify("session/update", {"sessionId": sid, "update": update})
386
+ if getattr(event, "type", "") == "agent_end":
387
+ messages = event.messages
388
+ finally:
389
+ watcher.cancel()
390
+ result = {"stopReason": updates.stop_reason(messages)}
391
+ if not hasattr(session, "agent_tree") and result["stopReason"] == "end_turn":
392
+ _, pending = session.steering.context("root", root=True)
393
+ session.steering.consumed(pending, "root", None)
394
+ if sid in self.runtimes:
395
+ result["_meta"] = {"rlmagent/session-v1": self._snapshot(session, result["stopReason"])}
396
+ return result
397
+
398
+ def _snapshot(self, session, reason=None) -> dict:
399
+ from dataclasses import asdict
400
+
401
+ tree = getattr(session, "agent_tree", None)
402
+ tracer = getattr(tree, "tracer", None)
403
+ metrics = {"modelRequests": sum(r["kind"] == "call" for r in tracer.records),
404
+ "causalEdges": sum(r["kind"] == "link" for r in tracer.records)} if tracer else {}
405
+ allowance = getattr(tree, "allowance", None)
406
+ limits = {key: getattr(allowance, key, None) for key in
407
+ ("max_calls", "max_cost", "max_depth", "max_live", "max_seconds")}
408
+ contract = getattr(session, "runtime_contract", None)
409
+ if contract is not None:
410
+ limits = asdict(contract.limits)
411
+ events = getattr(getattr(session, "backend", None), "events", [])
412
+ agents = list(getattr(tree, "backend_agents", {}).values())
413
+ metrics.update(
414
+ toolResults=sum(event.get("kind") == "host_result" for event in events),
415
+ cells=sum(event.get("kind") == "output_received" for event in events),
416
+ agentsStarted=sum(agent.get("depth", 0) > 0 for agent in agents) if agents else len(getattr(tree, "records", {})),
417
+ agentsRunning=sum(agent.get("status") == "running" for agent in agents),
418
+ openModelRequests=len(getattr(tracer, "_open", {})),
419
+ modelReservations=getattr(allowance, "calls", 0),
420
+ transportAttempts=sum(record.get("kind") == "model_attempt"
421
+ for record in getattr(tracer, "records", [])),
422
+ unreportedUsageRequests=sum(record.get("kind") == "call" and not record.get("usage_reported", True)
423
+ for record in getattr(tracer, "records", [])),
424
+ )
425
+ jobs = getattr(getattr(session, "jobs", None), "jobs", {})
426
+ metrics["jobsStarted"] = len(jobs)
427
+ metrics["jobsRunning"] = sum(not job.done for job in jobs.values())
428
+ metrics["unpricedModelRequests"] = sum(record.get("kind") == "call" and record.get("cost", 0) <= 0
429
+ for record in getattr(tracer, "records", []))
430
+ if not events:
431
+ from rlmagent.harness.session.records import TranscriptRecord
432
+ from rlmagent.harness.session.store import deserialize_records
433
+
434
+ children = list(getattr(tree, "_session_of", {}).values())
435
+ sessions = {item.session_id: item for item in [session, *children]}
436
+ messages = []
437
+ for item in sessions.values():
438
+ vault = getattr(item, "_vault", None)
439
+ if vault is not None and vault.path.exists():
440
+ records = deserialize_records(vault.path.read_text(encoding="utf-8").split("\n"))
441
+ messages.extend(record.message for record in records if isinstance(record, TranscriptRecord))
442
+ else:
443
+ messages.extend(item.transcript)
444
+ results = [message for message in messages if message.role == "toolResult"]
445
+ metrics["toolResults"] = len(results)
446
+ metrics["cells"] = sum(message.tool_name == "python" for message in results)
447
+ from rlmagent.stops import of_session
448
+
449
+ stop = of_session(session, getattr(getattr(tree, "provider", None), "refusal", None))
450
+ snapshot = {"format": "rlmagent.session/1", "sessionId": session.session_id, "model": session.model,
451
+ "usage": asdict(session.usage), "metrics": metrics, "limits": limits,
452
+ "treeUsage": dict(getattr(tracer, "_totals", {})),
453
+ "treeTotals": {name: sum(agent.get(name, 0) for agent in getattr(tracer, "_totals", {}).values())
454
+ for name in ("calls", "cost", "input", "output", "cache_read", "cache_write", "reasoning")},
455
+ "steering": session.steering.receipts(), "stopReason": reason, **stop}
456
+ if reason == "cancelled":
457
+ snapshot["stop_reason"] = "cancelled"
458
+ if tracer is not None:
459
+ totals = snapshot["treeTotals"]
460
+ lifetime = tracer.lifetime_usage()
461
+ snapshot["lifetimeAccountingScope"] = "persistedRequestLedger"
462
+ snapshot["lifetimeUsage"] = lifetime["agents"]
463
+ snapshot["lifetimeTotals"] = lifetime["totals"]
464
+ snapshot["lifetimeCoverage"] = lifetime["coverage"]
465
+ snapshot["accountingScope"] = "runtimeTree"
466
+ snapshot["frontendUsage"] = snapshot["usage"].copy()
467
+ snapshot["usage"].update(total_input_tokens=totals["input"] + totals["cache_read"] + totals["cache_write"],
468
+ total_output_tokens=totals["output"], total_cost_usd=totals["cost"],
469
+ cache_read_tokens=totals["cache_read"], cache_write_tokens=totals["cache_write"])
470
+ if contract is not None:
471
+ snapshot["runtime"] = {"engine": contract.engine, "kernel": asdict(contract.kernel),
472
+ "model": {"provider": contract.model.provider, "name": contract.model.name,
473
+ "base_url": contract.model.base_url},
474
+ "policy": asdict(tree.policy), "tools": list(contract.tools)}
475
+ return snapshot
476
+
477
+ async def _watch_plan(self, sid: str, session) -> None:
478
+ tree = getattr(session, "agent_tree", None)
479
+ if tree is None:
480
+ return
481
+ last: list = []
482
+ while True:
483
+ entries = updates.plan(tree)
484
+ if entries != last:
485
+ self.notify("session/update", {"sessionId": sid,
486
+ "update": {"sessionUpdate": "plan", "entries": entries}})
487
+ last = entries
488
+ await asyncio.sleep(0.5)
489
+
490
+ async def on_session_cancel(self, params: dict) -> None:
491
+ sid = params.get("sessionId", "")
492
+ session = self.sessions.get(sid)
493
+ if session is not None:
494
+ session.abort()
495
+ for owner, future in list(self._waiting.values()):
496
+ if owner == sid and not future.done():
497
+ future.set_result({"outcome": {"outcome": "cancelled"}})
498
+
499
+ async def on__session_steering(self, params: dict) -> dict:
500
+ sid = params["sessionId"]
501
+ session = self.sessions.get(sid)
502
+ if session is None or sid in self.closing:
503
+ return {"status": "promptRequired", "reason": "noRunningTurn"}
504
+ prompt = params.get("prompt", [])
505
+ if not isinstance(prompt, list):
506
+ raise RpcError(-32602, "steering requires text prompt blocks")
507
+ parts = []
508
+ for block in prompt:
509
+ match block:
510
+ case {"type": "text", "text": str(value)}:
511
+ if value:
512
+ parts.append(value)
513
+ case _:
514
+ raise RpcError(-32602, "steering requires text prompt blocks")
515
+ text = "\n\n".join(parts).strip()
516
+ if not text:
517
+ raise RpcError(-32602, "steering requires nonempty text")
518
+ identity = params.get("messageId")
519
+ ledger = getattr(session, "steering", None)
520
+ if ledger is not None:
521
+ known = {item["messageId"] for item in ledger.receipts()}
522
+ if sid not in self.running and identity not in known:
523
+ return {"status": "promptRequired", "reason": "noRunningTurn"}
524
+ try:
525
+ receipt = ledger.accept(text, identity, params.get("agentId", "root"))
526
+ except ValueError as error:
527
+ raise RpcError(-32602, str(error)) from None
528
+ if not hasattr(session, "agent_tree") and receipt["status"] == "accepted":
529
+ if identity not in self.steering_ids.setdefault(sid, {}):
530
+ session.inject(text)
531
+ self.steering_ids[sid][receipt["messageId"]] = text
532
+ return {"status": "injected", "_meta": {"rlmagent/steering-v1": receipt}}
533
+ if sid not in self.running:
534
+ return {"status": "promptRequired", "reason": "noRunningTurn"}
535
+ session.inject(text)
536
+ return {"status": "injected"}
537
+
538
+ async def on__session_steering_status(self, params: dict) -> dict:
539
+ session = self.sessions.get(params.get("sessionId"))
540
+ if session is None:
541
+ raise RpcError(-32602, "Unknown session")
542
+ return {"receipts": session.steering.receipts()}
543
+
544
+ async def on_session_close(self, params: dict) -> dict:
545
+ sid = params["sessionId"]
546
+ if sid in self.closing:
547
+ raise RpcError(-32602, "session is already closing")
548
+ if sid not in self.sessions and sid not in self.pending and sid not in self.running:
549
+ raise RpcError(-32602, f"unknown session: {sid}")
550
+ self.closing.add(sid)
551
+ try:
552
+ await self.on_session_cancel(params)
553
+ task = self.running.get(sid)
554
+ if task is not None:
555
+ if sid not in self.sessions:
556
+ task.cancel()
557
+ with contextlib.suppress(asyncio.CancelledError):
558
+ await task
559
+ session = self.sessions.pop(sid, None)
560
+ if session is None:
561
+ return {}
562
+ snapshot = self._snapshot(session)
563
+ await session.shutdown()
564
+ return {"_meta": {"rlmagent/session-v1": snapshot}}
565
+ finally:
566
+ self.pending.pop(sid, None)
567
+ self.mcp_servers.pop(sid, None)
568
+ self.runtimes.pop(sid, None)
569
+ self.steering_ids.pop(sid, None)
570
+ self.modes.pop(sid, None)
571
+ self.closing.discard(sid)
572
+
573
+ async def close(self) -> None:
574
+ outstanding = tuple(self._tasks)
575
+ for handler in outstanding:
576
+ handler.cancel()
577
+ await asyncio.gather(*outstanding, return_exceptions=True)
578
+ for session in self.sessions.values():
579
+ with contextlib.suppress(Exception):
580
+ await session.shutdown()
581
+ self.sessions.clear()
582
+
583
+
584
+ def run(argv: list[str]) -> int:
585
+ """Serve on this process's stdin/stdout; everything else printed goes to stderr."""
586
+ out = sys.stdout.buffer
587
+ sys.stdout = sys.stderr
588
+ stdin = sys.stdin.buffer
589
+
590
+ def write(data: bytes) -> None:
591
+ out.write(data)
592
+ out.flush()
593
+
594
+ async def main() -> None:
595
+ await Server(lambda: asyncio.to_thread(stdin.readline), write, argv).serve()
596
+
597
+ asyncio.run(main())
598
+ return 0