rlmness-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rlmagent/__init__.py +1 -0
- rlmagent/__main__.py +8 -0
- rlmagent/acp/__init__.py +1 -0
- rlmagent/acp/runtime.py +71 -0
- rlmagent/acp/server.py +598 -0
- rlmagent/acp/updates.py +128 -0
- rlmagent/agentnet.py +237 -0
- rlmagent/agents/__init__.py +1 -0
- rlmagent/agents/approval.py +90 -0
- rlmagent/agents/bridge.py +109 -0
- rlmagent/agents/budget.py +212 -0
- rlmagent/agents/child_handles.py +46 -0
- rlmagent/agents/child_mailbox.py +30 -0
- rlmagent/agents/kernel_api.py +156 -0
- rlmagent/agents/output.py +34 -0
- rlmagent/agents/patch.py +107 -0
- rlmagent/agents/prompts.py +156 -0
- rlmagent/agents/trace.py +290 -0
- rlmagent/agents/tree.py +682 -0
- rlmagent/agents/wasm_api.py +105 -0
- rlmagent/agents/workspace.py +357 -0
- rlmagent/autopilot.py +447 -0
- rlmagent/backend.py +946 -0
- rlmagent/backend_context.py +74 -0
- rlmagent/cli/__init__.py +5 -0
- rlmagent/cli/config_cmd.py +177 -0
- rlmagent/cli/health.py +156 -0
- rlmagent/cli/main.py +1194 -0
- rlmagent/cli/update.py +188 -0
- rlmagent/clipboard.py +63 -0
- rlmagent/config/__init__.py +37 -0
- rlmagent/config/config.py +297 -0
- rlmagent/config/loader.py +119 -0
- rlmagent/config/prompts.py +124 -0
- rlmagent/config/secure_input.py +213 -0
- rlmagent/config/store.py +275 -0
- rlmagent/context/__init__.py +47 -0
- rlmagent/context/budget.py +557 -0
- rlmagent/contract.py +311 -0
- rlmagent/contract_run.py +232 -0
- rlmagent/conversation.py +2547 -0
- rlmagent/cron.py +77 -0
- rlmagent/directives.py +893 -0
- rlmagent/discovery.py +356 -0
- rlmagent/execution_log.py +66 -0
- rlmagent/factory.py +189 -0
- rlmagent/factory_graph.py +413 -0
- rlmagent/harness/__init__.py +63 -0
- rlmagent/harness/contracts/__init__.py +1 -0
- rlmagent/harness/contracts/stream.py +129 -0
- rlmagent/harness/contracts/tooling.py +127 -0
- rlmagent/harness/contracts/transcript/__init__.py +68 -0
- rlmagent/harness/contracts/transcript/_base.py +29 -0
- rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
- rlmagent/harness/contracts/transcript/entries.py +180 -0
- rlmagent/harness/contracts/transcript/segments.py +64 -0
- rlmagent/harness/contracts/values.py +6 -0
- rlmagent/harness/driver.py +397 -0
- rlmagent/harness/engine.py +489 -0
- rlmagent/harness/mending.py +153 -0
- rlmagent/harness/provider/__init__.py +1 -0
- rlmagent/harness/provider/base.py +27 -0
- rlmagent/harness/provider/wire.py +189 -0
- rlmagent/harness/py.typed +0 -0
- rlmagent/harness/session/__init__.py +5 -0
- rlmagent/harness/session/index.py +218 -0
- rlmagent/harness/session/lease.py +66 -0
- rlmagent/harness/session/records.py +118 -0
- rlmagent/harness/session/replay.py +165 -0
- rlmagent/harness/session/store.py +155 -0
- rlmagent/harness/session/summary.py +46 -0
- rlmagent/hints.py +47 -0
- rlmagent/history.py +92 -0
- rlmagent/hooks/__init__.py +1 -0
- rlmagent/hooks/hooks.py +1 -0
- rlmagent/instructions.py +157 -0
- rlmagent/job_runner.py +117 -0
- rlmagent/jobs.py +365 -0
- rlmagent/kept_agents.py +397 -0
- rlmagent/kernel/__init__.py +5 -0
- rlmagent/kernel/session.py +205 -0
- rlmagent/kernel/wasm.py +169 -0
- rlmagent/logs.py +208 -0
- rlmagent/managed_release.py +368 -0
- rlmagent/mcp.py +411 -0
- rlmagent/mcp_oauth.py +213 -0
- rlmagent/model/__init__.py +29 -0
- rlmagent/model/_claude/__init__.py +1 -0
- rlmagent/model/_claude/courier.py +117 -0
- rlmagent/model/_claude/emitter.py +378 -0
- rlmagent/model/_oai/__init__.py +1 -0
- rlmagent/model/_oai/helpers.py +256 -0
- rlmagent/model/_oai/normalize.py +263 -0
- rlmagent/model/_oai/parsers.py +317 -0
- rlmagent/model/_oai/payloads.py +226 -0
- rlmagent/model/_oai/transport.py +127 -0
- rlmagent/model/claude.py +552 -0
- rlmagent/model/copilot.py +142 -0
- rlmagent/model/correlation.py +21 -0
- rlmagent/model/deadline.py +19 -0
- rlmagent/model/discovery.py +181 -0
- rlmagent/model/external/__init__.py +11 -0
- rlmagent/model/external/acp.py +202 -0
- rlmagent/model/external/acp_install.py +109 -0
- rlmagent/model/external/cli.py +248 -0
- rlmagent/model/external/textcalls.py +57 -0
- rlmagent/model/failover.py +129 -0
- rlmagent/model/google.py +199 -0
- rlmagent/model/limits.py +46 -0
- rlmagent/model/oai_compatible.py +414 -0
- rlmagent/model/preferences.py +56 -0
- rlmagent/model/py.typed +0 -0
- rlmagent/model/registered_oauth.py +195 -0
- rlmagent/model/scripted.py +55 -0
- rlmagent/model/settings.py +271 -0
- rlmagent/model/subscription.py +398 -0
- rlmagent/model/token_bounds.py +15 -0
- rlmagent/model/transport/__init__.py +1 -0
- rlmagent/model/transport/backoff.py +87 -0
- rlmagent/model/transport/client.py +87 -0
- rlmagent/model/transport/faults.py +65 -0
- rlmagent/model/transport/responses_socket.py +223 -0
- rlmagent/model/vertex.py +93 -0
- rlmagent/network.py +123 -0
- rlmagent/packages.py +246 -0
- rlmagent/planning.py +84 -0
- rlmagent/playbook/__init__.py +1 -0
- rlmagent/playbook/apply.py +106 -0
- rlmagent/playbook/digest.py +63 -0
- rlmagent/playbook/improver.py +272 -0
- rlmagent/playbook/plan.py +99 -0
- rlmagent/playbook/store.py +67 -0
- rlmagent/plugins.py +327 -0
- rlmagent/policy.py +105 -0
- rlmagent/prompt_refs.py +57 -0
- rlmagent/prompts.py +331 -0
- rlmagent/prune.py +60 -0
- rlmagent/py.typed +0 -0
- rlmagent/reasoning.py +135 -0
- rlmagent/refinement/__init__.py +1 -0
- rlmagent/refinement/controller.py +448 -0
- rlmagent/refinement/engine.py +220 -0
- rlmagent/refinement/evidence.py +131 -0
- rlmagent/refinement/scoped.py +90 -0
- rlmagent/refinement/store.py +193 -0
- rlmagent/refresh.py +202 -0
- rlmagent/routing.py +52 -0
- rlmagent/runtime.py +575 -0
- rlmagent/safety.py +267 -0
- rlmagent/session_html.py +57 -0
- rlmagent/session_import.py +52 -0
- rlmagent/session_share.py +55 -0
- rlmagent/sessions.py +249 -0
- rlmagent/shell_sandbox.py +88 -0
- rlmagent/skillset.py +320 -0
- rlmagent/steering.py +148 -0
- rlmagent/stops.py +62 -0
- rlmagent/subscriptions.py +219 -0
- rlmagent/tasks.py +98 -0
- rlmagent/toolhost.py +310 -0
- rlmagent/tools/__init__.py +56 -0
- rlmagent/tools/_shared.py +210 -0
- rlmagent/tools/files.py +462 -0
- rlmagent/tools/kernel.py +93 -0
- rlmagent/tools/patch_tool.py +68 -0
- rlmagent/tools/search.py +188 -0
- rlmagent/trust.py +383 -0
- rlmagent/tui/__init__.py +1 -0
- rlmagent/tui/adapter.py +506 -0
- rlmagent/tui/app.py +711 -0
- rlmagent/tui/approval_screen.py +57 -0
- rlmagent/tui/choice_screen.py +47 -0
- rlmagent/tui/completions.py +116 -0
- rlmagent/tui/components.py +25 -0
- rlmagent/tui/composer.py +40 -0
- rlmagent/tui/factory_view.py +86 -0
- rlmagent/tui/inspect.py +193 -0
- rlmagent/tui/messages.py +215 -0
- rlmagent/tui/model_screen.py +307 -0
- rlmagent/tui/prompt.py +98 -0
- rlmagent/tui/question_screen.py +52 -0
- rlmagent/tui/replay.py +141 -0
- rlmagent/tui/rlm_agent.tcss +405 -0
- rlmagent/tui/search_box.py +91 -0
- rlmagent/tui/sidebar.py +182 -0
- rlmagent/tui/status.py +49 -0
- rlmagent/tui/steps.py +283 -0
- rlmagent/tui/suggest.py +778 -0
- rlmagent/tui/transcript.py +172 -0
- rlmagent/tui/trust_dialog.py +58 -0
- rlmagent/tui/worker_attach.py +141 -0
- rlmagent/ui/__init__.py +1 -0
- rlmagent/ui/render.py +211 -0
- rlmagent/undo.py +109 -0
- rlmagent/utils/__init__.py +1 -0
- rlmagent/websearch.py +75 -0
- rlmagent/worker.py +1275 -0
- rlmagent/worker_service.py +203 -0
- rlmness_cli-0.2.0.dist-info/METADATA +882 -0
- rlmness_cli-0.2.0.dist-info/RECORD +203 -0
- rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
- rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
- rlmness_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
rlmagent/contract.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""A run contract: every setting for a run in one JSON document, nothing implied."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import copy
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from urllib.parse import urlsplit
|
|
13
|
+
|
|
14
|
+
FORMAT = "rlmagent.run/1"
|
|
15
|
+
BUILTIN_TOOLS = ("python", "Read", "Write", "Edit")
|
|
16
|
+
_BASE_URLS = {
|
|
17
|
+
"anthropic": "https://api.anthropic.com",
|
|
18
|
+
"openai": "https://api.openai.com/v1",
|
|
19
|
+
"openrouter": "https://openrouter.ai/api/v1",
|
|
20
|
+
"ollama": "http://localhost:11434/v1",
|
|
21
|
+
"google": "https://generativelanguage.googleapis.com/v1beta",
|
|
22
|
+
"mistral": "https://api.mistral.ai/v1",
|
|
23
|
+
"azure": "",
|
|
24
|
+
"xai": "https://api.x.ai/v1",
|
|
25
|
+
"codex": "https://api.openai.com/v1",
|
|
26
|
+
"claude-subscription": "",
|
|
27
|
+
}
|
|
28
|
+
ACCOUNT_PROVIDERS = frozenset({"codex", "claude-subscription"})
|
|
29
|
+
_KEYLESS = {"ollama"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ContractError(ValueError):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ModelSpec:
|
|
38
|
+
provider: str
|
|
39
|
+
name: str
|
|
40
|
+
api_key_env: str | None
|
|
41
|
+
base_url: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Limits:
|
|
46
|
+
max_depth: int
|
|
47
|
+
max_calls: int
|
|
48
|
+
max_cost: float
|
|
49
|
+
max_live: int
|
|
50
|
+
max_agents: int
|
|
51
|
+
max_seconds: float | None
|
|
52
|
+
cell_timeout: float
|
|
53
|
+
request_timeout: float = 300.0
|
|
54
|
+
max_new_tokens: int | None = None
|
|
55
|
+
strict_new_tokens: bool = False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class PromptSpec:
|
|
60
|
+
system_file: str | None
|
|
61
|
+
append: str | None
|
|
62
|
+
append_sub_agent: str | None
|
|
63
|
+
append_leaf: str | None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class Sessions:
|
|
68
|
+
save: bool
|
|
69
|
+
dir: str | None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class Trace:
|
|
74
|
+
file: str
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class KernelSpec:
|
|
79
|
+
kind: str = "local"
|
|
80
|
+
approve: str = "deny"
|
|
81
|
+
network: bool = False
|
|
82
|
+
restore: bool = False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class Contract:
|
|
87
|
+
model: ModelSpec
|
|
88
|
+
limits: Limits
|
|
89
|
+
prompt: PromptSpec
|
|
90
|
+
tools: tuple[str, ...]
|
|
91
|
+
workdir: str
|
|
92
|
+
sessions: Sessions
|
|
93
|
+
trace: Trace
|
|
94
|
+
raw: dict
|
|
95
|
+
kernel: KernelSpec = KernelSpec()
|
|
96
|
+
playbook: dict | None = None
|
|
97
|
+
engine: str = "session"
|
|
98
|
+
policy: dict | None = None
|
|
99
|
+
|
|
100
|
+
def api_key(self) -> str | None:
|
|
101
|
+
if self.model.provider in ACCOUNT_PROVIDERS:
|
|
102
|
+
return None
|
|
103
|
+
if self.model.provider in _KEYLESS and self.model.api_key_env is None:
|
|
104
|
+
return self.model.provider
|
|
105
|
+
name = self.model.api_key_env
|
|
106
|
+
value = os.environ.get(name or "")
|
|
107
|
+
if not value:
|
|
108
|
+
raise ContractError(f"model.api_key_env: environment variable {name} is not set")
|
|
109
|
+
return value
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _fields(doc: object, where: str, spec: dict) -> dict:
|
|
113
|
+
if not isinstance(doc, dict):
|
|
114
|
+
raise ContractError(f"{where or 'contract'}: expected an object")
|
|
115
|
+
for key in doc:
|
|
116
|
+
if key not in spec:
|
|
117
|
+
raise ContractError(f"{_join(where, key)}: unknown field")
|
|
118
|
+
out = {}
|
|
119
|
+
for key, kinds in spec.items():
|
|
120
|
+
path = _join(where, key)
|
|
121
|
+
if key not in doc:
|
|
122
|
+
raise ContractError(f"{path}: missing (write null if it is not set)")
|
|
123
|
+
out[key] = _check(doc[key], kinds, path)
|
|
124
|
+
return out
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _check(value, kinds, path):
|
|
128
|
+
if isinstance(value, bool) and bool not in kinds:
|
|
129
|
+
raise ContractError(f"{path}: expected {_names(kinds)}, got a boolean")
|
|
130
|
+
if value is None and type(None) in kinds:
|
|
131
|
+
return None
|
|
132
|
+
if isinstance(value, int) and not isinstance(value, bool) and float in kinds:
|
|
133
|
+
return float(value)
|
|
134
|
+
if not isinstance(value, tuple(k for k in kinds if k is not type(None))):
|
|
135
|
+
raise ContractError(f"{path}: expected {_names(kinds)}, got {type(value).__name__}")
|
|
136
|
+
return value
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _names(kinds) -> str:
|
|
140
|
+
return " or ".join("null" if k is type(None) else k.__name__ for k in kinds)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _join(where: str, key: str) -> str:
|
|
144
|
+
return f"{where}.{key}" if where else key
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
N = type(None)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def parse_contract(doc: object) -> Contract:
|
|
151
|
+
doc = copy.deepcopy(doc)
|
|
152
|
+
original = doc
|
|
153
|
+
engine = "session"
|
|
154
|
+
if isinstance(doc, dict) and "engine" in doc:
|
|
155
|
+
engine = _check(doc["engine"], (str,), "engine")
|
|
156
|
+
if engine not in ("session", "rlmness"):
|
|
157
|
+
raise ContractError("engine: expected session or rlmness")
|
|
158
|
+
doc = {key: value for key, value in doc.items() if key != "engine"}
|
|
159
|
+
playbook = None
|
|
160
|
+
if isinstance(doc, dict) and "playbook" in doc:
|
|
161
|
+
playbook = _fields(doc["playbook"], "playbook", {"file": (str,), "update": (bool,)})
|
|
162
|
+
if not playbook["file"].strip():
|
|
163
|
+
raise ContractError("playbook.file: must not be empty")
|
|
164
|
+
doc = {key: value for key, value in doc.items() if key != "playbook"}
|
|
165
|
+
policy = None
|
|
166
|
+
if isinstance(doc, dict) and "policy" in doc:
|
|
167
|
+
policy_doc = doc["policy"]
|
|
168
|
+
shell_access, shell_network, allowed_tools = "workspace", False, None
|
|
169
|
+
if isinstance(policy_doc, dict):
|
|
170
|
+
shell_access = _check(policy_doc.get("shell_access", "workspace"),
|
|
171
|
+
(str,), "policy.shell_access")
|
|
172
|
+
shell_network = _check(policy_doc.get("shell_network", False),
|
|
173
|
+
(bool,), "policy.shell_network")
|
|
174
|
+
if shell_access not in ("workspace", "host"):
|
|
175
|
+
raise ContractError("policy.shell_access: expected workspace or host")
|
|
176
|
+
allowed_tools = _check(policy_doc.get("allowed_tools"), (list, N),
|
|
177
|
+
"policy.allowed_tools")
|
|
178
|
+
if allowed_tools is not None and (
|
|
179
|
+
any(not isinstance(name, str) or not re.fullmatch(r"[a-zA-Z][a-zA-Z_0-9]*", name)
|
|
180
|
+
for name in allowed_tools)
|
|
181
|
+
or len(set(allowed_tools)) != len(allowed_tools)
|
|
182
|
+
):
|
|
183
|
+
raise ContractError("policy.allowed_tools: expected unique tool names")
|
|
184
|
+
policy_doc = {key: value for key, value in policy_doc.items()
|
|
185
|
+
if key not in ("shell_access", "shell_network", "allowed_tools")}
|
|
186
|
+
# Optional: every field must be given, null where the default applies.
|
|
187
|
+
policy = _fields(policy_doc, "policy", {
|
|
188
|
+
"allow_git": (bool,), "max_tool_output_bytes": (int, N),
|
|
189
|
+
"max_compactions": (int, N), "agent_max_tokens": (int, N),
|
|
190
|
+
"search_block": (list,),
|
|
191
|
+
})
|
|
192
|
+
if not all(isinstance(rule, str) for rule in policy["search_block"]):
|
|
193
|
+
raise ContractError("policy.search_block: expected a list of strings")
|
|
194
|
+
if "shell_access" in doc["policy"]:
|
|
195
|
+
policy["shell_access"] = shell_access
|
|
196
|
+
if "shell_network" in doc["policy"]:
|
|
197
|
+
policy["shell_network"] = shell_network
|
|
198
|
+
if allowed_tools is not None:
|
|
199
|
+
policy["allowed_tools"] = allowed_tools
|
|
200
|
+
doc = {key: value for key, value in doc.items() if key != "policy"}
|
|
201
|
+
kernel = KernelSpec()
|
|
202
|
+
if isinstance(doc, dict) and "kernel" in doc:
|
|
203
|
+
# Optional, so contracts written before it still run the same way.
|
|
204
|
+
kernel_doc = doc["kernel"]
|
|
205
|
+
restore = False
|
|
206
|
+
if isinstance(kernel_doc, dict) and "restore" in kernel_doc:
|
|
207
|
+
restore = _check(kernel_doc["restore"], (bool,), "kernel.restore")
|
|
208
|
+
kernel_doc = {key: value for key, value in kernel_doc.items() if key != "restore"}
|
|
209
|
+
k = _fields(kernel_doc, "kernel", {"kind": (str,), "approve": (str,), "network": (bool,)})
|
|
210
|
+
if k["kind"] not in ("wasm", "local"):
|
|
211
|
+
raise ContractError(f"kernel.kind: {k['kind']!r} is not 'wasm' or 'local'")
|
|
212
|
+
if k["approve"] not in ("deny", "auto"):
|
|
213
|
+
raise ContractError(f"kernel.approve: {k['approve']!r} is not 'deny' or 'auto'")
|
|
214
|
+
if restore and k["kind"] != "wasm":
|
|
215
|
+
raise ContractError("kernel.restore: requires kernel.kind 'wasm'")
|
|
216
|
+
kernel = KernelSpec(**k, restore=restore)
|
|
217
|
+
doc = {key: value for key, value in doc.items() if key != "kernel"}
|
|
218
|
+
top = _fields(doc, "", {
|
|
219
|
+
"format": (str,), "model": (dict,), "limits": (dict,), "prompt": (dict,),
|
|
220
|
+
"tools": (list,), "workdir": (str,), "sessions": (dict,), "trace": (dict,),
|
|
221
|
+
})
|
|
222
|
+
if top["format"] != FORMAT:
|
|
223
|
+
raise ContractError(f"format: {top['format']!r} is not supported (expected {FORMAT!r})")
|
|
224
|
+
m = _fields(top["model"], "model", {
|
|
225
|
+
"provider": (str,), "name": (str,), "api_key_env": (str, N), "base_url": (str, N),
|
|
226
|
+
})
|
|
227
|
+
if m["provider"] not in _BASE_URLS:
|
|
228
|
+
raise ContractError(f"model.provider: {m['provider']!r} is not one of {sorted(_BASE_URLS)}")
|
|
229
|
+
account = m["provider"] in ACCOUNT_PROVIDERS
|
|
230
|
+
if account and (m["api_key_env"] is not None or m["base_url"] is not None):
|
|
231
|
+
raise ContractError("model: account routes require null api_key_env and base_url")
|
|
232
|
+
if m["api_key_env"] is None and m["provider"] not in _KEYLESS and not account:
|
|
233
|
+
raise ContractError("model.api_key_env: required for this provider")
|
|
234
|
+
if m["provider"] == "azure" and not m["base_url"]:
|
|
235
|
+
raise ContractError("model.base_url: required for the deployed Azure endpoint")
|
|
236
|
+
address = m["base_url"] or _BASE_URLS[m["provider"]]
|
|
237
|
+
parsed = urlsplit(address)
|
|
238
|
+
if not account and (parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username
|
|
239
|
+
or parsed.password or parsed.query or parsed.fragment):
|
|
240
|
+
raise ContractError("model.base_url: use an HTTP(S) endpoint without credentials, query or fragment")
|
|
241
|
+
if m["provider"] == "claude-subscription":
|
|
242
|
+
if re.fullmatch(r"cli:claude-code(?:\?model=[A-Za-z0-9][A-Za-z0-9._/-]{0,127})?", m["name"]) is None:
|
|
243
|
+
raise ContractError("model.name: use cli:claude-code or cli:claude-code?model=MODEL")
|
|
244
|
+
elif m["name"].startswith(("cli:", "acp:")):
|
|
245
|
+
raise ContractError("model.name: this native-provider contract requires a native model identifier")
|
|
246
|
+
limit_input = top["limits"]
|
|
247
|
+
if isinstance(limit_input, dict):
|
|
248
|
+
limit_input = {"request_timeout": 300.0, "max_new_tokens": None,
|
|
249
|
+
"strict_new_tokens": False, **limit_input}
|
|
250
|
+
lim = _fields(limit_input, "limits", {
|
|
251
|
+
"max_depth": (int,), "max_calls": (int,), "max_cost": (float,), "max_live": (int,),
|
|
252
|
+
"max_agents": (int,), "max_seconds": (float, N), "cell_timeout": (float,),
|
|
253
|
+
"request_timeout": (float,), "max_new_tokens": (int, N), "strict_new_tokens": (bool,),
|
|
254
|
+
})
|
|
255
|
+
if lim["strict_new_tokens"] and lim["max_new_tokens"] is None:
|
|
256
|
+
raise ContractError("limits.strict_new_tokens: requires max_new_tokens")
|
|
257
|
+
for name, value in lim.items():
|
|
258
|
+
if value is None:
|
|
259
|
+
continue
|
|
260
|
+
if not math.isfinite(value) or value < 0:
|
|
261
|
+
raise ContractError(f"limits.{name}: must be finite and nonnegative")
|
|
262
|
+
if name in ("max_live", "cell_timeout", "request_timeout") and value == 0:
|
|
263
|
+
raise ContractError(f"limits.{name}: must be positive")
|
|
264
|
+
if policy is not None:
|
|
265
|
+
for name in ("max_tool_output_bytes", "max_compactions", "agent_max_tokens"):
|
|
266
|
+
if policy[name] is not None and policy[name] < 0:
|
|
267
|
+
raise ContractError(f"policy.{name}: must be nonnegative or null")
|
|
268
|
+
for name in ("name", "base_url"):
|
|
269
|
+
if m[name] is not None and not m[name].strip():
|
|
270
|
+
raise ContractError(f"model.{name}: must not be blank")
|
|
271
|
+
pr = _fields(top["prompt"], "prompt", {
|
|
272
|
+
"system_file": (str, N), "append": (str, N), "append_sub_agent": (str, N),
|
|
273
|
+
"append_leaf": (str, N),
|
|
274
|
+
})
|
|
275
|
+
tools = top["tools"]
|
|
276
|
+
bad = [t for t in tools if t not in BUILTIN_TOOLS]
|
|
277
|
+
if bad or len(set(tools)) != len(tools):
|
|
278
|
+
raise ContractError(f"tools: each must be one of {list(BUILTIN_TOOLS)}, once")
|
|
279
|
+
if kernel.kind == "wasm" and {"Write", "Edit"} & set(tools):
|
|
280
|
+
raise ContractError("tools: Write and Edit are not offered with kernel.kind 'wasm'; "
|
|
281
|
+
"the sandbox changes files through write_file and edit_file")
|
|
282
|
+
if policy is not None and "allowed_tools" in policy and kernel.kind != "wasm":
|
|
283
|
+
if "python" in tools and "python" in policy["allowed_tools"]:
|
|
284
|
+
raise ContractError("policy.allowed_tools: restricted Python requires a Wasm kernel")
|
|
285
|
+
ses = _fields(top["sessions"], "sessions", {"save": (bool,), "dir": (str, N)})
|
|
286
|
+
tr = _fields(top["trace"], "trace", {"file": (str,)})
|
|
287
|
+
return Contract(
|
|
288
|
+
model=ModelSpec(m["provider"], m["name"], m["api_key_env"],
|
|
289
|
+
m["base_url"] or _BASE_URLS[m["provider"]]),
|
|
290
|
+
limits=Limits(**lim),
|
|
291
|
+
prompt=PromptSpec(**pr),
|
|
292
|
+
tools=tuple(tools),
|
|
293
|
+
workdir=top["workdir"],
|
|
294
|
+
sessions=Sessions(**ses),
|
|
295
|
+
trace=Trace(**tr),
|
|
296
|
+
raw=original,
|
|
297
|
+
kernel=kernel,
|
|
298
|
+
playbook=playbook,
|
|
299
|
+
engine=engine,
|
|
300
|
+
policy=policy,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def load_contract(path: str | Path) -> Contract:
|
|
305
|
+
try:
|
|
306
|
+
doc = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
307
|
+
except OSError as exc:
|
|
308
|
+
raise ContractError(f"cannot read {path}: {exc}") from None
|
|
309
|
+
except json.JSONDecodeError as exc:
|
|
310
|
+
raise ContractError(f"{path} is not valid JSON: {exc}") from None
|
|
311
|
+
return parse_contract(doc)
|
rlmagent/contract_run.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Run from a contract: settings come from the contract file and nowhere else."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from rlmagent.agents.approval import Approver
|
|
11
|
+
from rlmagent.agents.budget import ContractAllowance, NewTokenBudget
|
|
12
|
+
from rlmagent.agents.prompts import child_first_message, child_system, root_system
|
|
13
|
+
from rlmagent.agents.trace import Tracer
|
|
14
|
+
from rlmagent.agents.tree import AgentTree
|
|
15
|
+
from rlmagent.contract import ACCOUNT_PROVIDERS, Contract, ContractError, load_contract
|
|
16
|
+
from rlmagent.instructions import system_prompt
|
|
17
|
+
from rlmagent.tools import build_tool_registry
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _provider(c: Contract, credential=None):
|
|
21
|
+
from rlmagent.model.settings import AnthropicProfile, Credential, OpenAIProfile
|
|
22
|
+
|
|
23
|
+
if c.model.provider in ACCOUNT_PROVIDERS:
|
|
24
|
+
if credential is not None:
|
|
25
|
+
raise ContractError("Account routes do not accept injected credentials")
|
|
26
|
+
if c.model.provider == "claude-subscription":
|
|
27
|
+
from rlmagent.model.external import ExternalAgentProvider
|
|
28
|
+
|
|
29
|
+
return ExternalAgentProvider()
|
|
30
|
+
from rlmagent.model.oai_compatible import OpenAIProvider
|
|
31
|
+
from rlmagent.model.subscription import RESOURCE, resolve
|
|
32
|
+
|
|
33
|
+
return OpenAIProvider(OpenAIProfile(name="codex", credential=Credential(None, RESOURCE),
|
|
34
|
+
resolver=resolve, endpoint="responses", transport="http", idempotency=True))
|
|
35
|
+
credential = credential or Credential(api_key=c.api_key(), base_url=c.model.base_url)
|
|
36
|
+
if c.model.provider == "google":
|
|
37
|
+
from rlmagent.model.google import GoogleProvider
|
|
38
|
+
|
|
39
|
+
return GoogleProvider(credential)
|
|
40
|
+
if c.model.provider == "anthropic":
|
|
41
|
+
from rlmagent.model.claude import AnthropicProvider
|
|
42
|
+
|
|
43
|
+
return AnthropicProvider(AnthropicProfile(name="anthropic", credential=credential,
|
|
44
|
+
idempotency=True))
|
|
45
|
+
from rlmagent.model.oai_compatible import OpenAIProvider
|
|
46
|
+
|
|
47
|
+
if c.model.provider == "azure":
|
|
48
|
+
credential = Credential(None, credential.base_url,
|
|
49
|
+
(*credential.extra_headers, ("api-key", credential.api_key)))
|
|
50
|
+
return OpenAIProvider(OpenAIProfile(
|
|
51
|
+
name=c.model.provider, credential=credential, idempotency=True,
|
|
52
|
+
endpoint="responses" if c.model.provider == "azure" else "chat" if c.model.provider == "mistral" else None,
|
|
53
|
+
))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _tools(c: Contract, kernel, policy) -> list:
|
|
57
|
+
return [t for t in build_tool_registry(kernel) if t.name in c.tools and policy.allows_tool(t.name)]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def build_contract_session(c: Contract, *, provider=None, engine="session"):
|
|
61
|
+
from rlmagent.conversation import CodingSession
|
|
62
|
+
|
|
63
|
+
provider = provider or _provider(c)
|
|
64
|
+
workdir = str(Path(c.workdir).resolve())
|
|
65
|
+
identity = Path(c.prompt.system_file).read_text(encoding="utf-8") if c.prompt.system_file else None
|
|
66
|
+
trace_file = Path(c.trace.file).resolve()
|
|
67
|
+
tracer = Tracer(trace_file, contract=c.raw)
|
|
68
|
+
lim = c.limits
|
|
69
|
+
allowance = ContractAllowance(
|
|
70
|
+
max_depth=lim.max_depth, max_calls=lim.max_calls, max_cost=lim.max_cost,
|
|
71
|
+
max_live=lim.max_live, max_seconds=lim.max_seconds,
|
|
72
|
+
new_token_budget=(NewTokenBudget(lim.max_new_tokens, strict=lim.strict_new_tokens)
|
|
73
|
+
if lim.max_new_tokens is not None else None),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def base(tools) -> str:
|
|
77
|
+
return system_prompt(tools=tools, cwd=workdir, include_date=False,
|
|
78
|
+
identity=identity, project=False)
|
|
79
|
+
|
|
80
|
+
def extra(text: str | None) -> str:
|
|
81
|
+
text = text if text is not None else c.prompt.append
|
|
82
|
+
return f"\n\n{text}" if text else ""
|
|
83
|
+
|
|
84
|
+
def system_for(node, tools) -> str:
|
|
85
|
+
delegates = node.depth < lim.max_depth
|
|
86
|
+
added = c.prompt.append_sub_agent if delegates else c.prompt.append_leaf
|
|
87
|
+
return child_system(base(tools), node, delegates, sandboxed=sandboxed,
|
|
88
|
+
network=c.kernel.network) + extra(added)
|
|
89
|
+
|
|
90
|
+
sandboxed = c.kernel.kind == "wasm"
|
|
91
|
+
if sandboxed:
|
|
92
|
+
from rlmagent.kernel.wasm import available
|
|
93
|
+
|
|
94
|
+
if not available():
|
|
95
|
+
# No silent fallback: the contract names the kernel the run must use.
|
|
96
|
+
raise ContractError("kernel.kind: 'wasm' but the sandbox is not set up (rlmness --setup)")
|
|
97
|
+
sessions_dir = None
|
|
98
|
+
if c.sessions.save:
|
|
99
|
+
sessions_dir = Path(c.sessions.dir or Path(workdir) / ".rlm-agent" / "sessions")
|
|
100
|
+
from rlmagent.tools._shared import set_workspace
|
|
101
|
+
|
|
102
|
+
set_workspace(workdir)
|
|
103
|
+
if engine == "rlmness":
|
|
104
|
+
from rlmagent.backend_context import BackendContext
|
|
105
|
+
tree_type = BackendContext
|
|
106
|
+
else:
|
|
107
|
+
tree_type = AgentTree
|
|
108
|
+
tree = tree_type(
|
|
109
|
+
provider=provider, provider_name=c.model.provider, model=c.model.name, cwd=workdir,
|
|
110
|
+
allowance=allowance, sessions_dir=sessions_dir, system_for=system_for,
|
|
111
|
+
first_message_for=child_first_message, cell_timeout=lim.cell_timeout,
|
|
112
|
+
max_agents=lim.max_agents, tracer=tracer,
|
|
113
|
+
kernel=c.kernel.kind, restore_kernel=c.kernel.restore, approver=Approver(c.kernel.approve), network=c.kernel.network,
|
|
114
|
+
)
|
|
115
|
+
tree.contract_model = c.model.name
|
|
116
|
+
tree.request_timeout = lim.request_timeout
|
|
117
|
+
from rlmagent.policy import DEFAULT_OUTPUT_BYTES, Policy
|
|
118
|
+
|
|
119
|
+
p = c.policy or {}
|
|
120
|
+
tree.policy = Policy(
|
|
121
|
+
allow_git=p.get("allow_git", True),
|
|
122
|
+
max_tool_output_bytes=(p["max_tool_output_bytes"] if p.get("max_tool_output_bytes") is not None
|
|
123
|
+
else DEFAULT_OUTPUT_BYTES),
|
|
124
|
+
max_compactions=p.get("max_compactions"),
|
|
125
|
+
agent_max_tokens=p.get("agent_max_tokens"),
|
|
126
|
+
search_block=list(p.get("search_block") or []),
|
|
127
|
+
shell_access=p.get("shell_access", "workspace"),
|
|
128
|
+
shell_network=p.get("shell_network", False),
|
|
129
|
+
allowed_tools=frozenset(p["allowed_tools"]) if "allowed_tools" in p else None,
|
|
130
|
+
)
|
|
131
|
+
tree.tool_filter = lambda kernel: _tools(c, kernel, tree.policy)
|
|
132
|
+
await tree.start()
|
|
133
|
+
kernel = tree.root_kernel()
|
|
134
|
+
tools = tree.tool_filter(kernel)
|
|
135
|
+
system = root_system(base(tools), can_delegate=lim.max_depth > 0,
|
|
136
|
+
sandboxed=sandboxed, network=c.kernel.network) + extra(c.prompt.append)
|
|
137
|
+
session_provider = tree.provider_for("root")
|
|
138
|
+
if engine == "rlmness":
|
|
139
|
+
from rlmagent.backend import BackendProvider
|
|
140
|
+
from rlmagent.model.external import with_external
|
|
141
|
+
session_provider = BackendProvider(with_external(provider), session_provider, tree)
|
|
142
|
+
session_provider.instructions = base([]) + extra(c.prompt.append)
|
|
143
|
+
session_provider.event_path = Path(str(trace_file) + ".backend.events.jsonl")
|
|
144
|
+
session = await CodingSession.create(
|
|
145
|
+
provider=session_provider, provider_name=c.model.provider, model=c.model.name,
|
|
146
|
+
system=system, tools=tools, sessions_dir=sessions_dir, cwd=workdir,
|
|
147
|
+
)
|
|
148
|
+
await session.set_name("contract run")
|
|
149
|
+
if engine == "rlmness":
|
|
150
|
+
from rlmness.session import Session as BackendState
|
|
151
|
+
session.backend = session_provider
|
|
152
|
+
if sessions_dir is not None:
|
|
153
|
+
session_provider.state = BackendState.load(sessions_dir / f"{session.session_id}.backend.json")
|
|
154
|
+
session.agent_tree = tree
|
|
155
|
+
session.runtime_contract = c
|
|
156
|
+
session.policy = tree.policy
|
|
157
|
+
if engine == "rlmness":
|
|
158
|
+
# Background jobs, watches and the full record, under the contract's policy.
|
|
159
|
+
from rlmagent.agents.approval import Request, check
|
|
160
|
+
from rlmagent.history import make_history
|
|
161
|
+
from rlmagent.jobs import Jobs
|
|
162
|
+
from rlmagent.policy import git_refusal
|
|
163
|
+
|
|
164
|
+
async def approve(command: str) -> None:
|
|
165
|
+
request = Request("run", command)
|
|
166
|
+
check(await tree.approver.approve(request), request)
|
|
167
|
+
|
|
168
|
+
session.jobs = Jobs(workdir, Path(str(trace_file) + ".jobs"), approve,
|
|
169
|
+
lambda command: git_refusal(command, tree.policy),
|
|
170
|
+
shell_access=tree.policy.shell_access,
|
|
171
|
+
shell_network=tree.policy.shell_network)
|
|
172
|
+
session.jobs.subscriptions.start()
|
|
173
|
+
tree.host_functions = {
|
|
174
|
+
**session.jobs.functions(),
|
|
175
|
+
"history": make_history(lambda: getattr(getattr(session, "_vault", None), "path", None),
|
|
176
|
+
lambda: session_provider.event_path),
|
|
177
|
+
}
|
|
178
|
+
tree.attach(tree.root_id(), session)
|
|
179
|
+
if c.playbook is not None:
|
|
180
|
+
from rlmagent.playbook.improver import Improver
|
|
181
|
+
path = Path(c.playbook["file"])
|
|
182
|
+
if not path.is_absolute():
|
|
183
|
+
path = Path(workdir) / path
|
|
184
|
+
session.improver = tree.improver = Improver(session, path, None)
|
|
185
|
+
tree.playbook_writable = c.playbook["update"]
|
|
186
|
+
session.improver.auto = c.playbook["update"]
|
|
187
|
+
session.improver.writable = c.playbook["update"]
|
|
188
|
+
session.improver.show_digest()
|
|
189
|
+
session.on_compacted = lambda: (tracer.compacted("root"),
|
|
190
|
+
session.improver.compacted() if getattr(session, "improver", None) else None)
|
|
191
|
+
return session
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
async def run_contract(ns) -> int:
|
|
195
|
+
if ns.prompt is None and not sys.stdin.isatty():
|
|
196
|
+
ns.prompt = sys.stdin.read().strip()
|
|
197
|
+
if not ns.prompt:
|
|
198
|
+
print("rlm-agent: --contract needs a prompt (-p \"...\")", file=sys.stderr)
|
|
199
|
+
return 2
|
|
200
|
+
try:
|
|
201
|
+
contract = load_contract(ns.contract)
|
|
202
|
+
contract.api_key()
|
|
203
|
+
session = await build_contract_session(contract, engine=contract.engine)
|
|
204
|
+
except (ContractError, OSError) as exc:
|
|
205
|
+
print(f"rlm-agent: {exc}", file=sys.stderr)
|
|
206
|
+
return 2
|
|
207
|
+
return await execute_session(session, ns.prompt)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
async def execute_session(session, prompt: str | list[str]) -> int:
|
|
211
|
+
from rlmagent.cli.main import _run_one_shot
|
|
212
|
+
|
|
213
|
+
previous_dir = Path.cwd()
|
|
214
|
+
tree = session.agent_tree
|
|
215
|
+
try:
|
|
216
|
+
os.chdir(tree.cwd)
|
|
217
|
+
try:
|
|
218
|
+
for text in [prompt] if isinstance(prompt, str) else prompt:
|
|
219
|
+
status = await _run_one_shot(session, text, print_mode=True)
|
|
220
|
+
if status:
|
|
221
|
+
return status
|
|
222
|
+
return 0
|
|
223
|
+
finally:
|
|
224
|
+
from rlmagent.stops import of_session
|
|
225
|
+
|
|
226
|
+
stop = of_session(session, tree.provider.refusal)
|
|
227
|
+
await session.shutdown()
|
|
228
|
+
summary = tree.finish_trace() or tree.tracer.records[-1]
|
|
229
|
+
print(json.dumps({**{k: v for k, v in summary.items() if k != "kind"}, **stop}),
|
|
230
|
+
file=sys.stderr)
|
|
231
|
+
finally:
|
|
232
|
+
os.chdir(previous_dir)
|