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,248 @@
|
|
|
1
|
+
"""Coding agents as models: Claude Code and Codex through their own CLIs, others through ACP."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import signal
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
import uuid
|
|
15
|
+
from collections.abc import AsyncIterator, Sequence
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from rlmagent.harness.contracts.tooling import CancelToken, ToolSpec
|
|
19
|
+
from rlmagent.harness.contracts.transcript import (
|
|
20
|
+
CallBlock,
|
|
21
|
+
CostBreakdown,
|
|
22
|
+
ModelEntry,
|
|
23
|
+
TextSegment,
|
|
24
|
+
TranscriptEntry,
|
|
25
|
+
UsageStats,
|
|
26
|
+
)
|
|
27
|
+
from rlmagent.harness.provider.wire import (
|
|
28
|
+
CallCloseEvent,
|
|
29
|
+
StreamCloseEvent,
|
|
30
|
+
StreamFaultEvent,
|
|
31
|
+
WireEvent,
|
|
32
|
+
)
|
|
33
|
+
from rlmagent.model.external import acp
|
|
34
|
+
from rlmagent.model.external.acp import Cancelled as _Cancelled
|
|
35
|
+
from rlmagent.model.external.textcalls import PROTOCOL, parse_reply, render
|
|
36
|
+
|
|
37
|
+
PREFIXES = ("cli:", "acp:")
|
|
38
|
+
|
|
39
|
+
# Some models answer in their own tool-call markup instead of a python block.
|
|
40
|
+
FAKE_CALL = re.compile(r"<(function_calls|invoke|parameter|tool_use|tool_call)\b")
|
|
41
|
+
CORRECTION = ("That tool-call markup does nothing here and nothing ran. To run code, reply "
|
|
42
|
+
"with one ```python block.")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _add(a: UsageStats, b: UsageStats) -> UsageStats:
|
|
46
|
+
fields = ("total_tokens", "input", "output", "cache_read", "cache_write")
|
|
47
|
+
return UsageStats(**{f: getattr(a, f) + getattr(b, f) for f in fields},
|
|
48
|
+
cost=CostBreakdown(total=a.cost.total + b.cost.total))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_external(model: str | None) -> bool:
|
|
52
|
+
return bool(model) and model.startswith(PREFIXES)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_model(model: str) -> tuple[str, str, str | None]:
|
|
56
|
+
"""`cli:codex?model=gpt-5.5` -> ("cli", "codex", "gpt-5.5")."""
|
|
57
|
+
route, _, rest = model.partition(":")
|
|
58
|
+
name, _, query = rest.partition("?")
|
|
59
|
+
sub = dict(p.partition("=")[::2] for p in query.split("&") if p).get("model") or None
|
|
60
|
+
known = PRESETS if route == "cli" else acp.agents()
|
|
61
|
+
if name not in known:
|
|
62
|
+
raise ValueError(f"unknown agent {name!r} in {model!r}; known: {', '.join(known)}")
|
|
63
|
+
return route, name, sub
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def kill_tree(proc) -> None:
|
|
67
|
+
"""Stop the process and anything it started (npx starts node, for one)."""
|
|
68
|
+
if proc.returncode is not None:
|
|
69
|
+
return
|
|
70
|
+
if sys.platform == "win32":
|
|
71
|
+
subprocess.run(["taskkill", "/T", "/F", "/PID", str(proc.pid)], capture_output=True)
|
|
72
|
+
return
|
|
73
|
+
try:
|
|
74
|
+
if os.getpgid(proc.pid) == proc.pid:
|
|
75
|
+
os.killpg(proc.pid, signal.SIGKILL) # it leads its own group: see own_group()
|
|
76
|
+
else:
|
|
77
|
+
proc.kill()
|
|
78
|
+
except ProcessLookupError:
|
|
79
|
+
pass # it ended by itself just now; nothing is left to stop
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def own_group() -> dict:
|
|
83
|
+
"""Spawn option giving a process its own group, so kill_tree reaches what it starts."""
|
|
84
|
+
return {} if sys.platform == "win32" else {"start_new_session": True}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _exe(name: str) -> str:
|
|
88
|
+
found = shutil.which(name)
|
|
89
|
+
if found is None:
|
|
90
|
+
raise ValueError(f"{name} not found on PATH")
|
|
91
|
+
return found
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _claude(sub: str | None, system: str, prompt: str, cwd: Path) -> tuple[list[str], str]:
|
|
95
|
+
(cwd / "system.md").write_text(system, encoding="utf-8")
|
|
96
|
+
argv = [_exe("claude"), "-p", "--output-format", "json",
|
|
97
|
+
"--system-prompt-file", str(cwd / "system.md"),
|
|
98
|
+
"--tools", "", "--strict-mcp-config", "--setting-sources", "project",
|
|
99
|
+
"--no-session-persistence"]
|
|
100
|
+
return argv + (["--model", sub] if sub else []), prompt
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
_CODEX_TOOLS = ("shell_tool", "unified_exec", "code_mode_host", "view_image", "browser_use",
|
|
104
|
+
"computer_use", "apps", "tool_suggest", "sleep_tool", "multi_agent", "goals",
|
|
105
|
+
"image_generation")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _codex(sub: str | None, system: str, prompt: str, cwd: Path) -> tuple[list[str], str]:
|
|
109
|
+
argv = [_exe("codex"), "exec", "--json", "--sandbox", "read-only",
|
|
110
|
+
"--skip-git-repo-check", "--ephemeral", "--ignore-user-config", "-C", str(cwd),
|
|
111
|
+
"-c", "web_search=disabled"]
|
|
112
|
+
for feature in _CODEX_TOOLS:
|
|
113
|
+
argv += ["--disable", feature]
|
|
114
|
+
return argv + (["-m", sub] if sub else []) + ["-"], f"{system}\n\n{prompt}"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _usage(**kw) -> UsageStats:
|
|
118
|
+
total = sum(kw.get(k, 0) for k in ("input", "output", "cache_read", "cache_write"))
|
|
119
|
+
return UsageStats(total_tokens=total, **kw)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def parse_claude(out: str) -> tuple[str, UsageStats]:
|
|
123
|
+
lines = [line for line in out.splitlines() if line.strip()]
|
|
124
|
+
if not lines:
|
|
125
|
+
raise ValueError("claude printed nothing")
|
|
126
|
+
data = json.loads(lines[-1])
|
|
127
|
+
if data.get("is_error") or data.get("subtype") != "success":
|
|
128
|
+
raise ValueError(f"claude failed: {data.get('result') or data.get('subtype')}")
|
|
129
|
+
u = data.get("usage", {})
|
|
130
|
+
usage = _usage(input=u.get("input_tokens", 0), output=u.get("output_tokens", 0),
|
|
131
|
+
cache_read=u.get("cache_read_input_tokens", 0),
|
|
132
|
+
cache_write=u.get("cache_creation_input_tokens", 0),
|
|
133
|
+
cost=CostBreakdown(total=float(data.get("total_cost_usd") or 0.0)))
|
|
134
|
+
return str(data.get("result", "")), usage
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def parse_codex(out: str) -> tuple[str, UsageStats]:
|
|
138
|
+
texts, usage = [], UsageStats()
|
|
139
|
+
for line in out.splitlines():
|
|
140
|
+
try:
|
|
141
|
+
event = json.loads(line)
|
|
142
|
+
except json.JSONDecodeError:
|
|
143
|
+
continue
|
|
144
|
+
kind = event.get("type")
|
|
145
|
+
if kind == "item.completed" and event.get("item", {}).get("type") == "agent_message":
|
|
146
|
+
texts.append(event["item"].get("text", ""))
|
|
147
|
+
elif kind == "turn.completed":
|
|
148
|
+
u = event.get("usage", {})
|
|
149
|
+
cached = u.get("cached_input_tokens", 0)
|
|
150
|
+
usage = _usage(input=u.get("input_tokens", 0) - cached, cache_read=cached,
|
|
151
|
+
output=u.get("output_tokens", 0))
|
|
152
|
+
elif kind in ("error", "turn.failed"):
|
|
153
|
+
message = event.get("message") or event.get("error", {}).get("message", "")
|
|
154
|
+
raise ValueError(f"codex failed: {message}")
|
|
155
|
+
if not texts:
|
|
156
|
+
raise ValueError("codex gave no answer")
|
|
157
|
+
return "\n\n".join(texts), usage
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
PRESETS = {"claude-code": (_claude, parse_claude), "codex": (_codex, parse_codex)}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
async def _run(argv: list[str], stdin: str, cwd: Path, signal: CancelToken | None) -> str:
|
|
164
|
+
proc = await asyncio.create_subprocess_exec(
|
|
165
|
+
*argv, cwd=str(cwd), stdin=asyncio.subprocess.PIPE,
|
|
166
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, **own_group(),
|
|
167
|
+
)
|
|
168
|
+
task = asyncio.ensure_future(proc.communicate(stdin.encode("utf-8")))
|
|
169
|
+
try:
|
|
170
|
+
while not task.done():
|
|
171
|
+
if signal is not None and signal.is_cancelled():
|
|
172
|
+
raise _Cancelled
|
|
173
|
+
await asyncio.wait({task}, timeout=0.2)
|
|
174
|
+
finally:
|
|
175
|
+
if not task.done():
|
|
176
|
+
kill_tree(proc)
|
|
177
|
+
task.cancel()
|
|
178
|
+
await proc.wait()
|
|
179
|
+
out, err = task.result()
|
|
180
|
+
if proc.returncode != 0 and not out.strip():
|
|
181
|
+
raise ValueError(f"{Path(argv[0]).stem} exited {proc.returncode}: {err.decode(errors='replace')[-500:]}")
|
|
182
|
+
return out.decode("utf-8", errors="replace")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _fault(model: str, reason: str, message: str) -> StreamFaultEvent:
|
|
186
|
+
entry = ModelEntry(model=model, provider="cli", api="cli", stop_reason=reason,
|
|
187
|
+
error_message=message)
|
|
188
|
+
return StreamFaultEvent(reason=reason, error=entry)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class ExternalAgentProvider:
|
|
192
|
+
"""Answers `cli:` models with an agent CLI; anything else goes to `fallback`."""
|
|
193
|
+
|
|
194
|
+
def __init__(self, fallback=None) -> None:
|
|
195
|
+
self.fallback = fallback
|
|
196
|
+
|
|
197
|
+
def __getattr__(self, name: str):
|
|
198
|
+
if self.fallback is None:
|
|
199
|
+
raise AttributeError(name)
|
|
200
|
+
return getattr(self.fallback, name)
|
|
201
|
+
|
|
202
|
+
def stream_response(self, *, model: str, system: str, messages: Sequence[TranscriptEntry],
|
|
203
|
+
tools: Sequence[ToolSpec], signal: CancelToken | None = None,
|
|
204
|
+
cache_key: str | None = None) -> AsyncIterator[WireEvent]:
|
|
205
|
+
if not is_external(model):
|
|
206
|
+
if self.fallback is None:
|
|
207
|
+
raise ValueError(f"the cli provider needs a cli: or acp: model, got {model!r}")
|
|
208
|
+
return self.fallback.stream_response(model=model, system=system, messages=messages,
|
|
209
|
+
tools=tools, signal=signal, cache_key=cache_key)
|
|
210
|
+
return self._answer(model, system, messages, tools, signal)
|
|
211
|
+
|
|
212
|
+
async def _ask(self, route, name, sub, system, conversation, signal) -> tuple[str, UsageStats]:
|
|
213
|
+
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp:
|
|
214
|
+
if route == "acp":
|
|
215
|
+
return await acp.answer(name, sub, f"{system}\n\n{conversation}", Path(tmp), signal)
|
|
216
|
+
build, parse = PRESETS[name]
|
|
217
|
+
argv, stdin = build(sub, system, conversation, Path(tmp))
|
|
218
|
+
return parse(await _run(argv, stdin, Path(tmp), signal))
|
|
219
|
+
|
|
220
|
+
async def _answer(self, model, system, messages, tools, signal) -> AsyncIterator[WireEvent]:
|
|
221
|
+
coding = any(t.name == "python" for t in tools)
|
|
222
|
+
try:
|
|
223
|
+
route, name, sub = parse_model(model)
|
|
224
|
+
system = f"{system}\n\n{PROTOCOL}" if coding else system
|
|
225
|
+
conversation = render(messages)
|
|
226
|
+
text, usage = await self._ask(route, name, sub, system, conversation, signal)
|
|
227
|
+
if coding and parse_reply(text)[1] is None and FAKE_CALL.search(text):
|
|
228
|
+
conversation += f"\n\n[assistant]\n{text}\n\n[user]\n{CORRECTION}"
|
|
229
|
+
text, more = await self._ask(route, name, sub, system, conversation, signal)
|
|
230
|
+
usage = _add(usage, more)
|
|
231
|
+
except _Cancelled:
|
|
232
|
+
yield _fault(model, "aborted", "cancelled")
|
|
233
|
+
return
|
|
234
|
+
except (ValueError, OSError, json.JSONDecodeError) as exc:
|
|
235
|
+
yield _fault(model, "error", str(exc))
|
|
236
|
+
return
|
|
237
|
+
said, code = parse_reply(text) if coding else (text.strip(), None)
|
|
238
|
+
content: list = [TextSegment(text=said)] if said else []
|
|
239
|
+
stop = "stop"
|
|
240
|
+
if code is not None:
|
|
241
|
+
call = CallBlock(name="python", id=f"call_{uuid.uuid4().hex[:12]}", arguments={"code": code})
|
|
242
|
+
content.append(call)
|
|
243
|
+
stop = "toolUse"
|
|
244
|
+
message = ModelEntry(content=content, model=model, provider="cli", api="cli",
|
|
245
|
+
stop_reason=stop, usage=usage)
|
|
246
|
+
if code is not None:
|
|
247
|
+
yield CallCloseEvent(content_index=len(content) - 1, tool_call=call, partial=message)
|
|
248
|
+
yield StreamCloseEvent(reason=stop, message=message)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""The python tool through plain text, for agents that cannot call our tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
|
|
8
|
+
from rlmagent.harness.contracts.transcript import TranscriptEntry, gather_text
|
|
9
|
+
|
|
10
|
+
PROTOCOL = """## Running code
|
|
11
|
+
You have no tools of your own here; ignore any you think you have. Running a python block is how you use the python tool: the host runs it in a persistent kernel that has the project files and every function named above. To run code, reply with one block:
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
print("hello")
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Its output comes back in the next message. Put at most one block in a reply; text before it \
|
|
18
|
+
is shown to the user. A reply with no block is your final answer."""
|
|
19
|
+
|
|
20
|
+
_BLOCK = re.compile(r"```(?:python|py|repl)[ \t]*\r?\n(.*?)```", re.S)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse_reply(text: str) -> tuple[str, str | None]:
|
|
24
|
+
"""Split a reply into the text before its first python block and the block's code."""
|
|
25
|
+
match = _BLOCK.search(text)
|
|
26
|
+
if match is None:
|
|
27
|
+
return text.strip(), None
|
|
28
|
+
return text[: match.start()].strip(), match.group(1)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _assistant(entry) -> str:
|
|
32
|
+
parts = []
|
|
33
|
+
for block in entry.content:
|
|
34
|
+
if block.type == "text":
|
|
35
|
+
parts.append(block.text)
|
|
36
|
+
elif block.type == "toolCall":
|
|
37
|
+
parts.append(f"```python\n{block.arguments.get('code', '')}\n```")
|
|
38
|
+
return "\n".join(parts)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def render(messages: Sequence[TranscriptEntry]) -> str:
|
|
42
|
+
"""The conversation as one prompt, oldest first."""
|
|
43
|
+
parts = []
|
|
44
|
+
for entry in messages:
|
|
45
|
+
role = getattr(entry, "role", "")
|
|
46
|
+
if role == "user":
|
|
47
|
+
parts.append(f"[user]\n{entry.text}")
|
|
48
|
+
elif role == "assistant":
|
|
49
|
+
parts.append(f"[assistant]\n{_assistant(entry)}")
|
|
50
|
+
elif role == "toolResult":
|
|
51
|
+
label = "output" if not entry.is_error else "error"
|
|
52
|
+
parts.append(f"[{entry.tool_name} {label}]\n{gather_text(entry.content)}")
|
|
53
|
+
elif role == "custom":
|
|
54
|
+
parts.append(f"[note]\n{entry.text}")
|
|
55
|
+
elif role in ("compactionSummary", "branchSummary"):
|
|
56
|
+
parts.append(f"[summary of earlier conversation]\n{entry.summary}")
|
|
57
|
+
return "\n\n".join(parts)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Fall back to other provider/model pairs when the main one fails outright.
|
|
2
|
+
|
|
3
|
+
A request moves to the next pair only when it failed before producing any
|
|
4
|
+
output. Once text, reasoning or a tool call has streamed, the reply belongs
|
|
5
|
+
to that provider and its failure is reported as it is: replaying a half-sent
|
|
6
|
+
reply on another model would show the user two answers.
|
|
7
|
+
|
|
8
|
+
Pairs come from ``RLM_AGENT_FALLBACK`` as ``provider:model`` items separated
|
|
9
|
+
by commas, for example ``openrouter:qwen/qwen3-coder,anthropic:claude-sonnet-4-5``,
|
|
10
|
+
or from ``fallback`` in a trusted project's ``.rlm-agent/settings.toml``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
from collections.abc import AsyncIterator, Callable, Sequence
|
|
17
|
+
|
|
18
|
+
from rlmagent.harness.contracts.tooling import CancelToken, ToolSpec
|
|
19
|
+
from rlmagent.harness.contracts.transcript import TranscriptEntry
|
|
20
|
+
from rlmagent.harness.provider.base import ModelProvider
|
|
21
|
+
from rlmagent.harness.provider.wire import (
|
|
22
|
+
SourceRetryEvent,
|
|
23
|
+
StreamFaultEvent,
|
|
24
|
+
StreamOpenEvent,
|
|
25
|
+
WireEvent,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
FALLBACK_ENV = "RLM_AGENT_FALLBACK"
|
|
29
|
+
|
|
30
|
+
# Events that carry no output: a request that failed after only these can be
|
|
31
|
+
# sent elsewhere without the user having seen anything from it.
|
|
32
|
+
_SILENT = (StreamOpenEvent, SourceRetryEvent)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_fallbacks(text: str | None) -> list[tuple[str, str]]:
|
|
36
|
+
"""``"a:m1, b:m2"`` -> ``[("a", "m1"), ("b", "m2")]``; bad items are skipped."""
|
|
37
|
+
pairs: list[tuple[str, str]] = []
|
|
38
|
+
for item in (text or "").split(","):
|
|
39
|
+
name, sep, model = item.strip().partition(":")
|
|
40
|
+
if sep and name.strip() and model.strip():
|
|
41
|
+
pairs.append((name.strip().lower(), model.strip()))
|
|
42
|
+
return pairs
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class FailoverProvider:
|
|
46
|
+
"""Wraps a provider; tries each fallback pair after an outright failure."""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
primary: ModelProvider,
|
|
51
|
+
fallbacks: Sequence[tuple[str, str]],
|
|
52
|
+
resolve: Callable[[str], ModelProvider],
|
|
53
|
+
) -> None:
|
|
54
|
+
self.primary = primary
|
|
55
|
+
self.fallbacks = list(fallbacks)
|
|
56
|
+
self._resolve = resolve
|
|
57
|
+
self._built: dict[str, ModelProvider] = {}
|
|
58
|
+
self.last_route: tuple[str, str] | None = None
|
|
59
|
+
|
|
60
|
+
def __getattr__(self, name: str):
|
|
61
|
+
# Anything else (service tiers, shutdown hooks…) is the primary's.
|
|
62
|
+
return getattr(self.primary, name)
|
|
63
|
+
|
|
64
|
+
def _provider(self, name: str) -> ModelProvider | None:
|
|
65
|
+
if name not in self._built:
|
|
66
|
+
try:
|
|
67
|
+
self._built[name] = self._resolve(name)
|
|
68
|
+
except Exception: # a fallback without credentials is skipped
|
|
69
|
+
return None
|
|
70
|
+
return self._built[name]
|
|
71
|
+
|
|
72
|
+
async def stream_response(
|
|
73
|
+
self,
|
|
74
|
+
*,
|
|
75
|
+
model: str,
|
|
76
|
+
system: str,
|
|
77
|
+
messages: Sequence[TranscriptEntry],
|
|
78
|
+
tools: Sequence[ToolSpec],
|
|
79
|
+
signal: CancelToken | None = None,
|
|
80
|
+
cache_key: str | None = None,
|
|
81
|
+
) -> AsyncIterator[WireEvent]:
|
|
82
|
+
routes: list[tuple[str | None, str]] = [(None, model), *self.fallbacks]
|
|
83
|
+
for index, (name, route_model) in enumerate(routes):
|
|
84
|
+
provider = self.primary if name is None else self._provider(name)
|
|
85
|
+
if provider is None:
|
|
86
|
+
continue
|
|
87
|
+
last = index == len(routes) - 1
|
|
88
|
+
held: list[WireEvent] = []
|
|
89
|
+
spoke = False
|
|
90
|
+
failed = False
|
|
91
|
+
async for event in provider.stream_response(
|
|
92
|
+
model=route_model, system=system, messages=messages, tools=tools,
|
|
93
|
+
signal=signal, cache_key=cache_key,
|
|
94
|
+
):
|
|
95
|
+
if spoke:
|
|
96
|
+
yield event
|
|
97
|
+
continue
|
|
98
|
+
if isinstance(event, _SILENT):
|
|
99
|
+
held.append(event)
|
|
100
|
+
continue
|
|
101
|
+
if (isinstance(event, StreamFaultEvent) and event.reason == "error"
|
|
102
|
+
and not last and not (signal and signal.is_cancelled())):
|
|
103
|
+
failed = True
|
|
104
|
+
break
|
|
105
|
+
spoke = True
|
|
106
|
+
self.last_route = (name or "", route_model)
|
|
107
|
+
for early in held:
|
|
108
|
+
yield early
|
|
109
|
+
yield event
|
|
110
|
+
if not failed:
|
|
111
|
+
if not spoke:
|
|
112
|
+
for early in held:
|
|
113
|
+
yield early
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def with_fallbacks(
|
|
118
|
+
primary: ModelProvider, resolve: Callable[[str], ModelProvider],
|
|
119
|
+
env: dict[str, str] | None = None,
|
|
120
|
+
) -> ModelProvider:
|
|
121
|
+
"""Wrap *primary* when fallbacks are configured; otherwise return it as is."""
|
|
122
|
+
source = env if env is not None else os.environ
|
|
123
|
+
text = source.get(FALLBACK_ENV)
|
|
124
|
+
if text is None and env is None:
|
|
125
|
+
from rlmagent.config.loader import project_settings
|
|
126
|
+
|
|
127
|
+
text = project_settings().get("fallback")
|
|
128
|
+
pairs = parse_fallbacks(text)
|
|
129
|
+
return FailoverProvider(primary, pairs, resolve) if pairs else primary
|
rlmagent/model/google.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Gemini content streaming through the native generate-content endpoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from rlmagent.harness.contracts.transcript import ModelEntry
|
|
13
|
+
from rlmagent.harness.provider.wire import StreamFaultEvent
|
|
14
|
+
from rlmagent.model._oai.normalize import EventAssembler
|
|
15
|
+
from rlmagent.model._oai.parsers import (
|
|
16
|
+
CallBegin,
|
|
17
|
+
CallResolved,
|
|
18
|
+
Finished,
|
|
19
|
+
Metadata,
|
|
20
|
+
ReasoningChunk,
|
|
21
|
+
TextChunk,
|
|
22
|
+
)
|
|
23
|
+
from rlmagent.model._oai.transport import HttpStreamError, open_event_stream
|
|
24
|
+
from rlmagent.model.settings import ConfigError, Credential
|
|
25
|
+
from rlmagent.model.transport.client import build_async_client
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def content(messages) -> list[dict]:
|
|
29
|
+
result = []
|
|
30
|
+
for entry in messages:
|
|
31
|
+
role = getattr(entry, "role", "")
|
|
32
|
+
if role == "toolResult":
|
|
33
|
+
parts = [
|
|
34
|
+
{"functionResponse": {"name": entry.tool_name, "response": {"result": entry.text}}}
|
|
35
|
+
]
|
|
36
|
+
sender = "user"
|
|
37
|
+
elif role in ("user", "assistant"):
|
|
38
|
+
sender = "model" if role == "assistant" else "user"
|
|
39
|
+
blocks = entry.content
|
|
40
|
+
parts = [{"text": blocks}] if isinstance(blocks, str) else []
|
|
41
|
+
if not isinstance(blocks, str):
|
|
42
|
+
for block in blocks:
|
|
43
|
+
if block.type == "text":
|
|
44
|
+
parts.append({"text": block.text})
|
|
45
|
+
elif block.type == "image":
|
|
46
|
+
parts.append(
|
|
47
|
+
{"inlineData": {"mimeType": block.mime_type, "data": block.data}}
|
|
48
|
+
)
|
|
49
|
+
elif block.type == "toolCall":
|
|
50
|
+
call = {"functionCall": {"name": block.name, "args": block.arguments}}
|
|
51
|
+
if block.thought_signature:
|
|
52
|
+
call["thoughtSignature"] = block.thought_signature
|
|
53
|
+
parts.append(call)
|
|
54
|
+
elif hasattr(entry, "summary"):
|
|
55
|
+
sender, parts = "user", [{"text": entry.summary}]
|
|
56
|
+
else:
|
|
57
|
+
continue
|
|
58
|
+
if parts:
|
|
59
|
+
if result and result[-1]["role"] == sender:
|
|
60
|
+
result[-1]["parts"].extend(parts)
|
|
61
|
+
else:
|
|
62
|
+
result.append({"role": sender, "parts": parts})
|
|
63
|
+
return result
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class GoogleProvider:
|
|
67
|
+
def __init__(self, credential: Credential):
|
|
68
|
+
self.credential = credential
|
|
69
|
+
self._client = build_async_client(timeout=httpx.Timeout(120))
|
|
70
|
+
|
|
71
|
+
async def close(self):
|
|
72
|
+
await self._client.aclose()
|
|
73
|
+
|
|
74
|
+
async def stream_response(self, *, model, system, messages, tools, signal=None, cache_key=None):
|
|
75
|
+
name = model.removeprefix("models/")
|
|
76
|
+
payload = {
|
|
77
|
+
"contents": content(messages),
|
|
78
|
+
"systemInstruction": {"parts": [{"text": system}]},
|
|
79
|
+
}
|
|
80
|
+
if tools:
|
|
81
|
+
payload["tools"] = [
|
|
82
|
+
{
|
|
83
|
+
"functionDeclarations": [
|
|
84
|
+
{
|
|
85
|
+
"name": tool.name,
|
|
86
|
+
"description": tool.description,
|
|
87
|
+
"parameters": tool.parameters,
|
|
88
|
+
}
|
|
89
|
+
for tool in tools
|
|
90
|
+
]
|
|
91
|
+
}
|
|
92
|
+
]
|
|
93
|
+
url = (
|
|
94
|
+
self.credential.base_url.rstrip("/")
|
|
95
|
+
+ f"/models/{quote(name, safe='')}:streamGenerateContent?alt=sse"
|
|
96
|
+
)
|
|
97
|
+
headers = {
|
|
98
|
+
"x-goog-api-key": self.credential.api_key,
|
|
99
|
+
"Content-Type": "application/json",
|
|
100
|
+
**dict(self.credential.extra_headers),
|
|
101
|
+
}
|
|
102
|
+
assembler = EventAssembler(model=model, provider="google", api="google-generate")
|
|
103
|
+
signatures = {}
|
|
104
|
+
usage, finish, index = {}, "stop", 0
|
|
105
|
+
completed = False
|
|
106
|
+
try:
|
|
107
|
+
async for event in open_event_stream(
|
|
108
|
+
self._client, url, payload, headers, signal=signal
|
|
109
|
+
):
|
|
110
|
+
value = json.loads(event.data)
|
|
111
|
+
signals = []
|
|
112
|
+
if value.get("error") or value.get("promptFeedback", {}).get("blockReason"):
|
|
113
|
+
raise ValueError("Google declined the content request")
|
|
114
|
+
if value.get("responseId"):
|
|
115
|
+
signals.append(Metadata(value["responseId"], value.get("modelVersion")))
|
|
116
|
+
for candidate in value.get("candidates", [])[:1]:
|
|
117
|
+
for part in candidate.get("content", {}).get("parts", []):
|
|
118
|
+
if "text" in part:
|
|
119
|
+
signals.append(
|
|
120
|
+
ReasoningChunk(part["text"])
|
|
121
|
+
if part.get("thought")
|
|
122
|
+
else TextChunk(part["text"])
|
|
123
|
+
)
|
|
124
|
+
if "functionCall" in part:
|
|
125
|
+
call = part["functionCall"]
|
|
126
|
+
identity = call.get("id") or uuid4().hex
|
|
127
|
+
signatures[identity] = part.get("thoughtSignature")
|
|
128
|
+
signals.extend(
|
|
129
|
+
[
|
|
130
|
+
CallBegin(index, identity, call["name"]),
|
|
131
|
+
CallResolved(
|
|
132
|
+
index,
|
|
133
|
+
identity,
|
|
134
|
+
call["name"],
|
|
135
|
+
json.dumps(call.get("args", {})),
|
|
136
|
+
),
|
|
137
|
+
]
|
|
138
|
+
)
|
|
139
|
+
index += 1
|
|
140
|
+
finish = "tool_calls"
|
|
141
|
+
reason = candidate.get("finishReason")
|
|
142
|
+
completed = completed or reason is not None
|
|
143
|
+
if reason == "MAX_TOKENS":
|
|
144
|
+
finish = "length"
|
|
145
|
+
elif reason not in (None, "STOP", "MAX_TOKENS"):
|
|
146
|
+
raise ValueError(f"Google generation ended with {reason}")
|
|
147
|
+
if "usageMetadata" in value:
|
|
148
|
+
reported = value["usageMetadata"]
|
|
149
|
+
usage = {
|
|
150
|
+
"prompt_tokens": reported.get("promptTokenCount", 0),
|
|
151
|
+
"completion_tokens": reported.get("candidatesTokenCount", 0)
|
|
152
|
+
+ reported.get("thoughtsTokenCount", 0),
|
|
153
|
+
"total_tokens": reported.get("totalTokenCount", 0),
|
|
154
|
+
"prompt_tokens_details": {
|
|
155
|
+
"cached_tokens": reported.get("cachedContentTokenCount", 0)
|
|
156
|
+
},
|
|
157
|
+
"completion_tokens_details": {
|
|
158
|
+
"reasoning_tokens": reported.get("thoughtsTokenCount", 0)
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
for parsed in signals:
|
|
162
|
+
for output in assembler.accept(parsed):
|
|
163
|
+
yield output
|
|
164
|
+
if signal is not None and signal.is_cancelled():
|
|
165
|
+
yield StreamFaultEvent(
|
|
166
|
+
reason="aborted", error=ModelEntry(model=model, stop_reason="aborted")
|
|
167
|
+
)
|
|
168
|
+
return
|
|
169
|
+
if not completed:
|
|
170
|
+
raise ValueError("Google stream ended before a terminal candidate")
|
|
171
|
+
assembler.accept(Finished(finish, usage))
|
|
172
|
+
for output in assembler.finalize():
|
|
173
|
+
if hasattr(output, "message"):
|
|
174
|
+
for call in output.message.tool_calls:
|
|
175
|
+
call.thought_signature = signatures.get(call.id)
|
|
176
|
+
yield output
|
|
177
|
+
except (httpx.HTTPError, HttpStreamError, ValueError, KeyError, TypeError) as exc:
|
|
178
|
+
yield StreamFaultEvent(
|
|
179
|
+
reason="error",
|
|
180
|
+
error=ModelEntry(
|
|
181
|
+
model=model,
|
|
182
|
+
stop_reason="error",
|
|
183
|
+
error_message=f"Google request failed ({type(exc).__name__})",
|
|
184
|
+
),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def build_google():
|
|
189
|
+
from rlmagent.config.loader import load_api_key
|
|
190
|
+
|
|
191
|
+
key = load_api_key("google")
|
|
192
|
+
if not key:
|
|
193
|
+
raise ConfigError("GOOGLE_API_KEY is not set")
|
|
194
|
+
return GoogleProvider(
|
|
195
|
+
Credential(
|
|
196
|
+
key,
|
|
197
|
+
os.environ.get("GOOGLE_API_BASE", "https://generativelanguage.googleapis.com/v1beta"),
|
|
198
|
+
)
|
|
199
|
+
)
|
rlmagent/model/limits.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Runtime model limits — immutable capability data and provider discovery contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Protocol, runtime_checkable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LimitsError(ValueError):
|
|
10
|
+
"""Raised when model-limit values are invalid or inconsistent."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class ModelLimits:
|
|
15
|
+
"""Immutable, provider-supplied token-budget constraints for a model."""
|
|
16
|
+
|
|
17
|
+
context_window: int
|
|
18
|
+
"""Total context-window size in tokens (input + output)."""
|
|
19
|
+
|
|
20
|
+
max_output: int
|
|
21
|
+
"""Maximum tokens the model can produce in a single response."""
|
|
22
|
+
|
|
23
|
+
def __post_init__(self) -> None:
|
|
24
|
+
if self.context_window < 1:
|
|
25
|
+
raise LimitsError(f"context_window must be positive, got {self.context_window}")
|
|
26
|
+
if self.max_output < 1:
|
|
27
|
+
raise LimitsError(f"max_output must be positive, got {self.max_output}")
|
|
28
|
+
if self.max_output >= self.context_window:
|
|
29
|
+
raise LimitsError(
|
|
30
|
+
f"max_output ({self.max_output:,}) must be less than "
|
|
31
|
+
f"context_window ({self.context_window:,})"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def usable_context(self) -> int:
|
|
36
|
+
"""Effective input budget: context window minus output reservation."""
|
|
37
|
+
return self.context_window - self.max_output
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@runtime_checkable
|
|
41
|
+
class LimitsProbe(Protocol):
|
|
42
|
+
"""Optional capability a provider implements to report model limits."""
|
|
43
|
+
|
|
44
|
+
async def query_limits(self, model: str) -> ModelLimits:
|
|
45
|
+
"""Return the runtime limits for *model*."""
|
|
46
|
+
...
|