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
@@ -0,0 +1,21 @@
1
+ """Wire-safe tool-call identifiers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from hashlib import sha256
7
+
8
+ # The narrowest alphabet any supported vendor accepts. Ids already inside it
9
+ # pass through untouched so same-vendor replay stays byte-identical.
10
+ _SAFE_ID = re.compile(r"[A-Za-z0-9_-]{1,64}")
11
+ _DIGEST_CHARS = 40
12
+
13
+
14
+ def wire_call_id(raw: str) -> str:
15
+ """Map ``raw`` to a deterministic id every provider accepts."""
16
+ if _SAFE_ID.fullmatch(raw):
17
+ return raw
18
+ return "tc_" + sha256(raw.encode("utf-8")).hexdigest()[:_DIGEST_CHARS]
19
+
20
+
21
+ __all__ = ["wire_call_id"]
@@ -0,0 +1,19 @@
1
+ """A wall-clock deadline around one complete provider stream."""
2
+ import asyncio
3
+
4
+ from rlmagent.harness.contracts.transcript import ModelEntry
5
+ from rlmagent.harness.provider.wire import StreamFaultEvent
6
+
7
+
8
+ async def bounded_stream(stream, model, seconds=300.0):
9
+ try:
10
+ async with asyncio.timeout(seconds):
11
+ async for event in stream:
12
+ yield event
13
+ except TimeoutError:
14
+ yield StreamFaultEvent(reason="error", error=ModelEntry(model=model,
15
+ stop_reason="error", error_message=f"Model request exceeded its {seconds:g}s deadline"))
16
+ finally:
17
+ close = getattr(stream, "aclose", None)
18
+ if close is not None:
19
+ await close()
@@ -0,0 +1,181 @@
1
+ """Remote model discovery and credential checks without generating a completion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from rlmagent.config.loader import load_api_key
8
+ from rlmagent.config.store import load_config, provider_info
9
+ from rlmagent.model.transport.client import build_async_client
10
+
11
+
12
+ #: Prices a provider's own model listing reported, per provider and model:
13
+ #: (input, output) in dollars per million tokens. Only listings that carry
14
+ #: prices fill it; nothing here is guessed.
15
+ PRICES: dict[str, dict[str, tuple[float, float]]] = {}
16
+
17
+
18
+ def _per_million(value) -> float | None:
19
+ try:
20
+ number = float(value)
21
+ except (TypeError, ValueError):
22
+ return None
23
+ return number * 1_000_000 if number >= 0 else None
24
+
25
+
26
+ def remember_prices(provider: str, rows) -> None:
27
+ """Keep prices from rows shaped ``{"id", "pricing": {"prompt", "completion"}}``."""
28
+ table = PRICES.setdefault(provider, {})
29
+ for row in rows or []:
30
+ pricing = row.get("pricing") if isinstance(row, dict) else None
31
+ if not isinstance(pricing, dict) or not row.get("id"):
32
+ continue
33
+ given, taken = _per_million(pricing.get("prompt")), _per_million(pricing.get("completion"))
34
+ if given is not None and taken is not None:
35
+ table[str(row["id"])] = (given, taken)
36
+
37
+
38
+ def price_label(provider: str, model: str) -> str:
39
+ """``$3.00/$15.00 per M`` when the listing said, else empty."""
40
+ price = PRICES.get(provider, {}).get(model)
41
+ if price is None:
42
+ return ""
43
+ given, taken = price
44
+ if given == taken == 0:
45
+ return "free"
46
+ return f"${given:,.2f}/${taken:,.2f} per M"
47
+
48
+
49
+ async def discover_models(provider: str, *, api_key=None, base_url=None, client=None) -> list[str]:
50
+ if os.environ.get("RLM_AGENT_OFFLINE") == "1":
51
+ raise ValueError("Model discovery is disabled in offline startup mode")
52
+ info = provider_info(provider)
53
+ headers = {}
54
+ if provider == "copilot":
55
+ from copilot import CopilotClient
56
+
57
+ sdk = CopilotClient()
58
+ try:
59
+ await sdk.start()
60
+ return [model.id for model in await sdk.list_models()]
61
+ finally:
62
+ await sdk.stop()
63
+ if provider == "claude-subscription":
64
+ import shutil
65
+ if shutil.which("claude") is None:
66
+ raise ValueError("Install and sign in to the official Claude Code CLI")
67
+ return list(info.models)
68
+ if provider in ("codex", "xai-subscription"):
69
+ if provider == "codex":
70
+ from rlmagent.model.subscription import resolve
71
+ else:
72
+ from rlmagent.model.registered_oauth import resolve
73
+
74
+ if api_key is not None or base_url is not None:
75
+ raise ValueError("Subscription discovery uses the signed-in account and fixed endpoint")
76
+ credential = await resolve()
77
+ url, key = credential.base_url, credential.api_key
78
+ elif provider == "vertex":
79
+ raise ValueError(
80
+ "Remote model listing is unavailable for this cloud endpoint; "
81
+ "enter a compatible model identifier"
82
+ )
83
+ else:
84
+ config = load_config()
85
+ url_var = {"anthropic": "ANTHROPIC_BASE_URL", "openai": "OPENAI_BASE_URL"}.get(
86
+ provider, provider.upper() + "_BASE_URL"
87
+ )
88
+ if provider == "azure":
89
+ url_var = "AZURE_OPENAI_BASE_URL"
90
+ elif provider == "google":
91
+ url_var = "GOOGLE_API_BASE"
92
+ url = base_url or config.base_urls.get(provider) or os.environ.get(url_var) or info.base_url
93
+ key = api_key or load_api_key(provider)
94
+ if provider == "anthropic":
95
+ if key:
96
+ headers["x-api-key"] = key
97
+ headers["anthropic-version"] = "2023-06-01"
98
+ endpoint = url.rstrip("/") + (
99
+ "/models" if url.rstrip("/").endswith("/v1") else "/v1/models"
100
+ )
101
+ elif provider in ("google", "azure"):
102
+ if key:
103
+ headers["x-goog-api-key" if provider == "google" else "api-key"] = key
104
+ endpoint = url.rstrip("/") + "/models"
105
+ else:
106
+ if key:
107
+ headers["Authorization"] = f"Bearer {key}"
108
+ endpoint = url.rstrip("/") + "/models"
109
+ owned = client is None
110
+ client = client or build_async_client(timeout=20)
111
+ try:
112
+ response = await client.get(endpoint, headers=headers)
113
+ response.raise_for_status()
114
+ data = response.json()
115
+ if provider == "codex":
116
+ return [row["slug"] for row in data.get("models", [])
117
+ if isinstance(row, dict) and row.get("visibility") == "list"
118
+ and isinstance(row.get("slug"), str)]
119
+ if provider == "google":
120
+ models = list(data.get("models", []))
121
+ seen = set()
122
+ while token := data.get("nextPageToken"):
123
+ if token in seen or len(seen) >= 1000:
124
+ raise ValueError("Invalid model-catalog pagination")
125
+ seen.add(token)
126
+ page = await client.get(endpoint, headers=headers, params={"pageToken": token})
127
+ page.raise_for_status()
128
+ data = page.json()
129
+ models.extend(data.get("models", []))
130
+ return sorted({row["name"].removeprefix("models/") for row in models
131
+ if isinstance(row, dict) and isinstance(row.get("name"), str)})
132
+ remember_prices(provider, data.get("data", []))
133
+ return sorted(
134
+ {
135
+ str(row["id"])
136
+ for row in data.get("data", [])
137
+ if isinstance(row, dict) and row.get("id")
138
+ }
139
+ )
140
+ finally:
141
+ if owned:
142
+ await client.aclose()
143
+
144
+
145
+ async def check_provider(provider: str, *, base_url=None, client=None) -> str:
146
+ if provider == "claude-subscription":
147
+ await discover_models(provider)
148
+ return "Official CLI found; account and inference access have not been tested."
149
+ if provider == "vertex":
150
+ from rlmagent.model.vertex import ApplicationCredentials
151
+
152
+ await ApplicationCredentials()()
153
+ return "Application credentials refreshed; inference access has not been tested."
154
+ if provider in ("openrouter", "huggingface"):
155
+ key = load_api_key(provider)
156
+ if not key:
157
+ raise ValueError("No provider credential configured")
158
+ config = load_config()
159
+ if provider == "openrouter":
160
+ url = base_url or config.base_urls.get(provider) or provider_info(provider).base_url
161
+ endpoint = url.rstrip("/") + "/key"
162
+ else:
163
+ if base_url:
164
+ raise ValueError(
165
+ "Credential testing is unavailable for a custom endpoint on this provider"
166
+ )
167
+ endpoint = "https://huggingface.co/api/whoami-v2"
168
+ owned = client is None
169
+ client = client or build_async_client(timeout=20)
170
+ try:
171
+ response = await client.get(endpoint, headers={"Authorization": f"Bearer {key}"})
172
+ response.raise_for_status()
173
+ finally:
174
+ if owned:
175
+ await client.aclose()
176
+ return "Credential endpoint accepted authentication; inference access has not been tested."
177
+ models = await discover_models(provider, base_url=base_url, client=client)
178
+ return (
179
+ f"Model endpoint reachable; {len(models)} models returned. "
180
+ "Inference access has not been tested."
181
+ )
@@ -0,0 +1,11 @@
1
+ from rlmagent.model.external.cli import ExternalAgentProvider, is_external, parse_model
2
+
3
+
4
+ def with_external(provider):
5
+ """`provider`, plus `cli:` and `acp:` models for any agent that asks for one."""
6
+ if isinstance(provider, ExternalAgentProvider):
7
+ return provider
8
+ return ExternalAgentProvider(fallback=provider)
9
+
10
+
11
+ __all__ = ["ExternalAgentProvider", "is_external", "parse_model", "with_external"]
@@ -0,0 +1,202 @@
1
+ """An agent reached over the Agent Client Protocol, used as the model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import itertools
7
+ import json
8
+ import os
9
+ import shutil
10
+ from pathlib import Path
11
+
12
+ from rlmagent.harness.contracts.tooling import CancelToken
13
+ from rlmagent.harness.contracts.transcript import UsageStats
14
+
15
+ _DENY_CLAUDE = ["Bash(*)", "Read(*)", "Write(*)", "Edit(*)", "Glob(*)", "Grep(*)",
16
+ "WebFetch(*)", "WebSearch(*)", "Task(*)", "NotebookEdit(*)"]
17
+
18
+ PRESETS: dict[str, dict] = {
19
+ "claude-code": {
20
+ "command": "npx", "bridge": "claude-code",
21
+ "files": {".claude/settings.json": {"permissions": {"deny": _DENY_CLAUDE}}},
22
+ },
23
+ "codex": {
24
+ "command": "npx", "bridge": "codex",
25
+ "readonly_mode": "read-only",
26
+ "approve": True,
27
+ },
28
+ "opencode": {
29
+ "command": "opencode", "args": ["acp"], "readonly_mode": "plan",
30
+ "files": {"opencode.json": {"permission": {k: "deny" for k in (
31
+ "bash", "edit", "read", "glob", "grep", "list", "webfetch", "task")}}},
32
+ },
33
+ }
34
+
35
+ START_TIMEOUT = 180.0
36
+ TURN_TIMEOUT = 180.0
37
+
38
+
39
+ def agents() -> dict[str, dict]:
40
+ """The presets, plus any agents registered in ~/.rlm-agent/acp_agents.json."""
41
+ from rlmagent.plugins import rlm_agent_home
42
+
43
+ found = dict(PRESETS)
44
+ path = rlm_agent_home() / "acp_agents.json"
45
+ try:
46
+ found.update(json.loads(path.read_text(encoding="utf-8")))
47
+ except (OSError, json.JSONDecodeError):
48
+ pass
49
+ return found
50
+
51
+
52
+ class Cancelled(Exception):
53
+ pass
54
+
55
+
56
+ class _Connection:
57
+ """JSON-RPC 2.0, one message per line, over the agent's stdin and stdout."""
58
+
59
+ def __init__(self, proc, approve: bool = False) -> None:
60
+ self.proc = proc
61
+ self.approve = approve
62
+ self.ids = itertools.count(1)
63
+ self.waiting: dict[int, asyncio.Future] = {}
64
+ self.text: list[str] = []
65
+ self.closed = asyncio.get_running_loop().create_future()
66
+
67
+ async def _send(self, message: dict) -> None:
68
+ self.proc.stdin.write((json.dumps({"jsonrpc": "2.0", **message}) + "\n").encode("utf-8"))
69
+ await self.proc.stdin.drain()
70
+
71
+ async def request(self, method: str, params: dict) -> dict:
72
+ rid = next(self.ids)
73
+ future = asyncio.get_running_loop().create_future()
74
+ self.waiting[rid] = future
75
+ await self._send({"id": rid, "method": method, "params": params})
76
+ done, _ = await asyncio.wait({future, self.closed}, return_when=asyncio.FIRST_COMPLETED)
77
+ if future not in done:
78
+ raise ValueError(f"the agent exited during {method}")
79
+ return future.result()
80
+
81
+ async def notify(self, method: str, params: dict) -> None:
82
+ await self._send({"method": method, "params": params})
83
+
84
+ async def pump(self) -> None:
85
+ try:
86
+ while line := await self.proc.stdout.readline():
87
+ try:
88
+ message = json.loads(line)
89
+ except json.JSONDecodeError:
90
+ continue
91
+ if trace := os.environ.get("RLM_AGENT_ACP_TRACE"):
92
+ with open(trace, "a", encoding="utf-8") as f:
93
+ f.write(line.decode("utf-8", errors="replace"))
94
+ await self._dispatch(message)
95
+ finally:
96
+ if not self.closed.done():
97
+ self.closed.set_result(None)
98
+
99
+ async def _dispatch(self, message: dict) -> None:
100
+ method = message.get("method")
101
+ if method is None:
102
+ future = self.waiting.pop(message.get("id"), None)
103
+ if future is not None and not future.done():
104
+ if "error" in message:
105
+ error = message["error"]
106
+ detail = (error.get("data") or {}).get("message") if isinstance(error.get("data"), dict) else None
107
+ text = error.get("message", "agent error") + (f": {detail}" if detail else "")
108
+ future.set_exception(ValueError(text[:500]))
109
+ else:
110
+ future.set_result(message.get("result") or {})
111
+ return
112
+ params = message.get("params") or {}
113
+ if method == "session/update":
114
+ update = params.get("update") or {}
115
+ content = update.get("content") or {}
116
+ if update.get("sessionUpdate") == "agent_message_chunk" and content.get("type") == "text":
117
+ self.text.append(content.get("text", ""))
118
+ return
119
+ if "id" not in message:
120
+ return
121
+ if method == "session/request_permission":
122
+ wanted = "allow_once" if self.approve else "reject"
123
+ picks = [o for o in params.get("options", []) if o.get("kind", "").startswith(wanted)]
124
+ outcome = ({"outcome": "selected", "optionId": picks[0]["optionId"]} if picks
125
+ else {"outcome": "cancelled"})
126
+ await self._send({"id": message["id"], "result": {"outcome": outcome}})
127
+ else:
128
+ await self._send({"id": message["id"],
129
+ "error": {"code": -32601, "message": f"{method} is not offered"}})
130
+
131
+
132
+ async def answer(name: str, sub: str | None, prompt: str, cwd: Path,
133
+ signal: CancelToken | None) -> tuple[str, UsageStats]:
134
+ spec = agents()[name]
135
+ for rel, content in (spec.get("files") or {}).items():
136
+ target = cwd / rel
137
+ target.parent.mkdir(parents=True, exist_ok=True)
138
+ target.write_text(json.dumps(content), encoding="utf-8")
139
+ args = list(spec.get("args", []))
140
+ if spec.get("bridge"):
141
+ from rlmagent.model.external.acp_install import bridge_spec
142
+
143
+ args = ["-y", bridge_spec(spec["bridge"]), *args]
144
+ from rlmagent.model.external.cli import own_group
145
+
146
+ exe = shutil.which(spec["command"])
147
+ if exe is None:
148
+ raise ValueError(f"{spec['command']} not found on PATH")
149
+ with open(cwd / "agent-stderr.log", "wb") as errors:
150
+ proc = await asyncio.create_subprocess_exec(
151
+ exe, *args, cwd=str(cwd), limit=2**24,
152
+ stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=errors,
153
+ env={**os.environ, **(spec.get("env") or {})}, **own_group(),
154
+ )
155
+ conn = _Connection(proc, approve=bool(spec.get("approve")))
156
+ pump = asyncio.create_task(conn.pump())
157
+ turn = None
158
+ try:
159
+ try:
160
+ async with asyncio.timeout(START_TIMEOUT):
161
+ await conn.request("initialize", {
162
+ "protocolVersion": 1,
163
+ "clientCapabilities": {"fs": {"readTextFile": False, "writeTextFile": False},
164
+ "terminal": False},
165
+ "clientInfo": {"name": "rlm-agent", "version": "0"},
166
+ })
167
+ session = await conn.request("session/new", {"cwd": str(cwd), "mcpServers": []})
168
+ sid = session["sessionId"]
169
+ modes = {m.get("id") for m in (session.get("modes") or {}).get("availableModes", [])}
170
+ if spec.get("readonly_mode") in modes:
171
+ await conn.request("session/set_mode", {
172
+ "sessionId": sid, "modeId": spec["readonly_mode"],
173
+ })
174
+ model = sub or spec.get("model")
175
+ if model and session.get("models"):
176
+ await conn.request("session/set_model", {"sessionId": sid, "modelId": model})
177
+ except TimeoutError:
178
+ raise ValueError(f"{name} did not initialize within {START_TIMEOUT:g}s") from None
179
+ try:
180
+ async with asyncio.timeout(TURN_TIMEOUT):
181
+ turn = asyncio.create_task(conn.request("session/prompt", {
182
+ "sessionId": sid, "prompt": [{"type": "text", "text": prompt}]}))
183
+ while not turn.done():
184
+ if signal is not None and signal.is_cancelled():
185
+ await conn.notify("session/cancel", {"sessionId": sid})
186
+ raise Cancelled
187
+ await asyncio.wait({turn}, timeout=0.2)
188
+ result = turn.result()
189
+ except TimeoutError:
190
+ raise ValueError(f"{name} did not finish its ACP turn within {TURN_TIMEOUT:g}s") from None
191
+ finally:
192
+ from rlmagent.model.external.cli import kill_tree
193
+
194
+ if turn is not None and not turn.done():
195
+ turn.cancel()
196
+ kill_tree(proc)
197
+ await proc.wait()
198
+ pump.cancel()
199
+ if result.get("stopReason") == "refusal":
200
+ raise ValueError(f"{name} refused")
201
+ # ACP replies carry no token counts, so cost stays 0; --max-calls is what limits a run.
202
+ return "".join(conn.text), UsageStats()
@@ -0,0 +1,109 @@
1
+ """`rlm-agent acp install` / `acp status`: choose the bridge versions acp: runs use.
2
+
3
+ Choices are saved in ~/.rlm-agent/bridges.json as {"format": 1, "pins": {agent:
4
+ {"package", "version"}}}. A run launches exactly the saved version; `install -u`
5
+ moves to the newest one. To follow a bridge that changed its npm name, edit that
6
+ agent's "package" in the file.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import shutil
13
+ import subprocess
14
+ from pathlib import Path
15
+
16
+ FORMAT = 1
17
+ DEFAULT_PACKAGES = {
18
+ "claude-code": "@agentclientprotocol/claude-agent-acp",
19
+ "codex": "@agentclientprotocol/codex-acp",
20
+ }
21
+ MISSING = ("acp:{agent} needs its bridge, which has not been set up. "
22
+ "Run `rlm-agent acp install` (Node and npm must be on PATH).")
23
+
24
+
25
+ def pin_file() -> Path:
26
+ from rlmagent.plugins import rlm_agent_home
27
+
28
+ return rlm_agent_home() / "bridges.json"
29
+
30
+
31
+ def read_pins() -> dict[str, dict] | None:
32
+ """Saved pins per agent, or None when install has not run (or the file is unreadable)."""
33
+ try:
34
+ saved = json.loads(pin_file().read_text(encoding="utf-8"))
35
+ except (OSError, ValueError):
36
+ return None
37
+ if not isinstance(saved, dict) or saved.get("format") != FORMAT:
38
+ return None
39
+ pins = saved.get("pins")
40
+ return pins if isinstance(pins, dict) else None
41
+
42
+
43
+ def bridge_spec(agent: str) -> str:
44
+ """What npx should launch for `agent`: package@version, or the bare package if unpinned."""
45
+ pins = read_pins()
46
+ if pins is None:
47
+ raise ValueError(MISSING.format(agent=agent))
48
+ pin = pins.get(agent) or {}
49
+ package = pin.get("package") or DEFAULT_PACKAGES[agent]
50
+ return f"{package}@{pin['version']}" if pin.get("version") else package
51
+
52
+
53
+ def _npm(*args: str) -> str | None:
54
+ """stdout of a successful npm command; None when npm is missing or fails."""
55
+ npm = shutil.which("npm")
56
+ if npm is None:
57
+ return None
58
+ try:
59
+ done = subprocess.run([npm, *args], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=120)
60
+ except (OSError, subprocess.TimeoutExpired):
61
+ return None
62
+ return done.stdout.strip() if done.returncode == 0 else None
63
+
64
+
65
+ def install(update: bool = False) -> dict[str, dict]:
66
+ """Pin every bridge (keeping saved versions unless `update`) and pre-fetch it."""
67
+ saved = read_pins() or {}
68
+ pins: dict[str, dict] = {}
69
+ for agent, default in DEFAULT_PACKAGES.items():
70
+ before = saved.get(agent) or {}
71
+ package = before.get("package") or default
72
+ kept = None if update else before.get("version")
73
+ version = kept or _npm("view", package, "version") or before.get("version")
74
+ pins[agent] = {"package": package, "version": version}
75
+ if version is None:
76
+ print(f"acp:{agent}: npm did not answer; {package} stays unpinned (newest at each launch)")
77
+ continue
78
+ fetched = _npm("cache", "add", f"{package}@{version}") is not None
79
+ print(f"acp:{agent}: {package}@{version}" + ("" if fetched else " (not pre-fetched)"))
80
+ pin_file().parent.mkdir(parents=True, exist_ok=True)
81
+ pin_file().write_text(json.dumps({"format": FORMAT, "pins": pins}, indent=2), encoding="utf-8")
82
+ print(f"Saved to {pin_file()}")
83
+ if shutil.which("npx") is None:
84
+ print("Warning: npx was not found; acp: runs start their bridge with it.")
85
+ return pins
86
+
87
+
88
+ def status() -> int:
89
+ pins = read_pins()
90
+ if pins is None:
91
+ print(f"No bridges set up ({pin_file()} missing). Run: rlm-agent acp install")
92
+ return 1
93
+ for agent, pin in pins.items():
94
+ version = pin.get("version") or "unpinned"
95
+ newest = _npm("view", pin.get("package", ""), "version")
96
+ hint = f", newer {newest} (rlm-agent acp install -u)" if newest and newest != version else ""
97
+ print(f"acp:{agent}: {pin.get('package')}@{version}{hint}")
98
+ return 0
99
+
100
+
101
+ def main(argv: list[str]) -> int:
102
+ command = argv[0] if argv else ""
103
+ if command == "install":
104
+ install(update=bool({"-u", "--update"} & set(argv)))
105
+ return 0
106
+ if command == "status":
107
+ return status()
108
+ print("usage: rlm-agent acp install [-u] | rlm-agent acp status")
109
+ return 2