rlmness-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. rlmagent/__init__.py +1 -0
  2. rlmagent/__main__.py +8 -0
  3. rlmagent/acp/__init__.py +1 -0
  4. rlmagent/acp/runtime.py +71 -0
  5. rlmagent/acp/server.py +598 -0
  6. rlmagent/acp/updates.py +128 -0
  7. rlmagent/agentnet.py +237 -0
  8. rlmagent/agents/__init__.py +1 -0
  9. rlmagent/agents/approval.py +90 -0
  10. rlmagent/agents/bridge.py +109 -0
  11. rlmagent/agents/budget.py +212 -0
  12. rlmagent/agents/child_handles.py +46 -0
  13. rlmagent/agents/child_mailbox.py +30 -0
  14. rlmagent/agents/kernel_api.py +156 -0
  15. rlmagent/agents/output.py +34 -0
  16. rlmagent/agents/patch.py +107 -0
  17. rlmagent/agents/prompts.py +156 -0
  18. rlmagent/agents/trace.py +290 -0
  19. rlmagent/agents/tree.py +682 -0
  20. rlmagent/agents/wasm_api.py +105 -0
  21. rlmagent/agents/workspace.py +357 -0
  22. rlmagent/autopilot.py +447 -0
  23. rlmagent/backend.py +946 -0
  24. rlmagent/backend_context.py +74 -0
  25. rlmagent/cli/__init__.py +5 -0
  26. rlmagent/cli/config_cmd.py +177 -0
  27. rlmagent/cli/health.py +156 -0
  28. rlmagent/cli/main.py +1194 -0
  29. rlmagent/cli/update.py +188 -0
  30. rlmagent/clipboard.py +63 -0
  31. rlmagent/config/__init__.py +37 -0
  32. rlmagent/config/config.py +297 -0
  33. rlmagent/config/loader.py +119 -0
  34. rlmagent/config/prompts.py +124 -0
  35. rlmagent/config/secure_input.py +213 -0
  36. rlmagent/config/store.py +275 -0
  37. rlmagent/context/__init__.py +47 -0
  38. rlmagent/context/budget.py +557 -0
  39. rlmagent/contract.py +311 -0
  40. rlmagent/contract_run.py +232 -0
  41. rlmagent/conversation.py +2547 -0
  42. rlmagent/cron.py +77 -0
  43. rlmagent/directives.py +893 -0
  44. rlmagent/discovery.py +356 -0
  45. rlmagent/execution_log.py +66 -0
  46. rlmagent/factory.py +189 -0
  47. rlmagent/factory_graph.py +413 -0
  48. rlmagent/harness/__init__.py +63 -0
  49. rlmagent/harness/contracts/__init__.py +1 -0
  50. rlmagent/harness/contracts/stream.py +129 -0
  51. rlmagent/harness/contracts/tooling.py +127 -0
  52. rlmagent/harness/contracts/transcript/__init__.py +68 -0
  53. rlmagent/harness/contracts/transcript/_base.py +29 -0
  54. rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
  55. rlmagent/harness/contracts/transcript/entries.py +180 -0
  56. rlmagent/harness/contracts/transcript/segments.py +64 -0
  57. rlmagent/harness/contracts/values.py +6 -0
  58. rlmagent/harness/driver.py +397 -0
  59. rlmagent/harness/engine.py +489 -0
  60. rlmagent/harness/mending.py +153 -0
  61. rlmagent/harness/provider/__init__.py +1 -0
  62. rlmagent/harness/provider/base.py +27 -0
  63. rlmagent/harness/provider/wire.py +189 -0
  64. rlmagent/harness/py.typed +0 -0
  65. rlmagent/harness/session/__init__.py +5 -0
  66. rlmagent/harness/session/index.py +218 -0
  67. rlmagent/harness/session/lease.py +66 -0
  68. rlmagent/harness/session/records.py +118 -0
  69. rlmagent/harness/session/replay.py +165 -0
  70. rlmagent/harness/session/store.py +155 -0
  71. rlmagent/harness/session/summary.py +46 -0
  72. rlmagent/hints.py +47 -0
  73. rlmagent/history.py +92 -0
  74. rlmagent/hooks/__init__.py +1 -0
  75. rlmagent/hooks/hooks.py +1 -0
  76. rlmagent/instructions.py +157 -0
  77. rlmagent/job_runner.py +117 -0
  78. rlmagent/jobs.py +365 -0
  79. rlmagent/kept_agents.py +397 -0
  80. rlmagent/kernel/__init__.py +5 -0
  81. rlmagent/kernel/session.py +205 -0
  82. rlmagent/kernel/wasm.py +169 -0
  83. rlmagent/logs.py +208 -0
  84. rlmagent/managed_release.py +368 -0
  85. rlmagent/mcp.py +411 -0
  86. rlmagent/mcp_oauth.py +213 -0
  87. rlmagent/model/__init__.py +29 -0
  88. rlmagent/model/_claude/__init__.py +1 -0
  89. rlmagent/model/_claude/courier.py +117 -0
  90. rlmagent/model/_claude/emitter.py +378 -0
  91. rlmagent/model/_oai/__init__.py +1 -0
  92. rlmagent/model/_oai/helpers.py +256 -0
  93. rlmagent/model/_oai/normalize.py +263 -0
  94. rlmagent/model/_oai/parsers.py +317 -0
  95. rlmagent/model/_oai/payloads.py +226 -0
  96. rlmagent/model/_oai/transport.py +127 -0
  97. rlmagent/model/claude.py +552 -0
  98. rlmagent/model/copilot.py +142 -0
  99. rlmagent/model/correlation.py +21 -0
  100. rlmagent/model/deadline.py +19 -0
  101. rlmagent/model/discovery.py +181 -0
  102. rlmagent/model/external/__init__.py +11 -0
  103. rlmagent/model/external/acp.py +202 -0
  104. rlmagent/model/external/acp_install.py +109 -0
  105. rlmagent/model/external/cli.py +248 -0
  106. rlmagent/model/external/textcalls.py +57 -0
  107. rlmagent/model/failover.py +129 -0
  108. rlmagent/model/google.py +199 -0
  109. rlmagent/model/limits.py +46 -0
  110. rlmagent/model/oai_compatible.py +414 -0
  111. rlmagent/model/preferences.py +56 -0
  112. rlmagent/model/py.typed +0 -0
  113. rlmagent/model/registered_oauth.py +195 -0
  114. rlmagent/model/scripted.py +55 -0
  115. rlmagent/model/settings.py +271 -0
  116. rlmagent/model/subscription.py +398 -0
  117. rlmagent/model/token_bounds.py +15 -0
  118. rlmagent/model/transport/__init__.py +1 -0
  119. rlmagent/model/transport/backoff.py +87 -0
  120. rlmagent/model/transport/client.py +87 -0
  121. rlmagent/model/transport/faults.py +65 -0
  122. rlmagent/model/transport/responses_socket.py +223 -0
  123. rlmagent/model/vertex.py +93 -0
  124. rlmagent/network.py +123 -0
  125. rlmagent/packages.py +246 -0
  126. rlmagent/planning.py +84 -0
  127. rlmagent/playbook/__init__.py +1 -0
  128. rlmagent/playbook/apply.py +106 -0
  129. rlmagent/playbook/digest.py +63 -0
  130. rlmagent/playbook/improver.py +272 -0
  131. rlmagent/playbook/plan.py +99 -0
  132. rlmagent/playbook/store.py +67 -0
  133. rlmagent/plugins.py +327 -0
  134. rlmagent/policy.py +105 -0
  135. rlmagent/prompt_refs.py +57 -0
  136. rlmagent/prompts.py +331 -0
  137. rlmagent/prune.py +60 -0
  138. rlmagent/py.typed +0 -0
  139. rlmagent/reasoning.py +135 -0
  140. rlmagent/refinement/__init__.py +1 -0
  141. rlmagent/refinement/controller.py +448 -0
  142. rlmagent/refinement/engine.py +220 -0
  143. rlmagent/refinement/evidence.py +131 -0
  144. rlmagent/refinement/scoped.py +90 -0
  145. rlmagent/refinement/store.py +193 -0
  146. rlmagent/refresh.py +202 -0
  147. rlmagent/routing.py +52 -0
  148. rlmagent/runtime.py +575 -0
  149. rlmagent/safety.py +267 -0
  150. rlmagent/session_html.py +57 -0
  151. rlmagent/session_import.py +52 -0
  152. rlmagent/session_share.py +55 -0
  153. rlmagent/sessions.py +249 -0
  154. rlmagent/shell_sandbox.py +88 -0
  155. rlmagent/skillset.py +320 -0
  156. rlmagent/steering.py +148 -0
  157. rlmagent/stops.py +62 -0
  158. rlmagent/subscriptions.py +219 -0
  159. rlmagent/tasks.py +98 -0
  160. rlmagent/toolhost.py +310 -0
  161. rlmagent/tools/__init__.py +56 -0
  162. rlmagent/tools/_shared.py +210 -0
  163. rlmagent/tools/files.py +462 -0
  164. rlmagent/tools/kernel.py +93 -0
  165. rlmagent/tools/patch_tool.py +68 -0
  166. rlmagent/tools/search.py +188 -0
  167. rlmagent/trust.py +383 -0
  168. rlmagent/tui/__init__.py +1 -0
  169. rlmagent/tui/adapter.py +506 -0
  170. rlmagent/tui/app.py +711 -0
  171. rlmagent/tui/approval_screen.py +57 -0
  172. rlmagent/tui/choice_screen.py +47 -0
  173. rlmagent/tui/completions.py +116 -0
  174. rlmagent/tui/components.py +25 -0
  175. rlmagent/tui/composer.py +40 -0
  176. rlmagent/tui/factory_view.py +86 -0
  177. rlmagent/tui/inspect.py +193 -0
  178. rlmagent/tui/messages.py +215 -0
  179. rlmagent/tui/model_screen.py +307 -0
  180. rlmagent/tui/prompt.py +98 -0
  181. rlmagent/tui/question_screen.py +52 -0
  182. rlmagent/tui/replay.py +141 -0
  183. rlmagent/tui/rlm_agent.tcss +405 -0
  184. rlmagent/tui/search_box.py +91 -0
  185. rlmagent/tui/sidebar.py +182 -0
  186. rlmagent/tui/status.py +49 -0
  187. rlmagent/tui/steps.py +283 -0
  188. rlmagent/tui/suggest.py +778 -0
  189. rlmagent/tui/transcript.py +172 -0
  190. rlmagent/tui/trust_dialog.py +58 -0
  191. rlmagent/tui/worker_attach.py +141 -0
  192. rlmagent/ui/__init__.py +1 -0
  193. rlmagent/ui/render.py +211 -0
  194. rlmagent/undo.py +109 -0
  195. rlmagent/utils/__init__.py +1 -0
  196. rlmagent/websearch.py +75 -0
  197. rlmagent/worker.py +1275 -0
  198. rlmagent/worker_service.py +203 -0
  199. rlmness_cli-0.2.0.dist-info/METADATA +882 -0
  200. rlmness_cli-0.2.0.dist-info/RECORD +203 -0
  201. rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
  202. rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
  203. rlmness_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,413 @@
1
+ """Persistent dependency workflows built from existing contract sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import hashlib
8
+ import json
9
+ import os
10
+ import subprocess
11
+ import sys
12
+ from dataclasses import dataclass
13
+ from graphlib import CycleError, TopologicalSorter
14
+ from pathlib import Path
15
+ from uuid import uuid4
16
+
17
+ from rlmagent.contract import Contract, ContractError, load_contract, parse_contract
18
+ from rlmagent.factory import HANDOFF_LIMIT, _NAME, _checkpoint
19
+ from rlmagent.harness.session.lease import SessionBusy, acquire
20
+ from rlmagent.model.external.cli import kill_tree, own_group
21
+ from rlmagent.plugins import rlm_agent_home
22
+
23
+ TERMINAL = {"done", "failed", "skipped"}
24
+ MAX_ITEMS = 128
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Stage:
29
+ contract: Contract
30
+ prompt: str
31
+ after: tuple[str, ...]
32
+ when: str
33
+ foreach: str | None = None
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class WorkflowPolicy:
38
+ max_transitions: int | None = None
39
+ max_live_agents: int | None = None
40
+ on_failure: str = "continue"
41
+
42
+
43
+ def _agent_reservation(stage):
44
+ limits = stage.contract.limits
45
+ return 1 + (limits.max_live if limits.max_depth else 0)
46
+
47
+
48
+ def load_graph(path: Path):
49
+ manifest = json.loads(path.read_text(encoding="utf-8"))
50
+ if not isinstance(manifest, dict) or manifest.get("format") != "rlmagent.factory/2":
51
+ raise ContractError("expected rlmagent.factory/2")
52
+ if manifest.keys() - {"format", "parallel", "stages", "policy"}:
53
+ raise ContractError("factory: unexpected graph fields")
54
+ width, raw = manifest.get("parallel", 1), manifest.get("stages")
55
+ if type(width) is not int or not 1 <= width <= 16:
56
+ raise ContractError("factory.parallel must be an integer from 1 to 16")
57
+ if not isinstance(raw, dict) or not 1 <= len(raw) <= 128:
58
+ raise ContractError("factory.stages must contain 1 to 128 named stages")
59
+ policy_doc = manifest.get("policy", {})
60
+ if not isinstance(policy_doc, dict) or policy_doc.keys() - WorkflowPolicy.__dataclass_fields__.keys():
61
+ raise ContractError("factory.policy has invalid fields")
62
+ policy = WorkflowPolicy(**policy_doc)
63
+ for field, minimum in (("max_transitions", 0), ("max_live_agents", 1)):
64
+ value = getattr(policy, field)
65
+ if value is not None and (type(value) is not int or value < minimum):
66
+ raise ContractError(f"factory.policy.{field} must be an integer of at least {minimum}")
67
+ if policy.on_failure not in ("continue", "stop"):
68
+ raise ContractError("factory.policy.on_failure must be continue or stop")
69
+ stages, traces = {}, set()
70
+ for name, row in raw.items():
71
+ if not _NAME.fullmatch(name) or not isinstance(row, dict) or row.keys() - {
72
+ "contract", "prompt", "after", "when", "foreach",
73
+ }:
74
+ raise ContractError(f"invalid factory stage {name!r}")
75
+ spec, prompt = row.get("contract"), row.get("prompt")
76
+ after, when = row.get("after", []), row.get("when", "all_done")
77
+ if not isinstance(prompt, str) or not prompt.strip():
78
+ raise ContractError(f"{name}: a nonempty prompt is required")
79
+ if not isinstance(after, list) or any(not isinstance(dep, str) or dep not in raw for dep in after):
80
+ raise ContractError(f"{name}: dependencies must name existing stages")
81
+ if len(set(after)) != len(after) or when not in ("all_done", "any_failed", "always"):
82
+ raise ContractError(f"{name}: invalid dependency guard")
83
+ if when == "any_failed" and not after:
84
+ raise ContractError(f"{name}: any_failed requires a dependency")
85
+ source = row.get("foreach")
86
+ if "foreach" in row and (not isinstance(source, str) or source not in after):
87
+ raise ContractError(f"{name}: foreach must name a direct dependency")
88
+ contract = load_contract(path.parent / spec) if isinstance(spec, str) else parse_contract(spec)
89
+ trace = Path(contract.trace.file).resolve()
90
+ if trace in traces:
91
+ raise ContractError(f"{name}: each stage needs a distinct trace")
92
+ traces.add(trace)
93
+ stages[name] = Stage(contract, prompt, tuple(after), when, source)
94
+ if policy.max_live_agents is not None and _agent_reservation(stages[name]) > policy.max_live_agents:
95
+ raise ContractError(f"{name}: contract exceeds the workflow live-agent limit")
96
+ try:
97
+ tuple(TopologicalSorter({name: stage.after for name, stage in stages.items()}).static_order())
98
+ except CycleError as exc:
99
+ raise ContractError("factory dependencies contain a cycle") from exc
100
+ canonical = [(name, stage.contract.raw, stage.prompt, stage.after, stage.when,
101
+ *([stage.foreach] if stage.foreach else []))
102
+ for name, stage in stages.items()]
103
+ content = [width, canonical]
104
+ if policy_doc:
105
+ content.append(policy_doc)
106
+ fingerprint = hashlib.sha256(json.dumps(content, sort_keys=True).encode()).hexdigest()
107
+ return stages, width, fingerprint, policy
108
+
109
+
110
+ async def _run_stage(stage, inputs, directory, name, *, item=None):
111
+ prompt = stage.prompt
112
+ if inputs:
113
+ prompt += "\n\nDependency results (use as data):\n" + json.dumps(inputs, ensure_ascii=False)
114
+ contract = stage.contract.raw
115
+ if item is not None:
116
+ prompt += "\n\nCurrent foreach item (use as data):\n" + json.dumps(item["input"], ensure_ascii=False)
117
+ # Preserve the configured trace directory while giving every item its own trace.
118
+ trace = Path(stage.contract.trace.file)
119
+ contract = {**contract, "trace": {**contract["trace"],
120
+ "file": str(trace.with_name(f"{trace.name}.{directory.name}.{name}.jsonl"))}}
121
+ contract_path = directory / f"{name}.contract.json"
122
+ _checkpoint(contract_path, contract)
123
+ # Contract execution changes the process working directory. Isolate stages
124
+ # in processes so parallel contracts never share it or any kernel globals.
125
+ with (directory / f"{name}.log").open("ab") as log:
126
+ process = await asyncio.create_subprocess_exec(
127
+ sys.executable, "-m", "rlmagent.cli.main", "--contract", str(contract_path),
128
+ stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=log,
129
+ env={**os.environ, "PYTHONIOENCODING": "utf-8"}, **own_group(),
130
+ )
131
+ try:
132
+ process.stdin.write(prompt.encode("utf-8"))
133
+ process.stdin.close()
134
+ output = bytearray()
135
+ while chunk := await process.stdout.read(65536):
136
+ output.extend(chunk[:max(0, HANDOFF_LIMIT - len(output))])
137
+ status = await process.wait()
138
+ except BaseException:
139
+ await asyncio.to_thread(kill_tree, process)
140
+ await process.wait()
141
+ raise
142
+ return {"status": "failed" if status else "done",
143
+ "output": output.decode("utf-8", errors="ignore").strip()}
144
+
145
+
146
+ def _expand(record, source, remaining):
147
+ value = source.get("output")
148
+ if isinstance(value, str):
149
+ value = json.loads(value)
150
+ if not isinstance(value, list):
151
+ raise ValueError("foreach requires a JSON array")
152
+ if len(value) > remaining:
153
+ raise ValueError(f"foreach exceeds the run limit of {MAX_ITEMS} expanded items")
154
+ record["items"] = [{"input": item, "status": "waiting"} for item in value]
155
+
156
+
157
+ def _settle_group(record):
158
+ items = record["items"]
159
+ if all(item["status"] in TERMINAL for item in items):
160
+ record["status"] = "failed" if any(item["status"] == "failed" for item in items) else "done"
161
+ limit = HANDOFF_LIMIT // max(1, len(items))
162
+ record["output"] = [item.get("output", "")[:limit] for item in items]
163
+
164
+
165
+ def _topology(stages):
166
+ return {name: {"after": list(stage.after), "when": stage.when, "foreach": stage.foreach}
167
+ for name, stage in stages.items()}
168
+
169
+
170
+ async def execute_graph(stages, width, fingerprint, policy, directory: Path, *, retry=False):
171
+ state_path = directory / "state.json"
172
+ if state_path.is_file():
173
+ state = json.loads(state_path.read_text(encoding="utf-8"))
174
+ if state["fingerprint"] != fingerprint:
175
+ raise ContractError("the workflow changed since this run began")
176
+ records = state["stages"]
177
+ interrupted = {name for name, row in records.items()
178
+ if row["status"] in ("running", "interrupted", "failed")}
179
+ if interrupted and not retry:
180
+ raise ContractError("review the interrupted or failed stages and resume with --retry-stages")
181
+ reset = set(interrupted)
182
+ order = TopologicalSorter({name: stage.after for name, stage in stages.items()})
183
+ for name in order.static_order():
184
+ if any(dep in reset for dep in stages[name].after):
185
+ reset.add(name)
186
+ for name in reset:
187
+ record = records[name]
188
+ if name in interrupted and "items" in record and not any(dep in reset for dep in stages[name].after):
189
+ for item in record["items"]:
190
+ if item["status"] in ("running", "interrupted", "failed"):
191
+ item.update(status="waiting", output="")
192
+ record["status"] = "waiting"
193
+ record.pop("output", None)
194
+ else:
195
+ records[name] = {"status": "waiting"}
196
+ else:
197
+ records = {name: {"status": "waiting"} for name in stages}
198
+ state = {"id": directory.name, "fingerprint": fingerprint, "stages": records}
199
+ state["graph"] = _topology(stages)
200
+ state["status"] = "running"
201
+ state.pop("stop_reason", None)
202
+ state.setdefault("transitions", 0)
203
+ _checkpoint(state_path, state)
204
+ running = {}
205
+ stop_reason = None
206
+ workspaces = {name: Path(stage.contract.workdir).resolve() for name, stage in stages.items()}
207
+
208
+ def conflicts(name):
209
+ path = workspaces[name]
210
+ return any(path == other or path in other.parents or other in path.parents
211
+ for other in (workspaces[node] for node, _ in running.values()))
212
+
213
+ def can_admit(name):
214
+ if len(running) >= width or conflicts(name):
215
+ return False
216
+ if policy.max_live_agents is None:
217
+ return True
218
+ reserved = sum(_agent_reservation(stages[node]) for node, _ in running.values())
219
+ return reserved + _agent_reservation(stages[name]) <= policy.max_live_agents
220
+
221
+ def charge_transition():
222
+ if policy.max_transitions is not None and state["transitions"] >= policy.max_transitions:
223
+ return False
224
+ state["transitions"] += 1
225
+ return True
226
+
227
+ try:
228
+ while True:
229
+ if (directory / "cancel").exists():
230
+ state["status"] = "cancelled"
231
+ break
232
+ changed = False
233
+ for name, stage in stages.items():
234
+ if stop_reason:
235
+ break
236
+ record = records[name]
237
+ if record["status"] not in ("waiting", "running"):
238
+ continue
239
+ if record["status"] == "running" and "items" not in record:
240
+ continue
241
+ deps = {dep: {key: val for key, val in records[dep].items() if key != "items"}
242
+ for dep in stage.after}
243
+ statuses = [row["status"] for row in deps.values()]
244
+ if any(status not in TERMINAL for status in statuses):
245
+ continue
246
+ if ((stage.when == "all_done" and any(status != "done" for status in statuses))
247
+ or (stage.when == "any_failed" and "failed" not in statuses)):
248
+ records[name] = {"status": "skipped"}
249
+ changed = True
250
+ elif stage.foreach:
251
+ if "items" not in record:
252
+ try:
253
+ used = sum(len(row.get("items", ())) for row in records.values())
254
+ _expand(record, deps[stage.foreach], MAX_ITEMS - used)
255
+ except ValueError as exc:
256
+ records[name] = {"status": "failed", "output": str(exc)}
257
+ if policy.on_failure == "stop":
258
+ stop_reason = f"stage failed: {name}"
259
+ changed = True
260
+ continue
261
+ _settle_group(record)
262
+ changed = True
263
+ if can_admit(name):
264
+ for index, item in enumerate(record["items"]):
265
+ if item["status"] != "waiting":
266
+ continue
267
+ if not charge_transition():
268
+ stop_reason = "workflow transition limit reached"
269
+ break
270
+ item["status"] = record["status"] = "running"
271
+ _checkpoint(state_path, state)
272
+ task = asyncio.create_task(_run_stage(
273
+ stage, deps, directory, f"{name}.item-{index}", item=item))
274
+ running[task] = (name, index)
275
+ break
276
+ elif can_admit(name):
277
+ if not charge_transition():
278
+ stop_reason = "workflow transition limit reached"
279
+ break
280
+ records[name] = {"status": "running"}
281
+ _checkpoint(state_path, state)
282
+ running[asyncio.create_task(_run_stage(stage, deps, directory, name))] = (name, None)
283
+ if not running:
284
+ if stop_reason:
285
+ state["status"] = "failed"
286
+ state["stop_reason"] = stop_reason
287
+ for record in records.values():
288
+ if record["status"] == "running" and "items" in record:
289
+ record["status"] = "failed"
290
+ break
291
+ if changed:
292
+ continue
293
+ state["status"] = "failed" if any(row["status"] == "failed"
294
+ for row in records.values()) else "done"
295
+ break
296
+ finished, _ = await asyncio.wait(running, timeout=0.25, return_when=asyncio.FIRST_COMPLETED)
297
+ for task in finished:
298
+ name, index = running.pop(task)
299
+ try:
300
+ outcome = task.result()
301
+ except Exception as exc:
302
+ outcome = {"status": "failed", "output": str(exc)[:HANDOFF_LIMIT]}
303
+ if index is None:
304
+ records[name] = outcome
305
+ else:
306
+ records[name]["items"][index].update(outcome)
307
+ _settle_group(records[name])
308
+ if outcome["status"] == "failed" and policy.on_failure == "stop" and not stop_reason:
309
+ stop_reason = f"stage failed: {name}"
310
+ if finished or changed:
311
+ _checkpoint(state_path, state)
312
+ finally:
313
+ if running:
314
+ for task, (name, index) in running.items():
315
+ task.cancel()
316
+ if index is not None:
317
+ records[name]["items"][index]["status"] = "interrupted"
318
+ records[name]["status"] = "interrupted"
319
+ await asyncio.gather(*running, return_exceptions=True)
320
+ if state["status"] == "running":
321
+ state["status"] = "interrupted"
322
+ _checkpoint(state_path, state)
323
+ return int(state["status"] != "done")
324
+
325
+
326
+ def run_status(directory: Path) -> dict:
327
+ state_path = directory / "state.json"
328
+ if state_path.is_file():
329
+ state = json.loads(state_path.read_text(encoding="utf-8"))
330
+ else:
331
+ spec = json.loads((directory / "run.json").read_text(encoding="utf-8"))
332
+ state = {"id": directory.name, "status": "queued", "graph": spec.get("graph", {})}
333
+ if state["status"] == "running":
334
+ try:
335
+ with acquire(directory, "writer"):
336
+ state = json.loads(state_path.read_text(encoding="utf-8"))
337
+ if state["status"] == "running":
338
+ state["status"] = "interrupted"
339
+ except SessionBusy:
340
+ pass
341
+ failure = directory / "failure.json"
342
+ try:
343
+ state["error"] = json.loads(failure.read_text(encoding="utf-8"))["error"]
344
+ except FileNotFoundError:
345
+ pass
346
+ else:
347
+ if state["status"] == "queued":
348
+ state["status"] = "failed"
349
+ return state
350
+
351
+
352
+ def command(action: str, argv: list[str]) -> int:
353
+ parser = argparse.ArgumentParser(prog=f"rlm-agent factory {action}")
354
+ parser.add_argument("manifest" if action == "launch" else "run_id")
355
+ if action in ("resume", "_execute"):
356
+ parser.add_argument("--retry-stages", action="store_true")
357
+ args = parser.parse_args(argv)
358
+ run_id = uuid4().hex if action == "launch" else args.run_id
359
+ if len(run_id) != 32 or any(char not in "0123456789abcdef" for char in run_id):
360
+ raise ContractError("invalid factory run ID")
361
+ directory = rlm_agent_home().resolve() / "factory-runs" / run_id
362
+ spec = directory / "run.json"
363
+ if action == "launch":
364
+ manifest = Path(args.manifest).resolve(strict=True)
365
+ stages, _, fingerprint, _ = load_graph(manifest)
366
+ for stage in stages.values():
367
+ stage.contract.api_key()
368
+ directory.mkdir(parents=True, exist_ok=False)
369
+ _checkpoint(spec, {"manifest": str(manifest), "cwd": os.getcwd(), "graph": _topology(stages),
370
+ "fingerprint": fingerprint})
371
+ else:
372
+ if not spec.is_file():
373
+ raise ContractError(f"unknown factory run {run_id}")
374
+ if action == "status":
375
+ print(json.dumps(run_status(directory)))
376
+ return 0
377
+ if action == "view":
378
+ from rlmagent.tui.factory_view import FactoryGraphApp
379
+
380
+ FactoryGraphApp(directory).run()
381
+ return 0
382
+ if action == "cancel":
383
+ (directory / "cancel").touch()
384
+ return 0
385
+ config = json.loads(spec.read_text(encoding="utf-8"))
386
+ if action == "_execute":
387
+ with acquire(directory, "writer"):
388
+ failure = directory / "failure.json"
389
+ failure.unlink(missing_ok=True)
390
+ try:
391
+ stages, width, fingerprint, policy = load_graph(Path(config["manifest"]))
392
+ if config.get("fingerprint", fingerprint) != fingerprint:
393
+ raise ContractError("the workflow changed since this run was launched")
394
+ return asyncio.run(execute_graph(stages, width, fingerprint, policy, directory,
395
+ retry=args.retry_stages))
396
+ except (OSError, RuntimeError, ValueError) as exc:
397
+ _checkpoint(failure, {"error": str(exc)})
398
+ raise
399
+ if action == "resume":
400
+ with acquire(directory, "writer"):
401
+ (directory / "cancel").unlink(missing_ok=True)
402
+ retry = ["--retry-stages"] if getattr(args, "retry_stages", False) else []
403
+ options = {"creationflags": subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP} if os.name == "nt" else {
404
+ "start_new_session": True,
405
+ }
406
+ with (directory / "output.log").open("ab") as output:
407
+ subprocess.Popen(
408
+ [sys.executable, "-m", "rlmagent.cli.main", "factory", "_execute", run_id, *retry],
409
+ cwd=config["cwd"], stdin=subprocess.DEVNULL, stdout=output, stderr=subprocess.STDOUT,
410
+ **options,
411
+ )
412
+ print(run_id)
413
+ return 0
@@ -0,0 +1,63 @@
1
+ """rlmagent.harness: the core."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rlmagent.harness.contracts.stream import (
6
+ AgentEvent,
7
+ MessageEndEvent,
8
+ MessageStartEvent,
9
+ MessageUpdateEvent,
10
+ RunEndEvent,
11
+ RunStartEvent,
12
+ ToolRunEndEvent,
13
+ ToolRunStartEvent,
14
+ ToolRunUpdateEvent,
15
+ TurnEndEvent,
16
+ TurnStartEvent,
17
+ )
18
+ from rlmagent.harness.contracts.tooling import (
19
+ CancelToken,
20
+ RunHandler,
21
+ ToolOutcome,
22
+ ToolSpec,
23
+ )
24
+ from rlmagent.harness.contracts.transcript import (
25
+ CallBlock,
26
+ HumanEntry,
27
+ ModelEntry,
28
+ TextSegment,
29
+ ToolOutcomeEntry,
30
+ TranscriptEntry,
31
+ )
32
+ from rlmagent.harness.contracts.values import JObject, JPrimitive, JValue
33
+
34
+ __all__ = [
35
+ # values
36
+ "JObject",
37
+ "JPrimitive",
38
+ "JValue",
39
+ # transcript
40
+ "CallBlock",
41
+ "HumanEntry",
42
+ "ModelEntry",
43
+ "TextSegment",
44
+ "ToolOutcomeEntry",
45
+ "TranscriptEntry",
46
+ # tooling
47
+ "CancelToken",
48
+ "RunHandler",
49
+ "ToolOutcome",
50
+ "ToolSpec",
51
+ # stream
52
+ "AgentEvent",
53
+ "MessageEndEvent",
54
+ "MessageStartEvent",
55
+ "MessageUpdateEvent",
56
+ "RunEndEvent",
57
+ "RunStartEvent",
58
+ "ToolRunEndEvent",
59
+ "ToolRunStartEvent",
60
+ "ToolRunUpdateEvent",
61
+ "TurnEndEvent",
62
+ "TurnStartEvent",
63
+ ]
@@ -0,0 +1 @@
1
+ """rlmagent.harness contracts: the typed vocabulary (values, tooling, transcript, stream)."""
@@ -0,0 +1,129 @@
1
+ """Harness-level event types consumed by UIs and session persistence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated, Literal
6
+
7
+ from pydantic import Field
8
+
9
+ from rlmagent.harness.contracts.tooling import ToolOutcome
10
+ from rlmagent.harness.contracts.transcript import (
11
+ StrictModel,
12
+ ToolOutcomeEntry,
13
+ TranscriptEntry,
14
+ )
15
+ from rlmagent.harness.contracts.values import JValue
16
+
17
+ # --- message streaming -----------------------------------------------------
18
+
19
+
20
+ class MessageStartEvent(StrictModel):
21
+ """Signals that a new transcript entry has opened."""
22
+
23
+ type: Literal["message_start"] = "message_start"
24
+ message: TranscriptEntry
25
+
26
+
27
+ class MessageUpdateEvent(StrictModel):
28
+ """Carries a partial snapshot as the model streams content."""
29
+
30
+ type: Literal["message_update"] = "message_update"
31
+ message: TranscriptEntry
32
+ assistant_message_event: JValue = Field(
33
+ default=None, serialization_alias="assistantMessageEvent"
34
+ )
35
+
36
+
37
+ class MessageEndEvent(StrictModel):
38
+ """Marks a transcript entry as complete and finalized."""
39
+
40
+ type: Literal["message_end"] = "message_end"
41
+ message: TranscriptEntry
42
+
43
+
44
+ # --- tool execution --------------------------------------------------------
45
+
46
+
47
+ class ToolRunStartEvent(StrictModel):
48
+ """Fired just before a tool invocation starts."""
49
+
50
+ type: Literal["tool_execution_start"] = "tool_execution_start"
51
+ tool_call_id: str
52
+ tool_name: str
53
+ args: dict[str, JValue] = Field(default_factory=dict)
54
+
55
+
56
+ class ToolRunUpdateEvent(StrictModel):
57
+ """Delivers intermediate progress from a running tool."""
58
+
59
+ type: Literal["tool_execution_update"] = "tool_execution_update"
60
+ tool_call_id: str
61
+ tool_name: str
62
+ args: dict[str, JValue] = Field(default_factory=dict)
63
+ partial_result: ToolOutcome
64
+
65
+
66
+ class ToolRunEndEvent(StrictModel):
67
+ """Carries the final outcome after a tool invocation completes."""
68
+
69
+ type: Literal["tool_execution_end"] = "tool_execution_end"
70
+ tool_call_id: str
71
+ tool_name: str
72
+ result: ToolOutcome
73
+ is_error: bool
74
+
75
+
76
+ # --- run lifecycle ---------------------------------------------------------
77
+
78
+
79
+ class TurnStartEvent(StrictModel):
80
+ """Opens a new turn boundary in the run."""
81
+
82
+ type: Literal["turn_start"] = "turn_start"
83
+
84
+
85
+ class TurnEndEvent(StrictModel):
86
+ """Closes a turn, carrying the model reply and any tool outcomes."""
87
+
88
+ type: Literal["turn_end"] = "turn_end"
89
+ message: TranscriptEntry
90
+ tool_results: list[ToolOutcomeEntry] = Field(default_factory=list)
91
+
92
+
93
+ class RetryEvent(StrictModel):
94
+ """A failed attempt is about to be retried. Progress only, not a result."""
95
+
96
+ type: Literal["retry"] = "retry"
97
+ attempt: int
98
+ max_attempts: int
99
+ delay_seconds: float = 0.0
100
+ message: str
101
+
102
+
103
+ class RunStartEvent(StrictModel):
104
+ """Marks the beginning of an agent run."""
105
+
106
+ type: Literal["agent_start"] = "agent_start"
107
+
108
+
109
+ class RunEndEvent(StrictModel):
110
+ """Marks the end of an agent run, carrying all entries produced."""
111
+
112
+ type: Literal["agent_end"] = "agent_end"
113
+ messages: list[TranscriptEntry] = Field(default_factory=list)
114
+
115
+
116
+ type AgentEvent = Annotated[
117
+ RunStartEvent
118
+ | RunEndEvent
119
+ | TurnStartEvent
120
+ | TurnEndEvent
121
+ | MessageStartEvent
122
+ | MessageUpdateEvent
123
+ | MessageEndEvent
124
+ | ToolRunStartEvent
125
+ | ToolRunUpdateEvent
126
+ | ToolRunEndEvent
127
+ | RetryEvent,
128
+ Field(discriminator="type"),
129
+ ]