rlmness-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. rlmagent/__init__.py +1 -0
  2. rlmagent/__main__.py +8 -0
  3. rlmagent/acp/__init__.py +1 -0
  4. rlmagent/acp/runtime.py +71 -0
  5. rlmagent/acp/server.py +598 -0
  6. rlmagent/acp/updates.py +128 -0
  7. rlmagent/agentnet.py +237 -0
  8. rlmagent/agents/__init__.py +1 -0
  9. rlmagent/agents/approval.py +90 -0
  10. rlmagent/agents/bridge.py +109 -0
  11. rlmagent/agents/budget.py +212 -0
  12. rlmagent/agents/child_handles.py +46 -0
  13. rlmagent/agents/child_mailbox.py +30 -0
  14. rlmagent/agents/kernel_api.py +156 -0
  15. rlmagent/agents/output.py +34 -0
  16. rlmagent/agents/patch.py +107 -0
  17. rlmagent/agents/prompts.py +156 -0
  18. rlmagent/agents/trace.py +290 -0
  19. rlmagent/agents/tree.py +682 -0
  20. rlmagent/agents/wasm_api.py +105 -0
  21. rlmagent/agents/workspace.py +357 -0
  22. rlmagent/autopilot.py +447 -0
  23. rlmagent/backend.py +946 -0
  24. rlmagent/backend_context.py +74 -0
  25. rlmagent/cli/__init__.py +5 -0
  26. rlmagent/cli/config_cmd.py +177 -0
  27. rlmagent/cli/health.py +156 -0
  28. rlmagent/cli/main.py +1194 -0
  29. rlmagent/cli/update.py +188 -0
  30. rlmagent/clipboard.py +63 -0
  31. rlmagent/config/__init__.py +37 -0
  32. rlmagent/config/config.py +297 -0
  33. rlmagent/config/loader.py +119 -0
  34. rlmagent/config/prompts.py +124 -0
  35. rlmagent/config/secure_input.py +213 -0
  36. rlmagent/config/store.py +275 -0
  37. rlmagent/context/__init__.py +47 -0
  38. rlmagent/context/budget.py +557 -0
  39. rlmagent/contract.py +311 -0
  40. rlmagent/contract_run.py +232 -0
  41. rlmagent/conversation.py +2547 -0
  42. rlmagent/cron.py +77 -0
  43. rlmagent/directives.py +893 -0
  44. rlmagent/discovery.py +356 -0
  45. rlmagent/execution_log.py +66 -0
  46. rlmagent/factory.py +189 -0
  47. rlmagent/factory_graph.py +413 -0
  48. rlmagent/harness/__init__.py +63 -0
  49. rlmagent/harness/contracts/__init__.py +1 -0
  50. rlmagent/harness/contracts/stream.py +129 -0
  51. rlmagent/harness/contracts/tooling.py +127 -0
  52. rlmagent/harness/contracts/transcript/__init__.py +68 -0
  53. rlmagent/harness/contracts/transcript/_base.py +29 -0
  54. rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
  55. rlmagent/harness/contracts/transcript/entries.py +180 -0
  56. rlmagent/harness/contracts/transcript/segments.py +64 -0
  57. rlmagent/harness/contracts/values.py +6 -0
  58. rlmagent/harness/driver.py +397 -0
  59. rlmagent/harness/engine.py +489 -0
  60. rlmagent/harness/mending.py +153 -0
  61. rlmagent/harness/provider/__init__.py +1 -0
  62. rlmagent/harness/provider/base.py +27 -0
  63. rlmagent/harness/provider/wire.py +189 -0
  64. rlmagent/harness/py.typed +0 -0
  65. rlmagent/harness/session/__init__.py +5 -0
  66. rlmagent/harness/session/index.py +218 -0
  67. rlmagent/harness/session/lease.py +66 -0
  68. rlmagent/harness/session/records.py +118 -0
  69. rlmagent/harness/session/replay.py +165 -0
  70. rlmagent/harness/session/store.py +155 -0
  71. rlmagent/harness/session/summary.py +46 -0
  72. rlmagent/hints.py +47 -0
  73. rlmagent/history.py +92 -0
  74. rlmagent/hooks/__init__.py +1 -0
  75. rlmagent/hooks/hooks.py +1 -0
  76. rlmagent/instructions.py +157 -0
  77. rlmagent/job_runner.py +117 -0
  78. rlmagent/jobs.py +365 -0
  79. rlmagent/kept_agents.py +397 -0
  80. rlmagent/kernel/__init__.py +5 -0
  81. rlmagent/kernel/session.py +205 -0
  82. rlmagent/kernel/wasm.py +169 -0
  83. rlmagent/logs.py +208 -0
  84. rlmagent/managed_release.py +368 -0
  85. rlmagent/mcp.py +411 -0
  86. rlmagent/mcp_oauth.py +213 -0
  87. rlmagent/model/__init__.py +29 -0
  88. rlmagent/model/_claude/__init__.py +1 -0
  89. rlmagent/model/_claude/courier.py +117 -0
  90. rlmagent/model/_claude/emitter.py +378 -0
  91. rlmagent/model/_oai/__init__.py +1 -0
  92. rlmagent/model/_oai/helpers.py +256 -0
  93. rlmagent/model/_oai/normalize.py +263 -0
  94. rlmagent/model/_oai/parsers.py +317 -0
  95. rlmagent/model/_oai/payloads.py +226 -0
  96. rlmagent/model/_oai/transport.py +127 -0
  97. rlmagent/model/claude.py +552 -0
  98. rlmagent/model/copilot.py +142 -0
  99. rlmagent/model/correlation.py +21 -0
  100. rlmagent/model/deadline.py +19 -0
  101. rlmagent/model/discovery.py +181 -0
  102. rlmagent/model/external/__init__.py +11 -0
  103. rlmagent/model/external/acp.py +202 -0
  104. rlmagent/model/external/acp_install.py +109 -0
  105. rlmagent/model/external/cli.py +248 -0
  106. rlmagent/model/external/textcalls.py +57 -0
  107. rlmagent/model/failover.py +129 -0
  108. rlmagent/model/google.py +199 -0
  109. rlmagent/model/limits.py +46 -0
  110. rlmagent/model/oai_compatible.py +414 -0
  111. rlmagent/model/preferences.py +56 -0
  112. rlmagent/model/py.typed +0 -0
  113. rlmagent/model/registered_oauth.py +195 -0
  114. rlmagent/model/scripted.py +55 -0
  115. rlmagent/model/settings.py +271 -0
  116. rlmagent/model/subscription.py +398 -0
  117. rlmagent/model/token_bounds.py +15 -0
  118. rlmagent/model/transport/__init__.py +1 -0
  119. rlmagent/model/transport/backoff.py +87 -0
  120. rlmagent/model/transport/client.py +87 -0
  121. rlmagent/model/transport/faults.py +65 -0
  122. rlmagent/model/transport/responses_socket.py +223 -0
  123. rlmagent/model/vertex.py +93 -0
  124. rlmagent/network.py +123 -0
  125. rlmagent/packages.py +246 -0
  126. rlmagent/planning.py +84 -0
  127. rlmagent/playbook/__init__.py +1 -0
  128. rlmagent/playbook/apply.py +106 -0
  129. rlmagent/playbook/digest.py +63 -0
  130. rlmagent/playbook/improver.py +272 -0
  131. rlmagent/playbook/plan.py +99 -0
  132. rlmagent/playbook/store.py +67 -0
  133. rlmagent/plugins.py +327 -0
  134. rlmagent/policy.py +105 -0
  135. rlmagent/prompt_refs.py +57 -0
  136. rlmagent/prompts.py +331 -0
  137. rlmagent/prune.py +60 -0
  138. rlmagent/py.typed +0 -0
  139. rlmagent/reasoning.py +135 -0
  140. rlmagent/refinement/__init__.py +1 -0
  141. rlmagent/refinement/controller.py +448 -0
  142. rlmagent/refinement/engine.py +220 -0
  143. rlmagent/refinement/evidence.py +131 -0
  144. rlmagent/refinement/scoped.py +90 -0
  145. rlmagent/refinement/store.py +193 -0
  146. rlmagent/refresh.py +202 -0
  147. rlmagent/routing.py +52 -0
  148. rlmagent/runtime.py +575 -0
  149. rlmagent/safety.py +267 -0
  150. rlmagent/session_html.py +57 -0
  151. rlmagent/session_import.py +52 -0
  152. rlmagent/session_share.py +55 -0
  153. rlmagent/sessions.py +249 -0
  154. rlmagent/shell_sandbox.py +88 -0
  155. rlmagent/skillset.py +320 -0
  156. rlmagent/steering.py +148 -0
  157. rlmagent/stops.py +62 -0
  158. rlmagent/subscriptions.py +219 -0
  159. rlmagent/tasks.py +98 -0
  160. rlmagent/toolhost.py +310 -0
  161. rlmagent/tools/__init__.py +56 -0
  162. rlmagent/tools/_shared.py +210 -0
  163. rlmagent/tools/files.py +462 -0
  164. rlmagent/tools/kernel.py +93 -0
  165. rlmagent/tools/patch_tool.py +68 -0
  166. rlmagent/tools/search.py +188 -0
  167. rlmagent/trust.py +383 -0
  168. rlmagent/tui/__init__.py +1 -0
  169. rlmagent/tui/adapter.py +506 -0
  170. rlmagent/tui/app.py +711 -0
  171. rlmagent/tui/approval_screen.py +57 -0
  172. rlmagent/tui/choice_screen.py +47 -0
  173. rlmagent/tui/completions.py +116 -0
  174. rlmagent/tui/components.py +25 -0
  175. rlmagent/tui/composer.py +40 -0
  176. rlmagent/tui/factory_view.py +86 -0
  177. rlmagent/tui/inspect.py +193 -0
  178. rlmagent/tui/messages.py +215 -0
  179. rlmagent/tui/model_screen.py +307 -0
  180. rlmagent/tui/prompt.py +98 -0
  181. rlmagent/tui/question_screen.py +52 -0
  182. rlmagent/tui/replay.py +141 -0
  183. rlmagent/tui/rlm_agent.tcss +405 -0
  184. rlmagent/tui/search_box.py +91 -0
  185. rlmagent/tui/sidebar.py +182 -0
  186. rlmagent/tui/status.py +49 -0
  187. rlmagent/tui/steps.py +283 -0
  188. rlmagent/tui/suggest.py +778 -0
  189. rlmagent/tui/transcript.py +172 -0
  190. rlmagent/tui/trust_dialog.py +58 -0
  191. rlmagent/tui/worker_attach.py +141 -0
  192. rlmagent/ui/__init__.py +1 -0
  193. rlmagent/ui/render.py +211 -0
  194. rlmagent/undo.py +109 -0
  195. rlmagent/utils/__init__.py +1 -0
  196. rlmagent/websearch.py +75 -0
  197. rlmagent/worker.py +1275 -0
  198. rlmagent/worker_service.py +203 -0
  199. rlmness_cli-0.2.0.dist-info/METADATA +882 -0
  200. rlmness_cli-0.2.0.dist-info/RECORD +203 -0
  201. rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
  202. rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
  203. rlmness_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
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)
@@ -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)