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
|
@@ -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
|