rlmness-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rlmagent/__init__.py +1 -0
- rlmagent/__main__.py +8 -0
- rlmagent/acp/__init__.py +1 -0
- rlmagent/acp/runtime.py +71 -0
- rlmagent/acp/server.py +598 -0
- rlmagent/acp/updates.py +128 -0
- rlmagent/agentnet.py +237 -0
- rlmagent/agents/__init__.py +1 -0
- rlmagent/agents/approval.py +90 -0
- rlmagent/agents/bridge.py +109 -0
- rlmagent/agents/budget.py +212 -0
- rlmagent/agents/child_handles.py +46 -0
- rlmagent/agents/child_mailbox.py +30 -0
- rlmagent/agents/kernel_api.py +156 -0
- rlmagent/agents/output.py +34 -0
- rlmagent/agents/patch.py +107 -0
- rlmagent/agents/prompts.py +156 -0
- rlmagent/agents/trace.py +290 -0
- rlmagent/agents/tree.py +682 -0
- rlmagent/agents/wasm_api.py +105 -0
- rlmagent/agents/workspace.py +357 -0
- rlmagent/autopilot.py +447 -0
- rlmagent/backend.py +946 -0
- rlmagent/backend_context.py +74 -0
- rlmagent/cli/__init__.py +5 -0
- rlmagent/cli/config_cmd.py +177 -0
- rlmagent/cli/health.py +156 -0
- rlmagent/cli/main.py +1194 -0
- rlmagent/cli/update.py +188 -0
- rlmagent/clipboard.py +63 -0
- rlmagent/config/__init__.py +37 -0
- rlmagent/config/config.py +297 -0
- rlmagent/config/loader.py +119 -0
- rlmagent/config/prompts.py +124 -0
- rlmagent/config/secure_input.py +213 -0
- rlmagent/config/store.py +275 -0
- rlmagent/context/__init__.py +47 -0
- rlmagent/context/budget.py +557 -0
- rlmagent/contract.py +311 -0
- rlmagent/contract_run.py +232 -0
- rlmagent/conversation.py +2547 -0
- rlmagent/cron.py +77 -0
- rlmagent/directives.py +893 -0
- rlmagent/discovery.py +356 -0
- rlmagent/execution_log.py +66 -0
- rlmagent/factory.py +189 -0
- rlmagent/factory_graph.py +413 -0
- rlmagent/harness/__init__.py +63 -0
- rlmagent/harness/contracts/__init__.py +1 -0
- rlmagent/harness/contracts/stream.py +129 -0
- rlmagent/harness/contracts/tooling.py +127 -0
- rlmagent/harness/contracts/transcript/__init__.py +68 -0
- rlmagent/harness/contracts/transcript/_base.py +29 -0
- rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
- rlmagent/harness/contracts/transcript/entries.py +180 -0
- rlmagent/harness/contracts/transcript/segments.py +64 -0
- rlmagent/harness/contracts/values.py +6 -0
- rlmagent/harness/driver.py +397 -0
- rlmagent/harness/engine.py +489 -0
- rlmagent/harness/mending.py +153 -0
- rlmagent/harness/provider/__init__.py +1 -0
- rlmagent/harness/provider/base.py +27 -0
- rlmagent/harness/provider/wire.py +189 -0
- rlmagent/harness/py.typed +0 -0
- rlmagent/harness/session/__init__.py +5 -0
- rlmagent/harness/session/index.py +218 -0
- rlmagent/harness/session/lease.py +66 -0
- rlmagent/harness/session/records.py +118 -0
- rlmagent/harness/session/replay.py +165 -0
- rlmagent/harness/session/store.py +155 -0
- rlmagent/harness/session/summary.py +46 -0
- rlmagent/hints.py +47 -0
- rlmagent/history.py +92 -0
- rlmagent/hooks/__init__.py +1 -0
- rlmagent/hooks/hooks.py +1 -0
- rlmagent/instructions.py +157 -0
- rlmagent/job_runner.py +117 -0
- rlmagent/jobs.py +365 -0
- rlmagent/kept_agents.py +397 -0
- rlmagent/kernel/__init__.py +5 -0
- rlmagent/kernel/session.py +205 -0
- rlmagent/kernel/wasm.py +169 -0
- rlmagent/logs.py +208 -0
- rlmagent/managed_release.py +368 -0
- rlmagent/mcp.py +411 -0
- rlmagent/mcp_oauth.py +213 -0
- rlmagent/model/__init__.py +29 -0
- rlmagent/model/_claude/__init__.py +1 -0
- rlmagent/model/_claude/courier.py +117 -0
- rlmagent/model/_claude/emitter.py +378 -0
- rlmagent/model/_oai/__init__.py +1 -0
- rlmagent/model/_oai/helpers.py +256 -0
- rlmagent/model/_oai/normalize.py +263 -0
- rlmagent/model/_oai/parsers.py +317 -0
- rlmagent/model/_oai/payloads.py +226 -0
- rlmagent/model/_oai/transport.py +127 -0
- rlmagent/model/claude.py +552 -0
- rlmagent/model/copilot.py +142 -0
- rlmagent/model/correlation.py +21 -0
- rlmagent/model/deadline.py +19 -0
- rlmagent/model/discovery.py +181 -0
- rlmagent/model/external/__init__.py +11 -0
- rlmagent/model/external/acp.py +202 -0
- rlmagent/model/external/acp_install.py +109 -0
- rlmagent/model/external/cli.py +248 -0
- rlmagent/model/external/textcalls.py +57 -0
- rlmagent/model/failover.py +129 -0
- rlmagent/model/google.py +199 -0
- rlmagent/model/limits.py +46 -0
- rlmagent/model/oai_compatible.py +414 -0
- rlmagent/model/preferences.py +56 -0
- rlmagent/model/py.typed +0 -0
- rlmagent/model/registered_oauth.py +195 -0
- rlmagent/model/scripted.py +55 -0
- rlmagent/model/settings.py +271 -0
- rlmagent/model/subscription.py +398 -0
- rlmagent/model/token_bounds.py +15 -0
- rlmagent/model/transport/__init__.py +1 -0
- rlmagent/model/transport/backoff.py +87 -0
- rlmagent/model/transport/client.py +87 -0
- rlmagent/model/transport/faults.py +65 -0
- rlmagent/model/transport/responses_socket.py +223 -0
- rlmagent/model/vertex.py +93 -0
- rlmagent/network.py +123 -0
- rlmagent/packages.py +246 -0
- rlmagent/planning.py +84 -0
- rlmagent/playbook/__init__.py +1 -0
- rlmagent/playbook/apply.py +106 -0
- rlmagent/playbook/digest.py +63 -0
- rlmagent/playbook/improver.py +272 -0
- rlmagent/playbook/plan.py +99 -0
- rlmagent/playbook/store.py +67 -0
- rlmagent/plugins.py +327 -0
- rlmagent/policy.py +105 -0
- rlmagent/prompt_refs.py +57 -0
- rlmagent/prompts.py +331 -0
- rlmagent/prune.py +60 -0
- rlmagent/py.typed +0 -0
- rlmagent/reasoning.py +135 -0
- rlmagent/refinement/__init__.py +1 -0
- rlmagent/refinement/controller.py +448 -0
- rlmagent/refinement/engine.py +220 -0
- rlmagent/refinement/evidence.py +131 -0
- rlmagent/refinement/scoped.py +90 -0
- rlmagent/refinement/store.py +193 -0
- rlmagent/refresh.py +202 -0
- rlmagent/routing.py +52 -0
- rlmagent/runtime.py +575 -0
- rlmagent/safety.py +267 -0
- rlmagent/session_html.py +57 -0
- rlmagent/session_import.py +52 -0
- rlmagent/session_share.py +55 -0
- rlmagent/sessions.py +249 -0
- rlmagent/shell_sandbox.py +88 -0
- rlmagent/skillset.py +320 -0
- rlmagent/steering.py +148 -0
- rlmagent/stops.py +62 -0
- rlmagent/subscriptions.py +219 -0
- rlmagent/tasks.py +98 -0
- rlmagent/toolhost.py +310 -0
- rlmagent/tools/__init__.py +56 -0
- rlmagent/tools/_shared.py +210 -0
- rlmagent/tools/files.py +462 -0
- rlmagent/tools/kernel.py +93 -0
- rlmagent/tools/patch_tool.py +68 -0
- rlmagent/tools/search.py +188 -0
- rlmagent/trust.py +383 -0
- rlmagent/tui/__init__.py +1 -0
- rlmagent/tui/adapter.py +506 -0
- rlmagent/tui/app.py +711 -0
- rlmagent/tui/approval_screen.py +57 -0
- rlmagent/tui/choice_screen.py +47 -0
- rlmagent/tui/completions.py +116 -0
- rlmagent/tui/components.py +25 -0
- rlmagent/tui/composer.py +40 -0
- rlmagent/tui/factory_view.py +86 -0
- rlmagent/tui/inspect.py +193 -0
- rlmagent/tui/messages.py +215 -0
- rlmagent/tui/model_screen.py +307 -0
- rlmagent/tui/prompt.py +98 -0
- rlmagent/tui/question_screen.py +52 -0
- rlmagent/tui/replay.py +141 -0
- rlmagent/tui/rlm_agent.tcss +405 -0
- rlmagent/tui/search_box.py +91 -0
- rlmagent/tui/sidebar.py +182 -0
- rlmagent/tui/status.py +49 -0
- rlmagent/tui/steps.py +283 -0
- rlmagent/tui/suggest.py +778 -0
- rlmagent/tui/transcript.py +172 -0
- rlmagent/tui/trust_dialog.py +58 -0
- rlmagent/tui/worker_attach.py +141 -0
- rlmagent/ui/__init__.py +1 -0
- rlmagent/ui/render.py +211 -0
- rlmagent/undo.py +109 -0
- rlmagent/utils/__init__.py +1 -0
- rlmagent/websearch.py +75 -0
- rlmagent/worker.py +1275 -0
- rlmagent/worker_service.py +203 -0
- rlmness_cli-0.2.0.dist-info/METADATA +882 -0
- rlmness_cli-0.2.0.dist-info/RECORD +203 -0
- rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
- rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
- rlmness_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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
|
+
]
|