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.
- rlmagent/__init__.py +1 -0
- rlmagent/__main__.py +8 -0
- rlmagent/acp/__init__.py +1 -0
- rlmagent/acp/runtime.py +71 -0
- rlmagent/acp/server.py +598 -0
- rlmagent/acp/updates.py +128 -0
- rlmagent/agentnet.py +237 -0
- rlmagent/agents/__init__.py +1 -0
- rlmagent/agents/approval.py +90 -0
- rlmagent/agents/bridge.py +109 -0
- rlmagent/agents/budget.py +212 -0
- rlmagent/agents/child_handles.py +46 -0
- rlmagent/agents/child_mailbox.py +30 -0
- rlmagent/agents/kernel_api.py +156 -0
- rlmagent/agents/output.py +34 -0
- rlmagent/agents/patch.py +107 -0
- rlmagent/agents/prompts.py +156 -0
- rlmagent/agents/trace.py +290 -0
- rlmagent/agents/tree.py +682 -0
- rlmagent/agents/wasm_api.py +105 -0
- rlmagent/agents/workspace.py +357 -0
- rlmagent/autopilot.py +447 -0
- rlmagent/backend.py +946 -0
- rlmagent/backend_context.py +74 -0
- rlmagent/cli/__init__.py +5 -0
- rlmagent/cli/config_cmd.py +177 -0
- rlmagent/cli/health.py +156 -0
- rlmagent/cli/main.py +1194 -0
- rlmagent/cli/update.py +188 -0
- rlmagent/clipboard.py +63 -0
- rlmagent/config/__init__.py +37 -0
- rlmagent/config/config.py +297 -0
- rlmagent/config/loader.py +119 -0
- rlmagent/config/prompts.py +124 -0
- rlmagent/config/secure_input.py +213 -0
- rlmagent/config/store.py +275 -0
- rlmagent/context/__init__.py +47 -0
- rlmagent/context/budget.py +557 -0
- rlmagent/contract.py +311 -0
- rlmagent/contract_run.py +232 -0
- rlmagent/conversation.py +2547 -0
- rlmagent/cron.py +77 -0
- rlmagent/directives.py +893 -0
- rlmagent/discovery.py +356 -0
- rlmagent/execution_log.py +66 -0
- rlmagent/factory.py +189 -0
- rlmagent/factory_graph.py +413 -0
- rlmagent/harness/__init__.py +63 -0
- rlmagent/harness/contracts/__init__.py +1 -0
- rlmagent/harness/contracts/stream.py +129 -0
- rlmagent/harness/contracts/tooling.py +127 -0
- rlmagent/harness/contracts/transcript/__init__.py +68 -0
- rlmagent/harness/contracts/transcript/_base.py +29 -0
- rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
- rlmagent/harness/contracts/transcript/entries.py +180 -0
- rlmagent/harness/contracts/transcript/segments.py +64 -0
- rlmagent/harness/contracts/values.py +6 -0
- rlmagent/harness/driver.py +397 -0
- rlmagent/harness/engine.py +489 -0
- rlmagent/harness/mending.py +153 -0
- rlmagent/harness/provider/__init__.py +1 -0
- rlmagent/harness/provider/base.py +27 -0
- rlmagent/harness/provider/wire.py +189 -0
- rlmagent/harness/py.typed +0 -0
- rlmagent/harness/session/__init__.py +5 -0
- rlmagent/harness/session/index.py +218 -0
- rlmagent/harness/session/lease.py +66 -0
- rlmagent/harness/session/records.py +118 -0
- rlmagent/harness/session/replay.py +165 -0
- rlmagent/harness/session/store.py +155 -0
- rlmagent/harness/session/summary.py +46 -0
- rlmagent/hints.py +47 -0
- rlmagent/history.py +92 -0
- rlmagent/hooks/__init__.py +1 -0
- rlmagent/hooks/hooks.py +1 -0
- rlmagent/instructions.py +157 -0
- rlmagent/job_runner.py +117 -0
- rlmagent/jobs.py +365 -0
- rlmagent/kept_agents.py +397 -0
- rlmagent/kernel/__init__.py +5 -0
- rlmagent/kernel/session.py +205 -0
- rlmagent/kernel/wasm.py +169 -0
- rlmagent/logs.py +208 -0
- rlmagent/managed_release.py +368 -0
- rlmagent/mcp.py +411 -0
- rlmagent/mcp_oauth.py +213 -0
- rlmagent/model/__init__.py +29 -0
- rlmagent/model/_claude/__init__.py +1 -0
- rlmagent/model/_claude/courier.py +117 -0
- rlmagent/model/_claude/emitter.py +378 -0
- rlmagent/model/_oai/__init__.py +1 -0
- rlmagent/model/_oai/helpers.py +256 -0
- rlmagent/model/_oai/normalize.py +263 -0
- rlmagent/model/_oai/parsers.py +317 -0
- rlmagent/model/_oai/payloads.py +226 -0
- rlmagent/model/_oai/transport.py +127 -0
- rlmagent/model/claude.py +552 -0
- rlmagent/model/copilot.py +142 -0
- rlmagent/model/correlation.py +21 -0
- rlmagent/model/deadline.py +19 -0
- rlmagent/model/discovery.py +181 -0
- rlmagent/model/external/__init__.py +11 -0
- rlmagent/model/external/acp.py +202 -0
- rlmagent/model/external/acp_install.py +109 -0
- rlmagent/model/external/cli.py +248 -0
- rlmagent/model/external/textcalls.py +57 -0
- rlmagent/model/failover.py +129 -0
- rlmagent/model/google.py +199 -0
- rlmagent/model/limits.py +46 -0
- rlmagent/model/oai_compatible.py +414 -0
- rlmagent/model/preferences.py +56 -0
- rlmagent/model/py.typed +0 -0
- rlmagent/model/registered_oauth.py +195 -0
- rlmagent/model/scripted.py +55 -0
- rlmagent/model/settings.py +271 -0
- rlmagent/model/subscription.py +398 -0
- rlmagent/model/token_bounds.py +15 -0
- rlmagent/model/transport/__init__.py +1 -0
- rlmagent/model/transport/backoff.py +87 -0
- rlmagent/model/transport/client.py +87 -0
- rlmagent/model/transport/faults.py +65 -0
- rlmagent/model/transport/responses_socket.py +223 -0
- rlmagent/model/vertex.py +93 -0
- rlmagent/network.py +123 -0
- rlmagent/packages.py +246 -0
- rlmagent/planning.py +84 -0
- rlmagent/playbook/__init__.py +1 -0
- rlmagent/playbook/apply.py +106 -0
- rlmagent/playbook/digest.py +63 -0
- rlmagent/playbook/improver.py +272 -0
- rlmagent/playbook/plan.py +99 -0
- rlmagent/playbook/store.py +67 -0
- rlmagent/plugins.py +327 -0
- rlmagent/policy.py +105 -0
- rlmagent/prompt_refs.py +57 -0
- rlmagent/prompts.py +331 -0
- rlmagent/prune.py +60 -0
- rlmagent/py.typed +0 -0
- rlmagent/reasoning.py +135 -0
- rlmagent/refinement/__init__.py +1 -0
- rlmagent/refinement/controller.py +448 -0
- rlmagent/refinement/engine.py +220 -0
- rlmagent/refinement/evidence.py +131 -0
- rlmagent/refinement/scoped.py +90 -0
- rlmagent/refinement/store.py +193 -0
- rlmagent/refresh.py +202 -0
- rlmagent/routing.py +52 -0
- rlmagent/runtime.py +575 -0
- rlmagent/safety.py +267 -0
- rlmagent/session_html.py +57 -0
- rlmagent/session_import.py +52 -0
- rlmagent/session_share.py +55 -0
- rlmagent/sessions.py +249 -0
- rlmagent/shell_sandbox.py +88 -0
- rlmagent/skillset.py +320 -0
- rlmagent/steering.py +148 -0
- rlmagent/stops.py +62 -0
- rlmagent/subscriptions.py +219 -0
- rlmagent/tasks.py +98 -0
- rlmagent/toolhost.py +310 -0
- rlmagent/tools/__init__.py +56 -0
- rlmagent/tools/_shared.py +210 -0
- rlmagent/tools/files.py +462 -0
- rlmagent/tools/kernel.py +93 -0
- rlmagent/tools/patch_tool.py +68 -0
- rlmagent/tools/search.py +188 -0
- rlmagent/trust.py +383 -0
- rlmagent/tui/__init__.py +1 -0
- rlmagent/tui/adapter.py +506 -0
- rlmagent/tui/app.py +711 -0
- rlmagent/tui/approval_screen.py +57 -0
- rlmagent/tui/choice_screen.py +47 -0
- rlmagent/tui/completions.py +116 -0
- rlmagent/tui/components.py +25 -0
- rlmagent/tui/composer.py +40 -0
- rlmagent/tui/factory_view.py +86 -0
- rlmagent/tui/inspect.py +193 -0
- rlmagent/tui/messages.py +215 -0
- rlmagent/tui/model_screen.py +307 -0
- rlmagent/tui/prompt.py +98 -0
- rlmagent/tui/question_screen.py +52 -0
- rlmagent/tui/replay.py +141 -0
- rlmagent/tui/rlm_agent.tcss +405 -0
- rlmagent/tui/search_box.py +91 -0
- rlmagent/tui/sidebar.py +182 -0
- rlmagent/tui/status.py +49 -0
- rlmagent/tui/steps.py +283 -0
- rlmagent/tui/suggest.py +778 -0
- rlmagent/tui/transcript.py +172 -0
- rlmagent/tui/trust_dialog.py +58 -0
- rlmagent/tui/worker_attach.py +141 -0
- rlmagent/ui/__init__.py +1 -0
- rlmagent/ui/render.py +211 -0
- rlmagent/undo.py +109 -0
- rlmagent/utils/__init__.py +1 -0
- rlmagent/websearch.py +75 -0
- rlmagent/worker.py +1275 -0
- rlmagent/worker_service.py +203 -0
- rlmness_cli-0.2.0.dist-info/METADATA +882 -0
- rlmness_cli-0.2.0.dist-info/RECORD +203 -0
- rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
- rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
- 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
|