rlmness-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rlmagent/__init__.py +1 -0
- rlmagent/__main__.py +8 -0
- rlmagent/acp/__init__.py +1 -0
- rlmagent/acp/runtime.py +71 -0
- rlmagent/acp/server.py +598 -0
- rlmagent/acp/updates.py +128 -0
- rlmagent/agentnet.py +237 -0
- rlmagent/agents/__init__.py +1 -0
- rlmagent/agents/approval.py +90 -0
- rlmagent/agents/bridge.py +109 -0
- rlmagent/agents/budget.py +212 -0
- rlmagent/agents/child_handles.py +46 -0
- rlmagent/agents/child_mailbox.py +30 -0
- rlmagent/agents/kernel_api.py +156 -0
- rlmagent/agents/output.py +34 -0
- rlmagent/agents/patch.py +107 -0
- rlmagent/agents/prompts.py +156 -0
- rlmagent/agents/trace.py +290 -0
- rlmagent/agents/tree.py +682 -0
- rlmagent/agents/wasm_api.py +105 -0
- rlmagent/agents/workspace.py +357 -0
- rlmagent/autopilot.py +447 -0
- rlmagent/backend.py +946 -0
- rlmagent/backend_context.py +74 -0
- rlmagent/cli/__init__.py +5 -0
- rlmagent/cli/config_cmd.py +177 -0
- rlmagent/cli/health.py +156 -0
- rlmagent/cli/main.py +1194 -0
- rlmagent/cli/update.py +188 -0
- rlmagent/clipboard.py +63 -0
- rlmagent/config/__init__.py +37 -0
- rlmagent/config/config.py +297 -0
- rlmagent/config/loader.py +119 -0
- rlmagent/config/prompts.py +124 -0
- rlmagent/config/secure_input.py +213 -0
- rlmagent/config/store.py +275 -0
- rlmagent/context/__init__.py +47 -0
- rlmagent/context/budget.py +557 -0
- rlmagent/contract.py +311 -0
- rlmagent/contract_run.py +232 -0
- rlmagent/conversation.py +2547 -0
- rlmagent/cron.py +77 -0
- rlmagent/directives.py +893 -0
- rlmagent/discovery.py +356 -0
- rlmagent/execution_log.py +66 -0
- rlmagent/factory.py +189 -0
- rlmagent/factory_graph.py +413 -0
- rlmagent/harness/__init__.py +63 -0
- rlmagent/harness/contracts/__init__.py +1 -0
- rlmagent/harness/contracts/stream.py +129 -0
- rlmagent/harness/contracts/tooling.py +127 -0
- rlmagent/harness/contracts/transcript/__init__.py +68 -0
- rlmagent/harness/contracts/transcript/_base.py +29 -0
- rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
- rlmagent/harness/contracts/transcript/entries.py +180 -0
- rlmagent/harness/contracts/transcript/segments.py +64 -0
- rlmagent/harness/contracts/values.py +6 -0
- rlmagent/harness/driver.py +397 -0
- rlmagent/harness/engine.py +489 -0
- rlmagent/harness/mending.py +153 -0
- rlmagent/harness/provider/__init__.py +1 -0
- rlmagent/harness/provider/base.py +27 -0
- rlmagent/harness/provider/wire.py +189 -0
- rlmagent/harness/py.typed +0 -0
- rlmagent/harness/session/__init__.py +5 -0
- rlmagent/harness/session/index.py +218 -0
- rlmagent/harness/session/lease.py +66 -0
- rlmagent/harness/session/records.py +118 -0
- rlmagent/harness/session/replay.py +165 -0
- rlmagent/harness/session/store.py +155 -0
- rlmagent/harness/session/summary.py +46 -0
- rlmagent/hints.py +47 -0
- rlmagent/history.py +92 -0
- rlmagent/hooks/__init__.py +1 -0
- rlmagent/hooks/hooks.py +1 -0
- rlmagent/instructions.py +157 -0
- rlmagent/job_runner.py +117 -0
- rlmagent/jobs.py +365 -0
- rlmagent/kept_agents.py +397 -0
- rlmagent/kernel/__init__.py +5 -0
- rlmagent/kernel/session.py +205 -0
- rlmagent/kernel/wasm.py +169 -0
- rlmagent/logs.py +208 -0
- rlmagent/managed_release.py +368 -0
- rlmagent/mcp.py +411 -0
- rlmagent/mcp_oauth.py +213 -0
- rlmagent/model/__init__.py +29 -0
- rlmagent/model/_claude/__init__.py +1 -0
- rlmagent/model/_claude/courier.py +117 -0
- rlmagent/model/_claude/emitter.py +378 -0
- rlmagent/model/_oai/__init__.py +1 -0
- rlmagent/model/_oai/helpers.py +256 -0
- rlmagent/model/_oai/normalize.py +263 -0
- rlmagent/model/_oai/parsers.py +317 -0
- rlmagent/model/_oai/payloads.py +226 -0
- rlmagent/model/_oai/transport.py +127 -0
- rlmagent/model/claude.py +552 -0
- rlmagent/model/copilot.py +142 -0
- rlmagent/model/correlation.py +21 -0
- rlmagent/model/deadline.py +19 -0
- rlmagent/model/discovery.py +181 -0
- rlmagent/model/external/__init__.py +11 -0
- rlmagent/model/external/acp.py +202 -0
- rlmagent/model/external/acp_install.py +109 -0
- rlmagent/model/external/cli.py +248 -0
- rlmagent/model/external/textcalls.py +57 -0
- rlmagent/model/failover.py +129 -0
- rlmagent/model/google.py +199 -0
- rlmagent/model/limits.py +46 -0
- rlmagent/model/oai_compatible.py +414 -0
- rlmagent/model/preferences.py +56 -0
- rlmagent/model/py.typed +0 -0
- rlmagent/model/registered_oauth.py +195 -0
- rlmagent/model/scripted.py +55 -0
- rlmagent/model/settings.py +271 -0
- rlmagent/model/subscription.py +398 -0
- rlmagent/model/token_bounds.py +15 -0
- rlmagent/model/transport/__init__.py +1 -0
- rlmagent/model/transport/backoff.py +87 -0
- rlmagent/model/transport/client.py +87 -0
- rlmagent/model/transport/faults.py +65 -0
- rlmagent/model/transport/responses_socket.py +223 -0
- rlmagent/model/vertex.py +93 -0
- rlmagent/network.py +123 -0
- rlmagent/packages.py +246 -0
- rlmagent/planning.py +84 -0
- rlmagent/playbook/__init__.py +1 -0
- rlmagent/playbook/apply.py +106 -0
- rlmagent/playbook/digest.py +63 -0
- rlmagent/playbook/improver.py +272 -0
- rlmagent/playbook/plan.py +99 -0
- rlmagent/playbook/store.py +67 -0
- rlmagent/plugins.py +327 -0
- rlmagent/policy.py +105 -0
- rlmagent/prompt_refs.py +57 -0
- rlmagent/prompts.py +331 -0
- rlmagent/prune.py +60 -0
- rlmagent/py.typed +0 -0
- rlmagent/reasoning.py +135 -0
- rlmagent/refinement/__init__.py +1 -0
- rlmagent/refinement/controller.py +448 -0
- rlmagent/refinement/engine.py +220 -0
- rlmagent/refinement/evidence.py +131 -0
- rlmagent/refinement/scoped.py +90 -0
- rlmagent/refinement/store.py +193 -0
- rlmagent/refresh.py +202 -0
- rlmagent/routing.py +52 -0
- rlmagent/runtime.py +575 -0
- rlmagent/safety.py +267 -0
- rlmagent/session_html.py +57 -0
- rlmagent/session_import.py +52 -0
- rlmagent/session_share.py +55 -0
- rlmagent/sessions.py +249 -0
- rlmagent/shell_sandbox.py +88 -0
- rlmagent/skillset.py +320 -0
- rlmagent/steering.py +148 -0
- rlmagent/stops.py +62 -0
- rlmagent/subscriptions.py +219 -0
- rlmagent/tasks.py +98 -0
- rlmagent/toolhost.py +310 -0
- rlmagent/tools/__init__.py +56 -0
- rlmagent/tools/_shared.py +210 -0
- rlmagent/tools/files.py +462 -0
- rlmagent/tools/kernel.py +93 -0
- rlmagent/tools/patch_tool.py +68 -0
- rlmagent/tools/search.py +188 -0
- rlmagent/trust.py +383 -0
- rlmagent/tui/__init__.py +1 -0
- rlmagent/tui/adapter.py +506 -0
- rlmagent/tui/app.py +711 -0
- rlmagent/tui/approval_screen.py +57 -0
- rlmagent/tui/choice_screen.py +47 -0
- rlmagent/tui/completions.py +116 -0
- rlmagent/tui/components.py +25 -0
- rlmagent/tui/composer.py +40 -0
- rlmagent/tui/factory_view.py +86 -0
- rlmagent/tui/inspect.py +193 -0
- rlmagent/tui/messages.py +215 -0
- rlmagent/tui/model_screen.py +307 -0
- rlmagent/tui/prompt.py +98 -0
- rlmagent/tui/question_screen.py +52 -0
- rlmagent/tui/replay.py +141 -0
- rlmagent/tui/rlm_agent.tcss +405 -0
- rlmagent/tui/search_box.py +91 -0
- rlmagent/tui/sidebar.py +182 -0
- rlmagent/tui/status.py +49 -0
- rlmagent/tui/steps.py +283 -0
- rlmagent/tui/suggest.py +778 -0
- rlmagent/tui/transcript.py +172 -0
- rlmagent/tui/trust_dialog.py +58 -0
- rlmagent/tui/worker_attach.py +141 -0
- rlmagent/ui/__init__.py +1 -0
- rlmagent/ui/render.py +211 -0
- rlmagent/undo.py +109 -0
- rlmagent/utils/__init__.py +1 -0
- rlmagent/websearch.py +75 -0
- rlmagent/worker.py +1275 -0
- rlmagent/worker_service.py +203 -0
- rlmness_cli-0.2.0.dist-info/METADATA +882 -0
- rlmness_cli-0.2.0.dist-info/RECORD +203 -0
- rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
- rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
- rlmness_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
rlmagent/autopilot.py
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
"""A goal the session keeps working toward, and turns that continue without the user.
|
|
2
|
+
|
|
3
|
+
/goal sets an objective. It is put in front of the model on every turn until the model
|
|
4
|
+
calls goal_done(summary) or the user clears it. /autonomous lets the session start its
|
|
5
|
+
own next turn while a goal is open, within turn, token and time limits. All
|
|
6
|
+
configured verification commands must pass before a goal counts as done.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ast
|
|
12
|
+
import asyncio
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import shlex
|
|
18
|
+
import subprocess
|
|
19
|
+
import time
|
|
20
|
+
from dataclasses import asdict, dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from uuid import uuid4
|
|
23
|
+
|
|
24
|
+
GATE_SECONDS = 600
|
|
25
|
+
GATE_OUTPUT = 4_000
|
|
26
|
+
STALL_TURNS = 3
|
|
27
|
+
#: A failed gate after goal_done buys this many turns past max_turns, so a
|
|
28
|
+
#: run that thought it was finished gets to fix what the check found.
|
|
29
|
+
GATE_EXTRA_TURNS = 3
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _only_reports_result(code: str) -> bool:
|
|
33
|
+
try:
|
|
34
|
+
call = ast.parse(code.strip(), mode="eval").body
|
|
35
|
+
if not (isinstance(call, ast.Call) and isinstance(call.func, ast.Name)
|
|
36
|
+
and call.func.id == "FINAL" and len(call.args) == 1 and not call.keywords):
|
|
37
|
+
return False
|
|
38
|
+
ast.literal_eval(call.args[0])
|
|
39
|
+
except (SyntaxError, ValueError, TypeError):
|
|
40
|
+
return False
|
|
41
|
+
return True
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Goal:
|
|
46
|
+
objective: str = ""
|
|
47
|
+
status: str = "none" # none, active, paused, done
|
|
48
|
+
summary: str = ""
|
|
49
|
+
token_budget: int | None = None
|
|
50
|
+
tokens_at_start: int = 0
|
|
51
|
+
started: float = 0.0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class Limits:
|
|
56
|
+
max_turns: int = 10
|
|
57
|
+
max_tokens: int | None = 500_000
|
|
58
|
+
max_seconds: float | None = 3600.0
|
|
59
|
+
gate: str | None = None
|
|
60
|
+
gates: dict[str, str] = field(default_factory=dict)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class Autopilot:
|
|
65
|
+
path: Path | None = None
|
|
66
|
+
goal: Goal = field(default_factory=Goal)
|
|
67
|
+
limits: Limits = field(default_factory=Limits)
|
|
68
|
+
autonomous: bool = False
|
|
69
|
+
turns: int = 0
|
|
70
|
+
tokens_at_start: int = 0
|
|
71
|
+
started: float = 0.0
|
|
72
|
+
stopped_because: str = ""
|
|
73
|
+
cwd: str = "."
|
|
74
|
+
used_tokens: object = None # () -> int, tokens the session has used so far
|
|
75
|
+
_last_turn: bytes | None = field(default=None, init=False, repr=False)
|
|
76
|
+
_unchanged_turns: int = field(default=0, init=False, repr=False)
|
|
77
|
+
_failed_turns: list[str] = field(default_factory=list, init=False, repr=False)
|
|
78
|
+
durable: bool = False
|
|
79
|
+
_in_flight: bool = field(default=False, init=False, repr=False)
|
|
80
|
+
_pending_prompt: str | None = field(default=None, init=False, repr=False)
|
|
81
|
+
gate_results: dict[str, dict] = field(default_factory=dict)
|
|
82
|
+
_verifying: bool = field(default=False, init=False, repr=False)
|
|
83
|
+
_gate_turns: int = field(default=0, init=False, repr=False)
|
|
84
|
+
_gate_failure: str = field(default="", init=False, repr=False)
|
|
85
|
+
|
|
86
|
+
# -- persistence -------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
def load(self) -> None:
|
|
89
|
+
if self.path is None or not self.path.exists():
|
|
90
|
+
return
|
|
91
|
+
try:
|
|
92
|
+
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
93
|
+
self.goal = Goal(**data.get("goal", {}))
|
|
94
|
+
self.limits = Limits(**data.get("limits", {}))
|
|
95
|
+
self.gate_results = data.get("gate_results", {})
|
|
96
|
+
for result in self.gate_results.values():
|
|
97
|
+
if result["status"] == "running":
|
|
98
|
+
result["status"] = "interrupted"
|
|
99
|
+
if self.durable and (state := data.get("continuation")):
|
|
100
|
+
self.turns = state["turns"]
|
|
101
|
+
self.tokens_at_start = state["tokens_at_start"]
|
|
102
|
+
self.started = state["started"]
|
|
103
|
+
self.autonomous = state["active"]
|
|
104
|
+
self._last_turn = bytes.fromhex(state["last_turn"]) if state["last_turn"] else None
|
|
105
|
+
self._unchanged_turns = state["unchanged_turns"]
|
|
106
|
+
self._failed_turns = state.get("failed_turns", [])[-2 * STALL_TURNS:]
|
|
107
|
+
self.stopped_because = state["stopped_because"]
|
|
108
|
+
self._pending_prompt = state.get("pending_prompt")
|
|
109
|
+
if state["in_flight"]:
|
|
110
|
+
self.stop_autonomous("a worker turn was interrupted; review it before continuing")
|
|
111
|
+
except (OSError, ValueError, TypeError, KeyError):
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
def save(self) -> None:
|
|
115
|
+
if self.path is not None:
|
|
116
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
data = {"goal": asdict(self.goal), "limits": asdict(self.limits)}
|
|
118
|
+
data["gate_results"] = self.gate_results
|
|
119
|
+
if self.durable:
|
|
120
|
+
data["continuation"] = {
|
|
121
|
+
"turns": self.turns, "tokens_at_start": self.tokens_at_start,
|
|
122
|
+
"started": self.started, "active": self.autonomous,
|
|
123
|
+
"in_flight": self._in_flight, "stopped_because": self.stopped_because,
|
|
124
|
+
"last_turn": self._last_turn.hex() if self._last_turn else None,
|
|
125
|
+
"unchanged_turns": self._unchanged_turns,
|
|
126
|
+
"failed_turns": self._failed_turns,
|
|
127
|
+
"pending_prompt": self._pending_prompt,
|
|
128
|
+
}
|
|
129
|
+
staged = self.path.with_suffix(f".{uuid4().hex}.tmp")
|
|
130
|
+
try:
|
|
131
|
+
staged.write_text(json.dumps(data), encoding="utf-8")
|
|
132
|
+
staged.replace(self.path)
|
|
133
|
+
finally:
|
|
134
|
+
staged.unlink(missing_ok=True)
|
|
135
|
+
|
|
136
|
+
def _tokens(self) -> int:
|
|
137
|
+
return int(self.used_tokens()) if self.used_tokens is not None else 0
|
|
138
|
+
|
|
139
|
+
# -- the goal ----------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
def set_goal(self, objective: str, token_budget: int | None = None) -> str:
|
|
142
|
+
self.goal = Goal(objective.strip(), "active", "", token_budget, self._tokens(), time.time())
|
|
143
|
+
self.gate_results.clear()
|
|
144
|
+
self._gate_turns, self._gate_failure = 0, ""
|
|
145
|
+
self._last_turn, self._unchanged_turns = None, 0
|
|
146
|
+
self._failed_turns.clear()
|
|
147
|
+
self._pending_prompt = None
|
|
148
|
+
self.save()
|
|
149
|
+
return self.describe()
|
|
150
|
+
|
|
151
|
+
def describe(self) -> str:
|
|
152
|
+
goal = self.goal
|
|
153
|
+
if goal.status == "none":
|
|
154
|
+
return "No goal. Set one with /goal TEXT [--tokens N]."
|
|
155
|
+
used = self._tokens() - goal.tokens_at_start
|
|
156
|
+
budget = f" of {goal.token_budget:,}" if goal.token_budget else ""
|
|
157
|
+
text = f"Goal ({goal.status}): {goal.objective}\nTokens used: {used:,}{budget}"
|
|
158
|
+
if goal.summary:
|
|
159
|
+
text += f"\nSummary: {goal.summary}"
|
|
160
|
+
if self.autonomous:
|
|
161
|
+
text += (f"\nAutonomous: turn {self.turns} of {self.limits.max_turns}"
|
|
162
|
+
+ (f", gates: {', '.join(self.gates())}" if self.gates() else ""))
|
|
163
|
+
return text
|
|
164
|
+
|
|
165
|
+
def reminder(self) -> str | None:
|
|
166
|
+
"""The note sent with each turn while a goal is open."""
|
|
167
|
+
if self.goal.status != "active":
|
|
168
|
+
return None
|
|
169
|
+
return (f"[Goal] Keep working toward: {self.goal.objective}\n"
|
|
170
|
+
"When it is fully achieved, call goal_done(summary) (or tell the user it is "
|
|
171
|
+
"done). Do not call it while anything is left to do.")
|
|
172
|
+
|
|
173
|
+
async def goal_status(self) -> dict:
|
|
174
|
+
"""The current goal: objective, status, summary and tokens used."""
|
|
175
|
+
return {"objective": self.goal.objective, "status": self.goal.status,
|
|
176
|
+
"summary": self.goal.summary,
|
|
177
|
+
"tokens_used": self._tokens() - self.goal.tokens_at_start,
|
|
178
|
+
"token_budget": self.goal.token_budget,
|
|
179
|
+
"gates": {name: dict(result) for name, result in self.gate_results.items()}}
|
|
180
|
+
|
|
181
|
+
async def goal_done(self, summary: str) -> str:
|
|
182
|
+
"""Mark the goal achieved, with a short summary of what was done and how it was checked."""
|
|
183
|
+
if self.goal.status != "active":
|
|
184
|
+
return "There is no active goal."
|
|
185
|
+
if self._verifying:
|
|
186
|
+
return "Goal verification is already running."
|
|
187
|
+
goal, gates = self.goal, self.gates()
|
|
188
|
+
failures = []
|
|
189
|
+
self._verifying = True
|
|
190
|
+
try:
|
|
191
|
+
for name, command in gates.items():
|
|
192
|
+
result = self.gate_results.setdefault(name, {"attempts": 0})
|
|
193
|
+
result.update(status="running", attempts=result["attempts"] + 1)
|
|
194
|
+
self.save()
|
|
195
|
+
try:
|
|
196
|
+
failure = await self.run_gate(command)
|
|
197
|
+
except asyncio.CancelledError:
|
|
198
|
+
result["status"] = "interrupted"
|
|
199
|
+
self.save()
|
|
200
|
+
raise
|
|
201
|
+
result["status"] = "failed" if failure else "passed"
|
|
202
|
+
self.save()
|
|
203
|
+
if failure:
|
|
204
|
+
failures.append(f"{name}: {failure}")
|
|
205
|
+
if self.goal is not goal or goal.status != "active" or self.gates() != gates:
|
|
206
|
+
return "Goal or verification settings changed during verification."
|
|
207
|
+
finally:
|
|
208
|
+
self._verifying = False
|
|
209
|
+
if failures:
|
|
210
|
+
report = "\n".join(failures)
|
|
211
|
+
if self._gate_turns < GATE_EXTRA_TURNS:
|
|
212
|
+
self._gate_turns += 1
|
|
213
|
+
self._gate_failure = report
|
|
214
|
+
return "The gate command failed, so the goal is still open:\n" + report
|
|
215
|
+
goal.summary = str(summary)[:2000]
|
|
216
|
+
goal.status = "done"
|
|
217
|
+
self.save()
|
|
218
|
+
return "Goal marked done."
|
|
219
|
+
|
|
220
|
+
def gates(self) -> dict[str, str]:
|
|
221
|
+
return {**({"default": self.limits.gate} if self.limits.gate else {}), **self.limits.gates}
|
|
222
|
+
|
|
223
|
+
async def run_gate(self, command: str) -> str | None:
|
|
224
|
+
from rlmagent.agents.output import OutputTail
|
|
225
|
+
from rlmagent.model.external.cli import kill_tree, own_group
|
|
226
|
+
|
|
227
|
+
tail = OutputTail()
|
|
228
|
+
options = own_group()
|
|
229
|
+
if os.name == "nt":
|
|
230
|
+
options["creationflags"] = subprocess.CREATE_NO_WINDOW
|
|
231
|
+
try:
|
|
232
|
+
argv = shlex.split(command, posix=True)
|
|
233
|
+
if not argv:
|
|
234
|
+
return "empty command"
|
|
235
|
+
process = await asyncio.create_subprocess_exec(
|
|
236
|
+
*argv, cwd=self.cwd, stdin=subprocess.DEVNULL,
|
|
237
|
+
stdout=asyncio.subprocess.PIPE, stderr=subprocess.STDOUT, **options,
|
|
238
|
+
)
|
|
239
|
+
except (OSError, ValueError) as exc:
|
|
240
|
+
return str(exc)
|
|
241
|
+
|
|
242
|
+
async def drain():
|
|
243
|
+
while chunk := await process.stdout.read(8192):
|
|
244
|
+
tail.feed(chunk)
|
|
245
|
+
await process.wait()
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
await asyncio.wait_for(drain(), GATE_SECONDS)
|
|
249
|
+
except (TimeoutError, asyncio.CancelledError) as exc:
|
|
250
|
+
kill_tree(process)
|
|
251
|
+
await process.wait()
|
|
252
|
+
if isinstance(exc, asyncio.CancelledError):
|
|
253
|
+
raise
|
|
254
|
+
return f"timed out after {GATE_SECONDS}s\n{tail.text()[-GATE_OUTPUT:]}"
|
|
255
|
+
return None if process.returncode == 0 else f"exited {process.returncode}\n{tail.text()[-GATE_OUTPUT:]}"
|
|
256
|
+
|
|
257
|
+
# -- autonomous turns --------------------------------------------------
|
|
258
|
+
|
|
259
|
+
def start_autonomous(self) -> str:
|
|
260
|
+
if self.goal.status != "active":
|
|
261
|
+
return "Set a goal first with /goal TEXT; autonomous mode works toward it."
|
|
262
|
+
self.autonomous, self.turns, self.stopped_because = True, 0, ""
|
|
263
|
+
self.tokens_at_start, self.started = self._tokens(), time.time()
|
|
264
|
+
self._last_turn, self._unchanged_turns = None, 0
|
|
265
|
+
self._failed_turns.clear()
|
|
266
|
+
self._pending_prompt = None
|
|
267
|
+
if self.durable:
|
|
268
|
+
self.save()
|
|
269
|
+
return self.describe()
|
|
270
|
+
|
|
271
|
+
def stop_autonomous(self, why: str) -> None:
|
|
272
|
+
self.autonomous, self.stopped_because = False, why
|
|
273
|
+
self._pending_prompt = None
|
|
274
|
+
if self.durable:
|
|
275
|
+
self.save()
|
|
276
|
+
|
|
277
|
+
def start_worker_turn(self, *, continuation=False) -> None:
|
|
278
|
+
if continuation:
|
|
279
|
+
self._pending_prompt = None
|
|
280
|
+
self._in_flight = True
|
|
281
|
+
self.save()
|
|
282
|
+
|
|
283
|
+
def finish_worker_turn(self) -> None:
|
|
284
|
+
self._in_flight = False
|
|
285
|
+
self.save()
|
|
286
|
+
|
|
287
|
+
def observe_turn(self, messages, backend_events=()) -> None:
|
|
288
|
+
"""Stop on repeated results or a cycle of the same failed operations."""
|
|
289
|
+
if not self.autonomous or self.goal.status != "active":
|
|
290
|
+
return
|
|
291
|
+
from rlmagent.harness.contracts.transcript import ModelEntry, ToolOutcomeEntry
|
|
292
|
+
|
|
293
|
+
reply = next((entry for entry in reversed(messages) if isinstance(entry, ModelEntry)), None)
|
|
294
|
+
if reply is not None and reply.stop_reason in ("error", "aborted"):
|
|
295
|
+
self.stop_autonomous("the last turn failed")
|
|
296
|
+
return
|
|
297
|
+
calls = {call.id: call.arguments for entry in messages if isinstance(entry, ModelEntry)
|
|
298
|
+
for call in entry.tool_calls}
|
|
299
|
+
operations = [
|
|
300
|
+
(entry.tool_name, calls.get(entry.tool_call_id), entry.is_error, " ".join(entry.text.split()))
|
|
301
|
+
for entry in messages if isinstance(entry, ToolOutcomeEntry)
|
|
302
|
+
]
|
|
303
|
+
cells = {event.get("step"): event.get("code") for event in backend_events
|
|
304
|
+
if event.get("kind") == "code_generated" and event.get("run_id") == "root"}
|
|
305
|
+
for event in backend_events:
|
|
306
|
+
if (event.get("kind") != "output_received" or event.get("run_id") != "root"
|
|
307
|
+
or event.get("step", 0) <= 0 or not (code := cells.get(event.get("step")))):
|
|
308
|
+
continue
|
|
309
|
+
output, failed = " ".join(str(event.get("output", "")).split()), bool(event.get("error"))
|
|
310
|
+
if failed or output or not _only_reports_result(code):
|
|
311
|
+
operations.append(("cell", code, failed, output))
|
|
312
|
+
if operations and all(operation[2] for operation in operations):
|
|
313
|
+
fingerprint = hashlib.sha256(json.dumps(operations, sort_keys=True).encode()).hexdigest()
|
|
314
|
+
self._failed_turns.append(fingerprint)
|
|
315
|
+
self._failed_turns = self._failed_turns[-2 * STALL_TURNS:]
|
|
316
|
+
for period in (1, 2):
|
|
317
|
+
pattern = self._failed_turns[-period:]
|
|
318
|
+
if len(pattern) == period and self._failed_turns[-period * STALL_TURNS:] == pattern * STALL_TURNS:
|
|
319
|
+
self.stop_autonomous("the same failed operations repeated without progress")
|
|
320
|
+
return
|
|
321
|
+
else:
|
|
322
|
+
self._failed_turns.clear()
|
|
323
|
+
answer = " ".join(reply.text.split()) if reply is not None else ""
|
|
324
|
+
digest = hashlib.sha256(json.dumps([answer, operations], sort_keys=True).encode()).digest()
|
|
325
|
+
self._unchanged_turns = self._unchanged_turns + 1 if digest == self._last_turn else 1
|
|
326
|
+
self._last_turn = digest
|
|
327
|
+
if self._unchanged_turns >= STALL_TURNS:
|
|
328
|
+
self.stop_autonomous(f"no visible progress across {STALL_TURNS} turns")
|
|
329
|
+
|
|
330
|
+
def next_prompt(self) -> str | None:
|
|
331
|
+
"""After a turn: the text of the next turn to start on its own, or None to stop."""
|
|
332
|
+
if not self.autonomous:
|
|
333
|
+
return None
|
|
334
|
+
goal, limits = self.goal, self.limits
|
|
335
|
+
used = self._tokens() - self.tokens_at_start
|
|
336
|
+
if goal.status != "active":
|
|
337
|
+
self.stop_autonomous("goal is " + goal.status)
|
|
338
|
+
elif self._pending_prompt is None and self.turns >= limits.max_turns + self._gate_turns:
|
|
339
|
+
self.stop_autonomous(f"reached {limits.max_turns} turns")
|
|
340
|
+
elif limits.max_tokens and used >= limits.max_tokens:
|
|
341
|
+
self.stop_autonomous(f"used {used:,} tokens")
|
|
342
|
+
elif goal.token_budget and self._tokens() - goal.tokens_at_start >= goal.token_budget:
|
|
343
|
+
self.stop_autonomous("goal token budget spent")
|
|
344
|
+
elif limits.max_seconds and time.time() - self.started >= limits.max_seconds:
|
|
345
|
+
self.stop_autonomous(f"ran {limits.max_seconds:.0f}s")
|
|
346
|
+
if not self.autonomous:
|
|
347
|
+
return None
|
|
348
|
+
if self._pending_prompt is not None:
|
|
349
|
+
return self._pending_prompt
|
|
350
|
+
self.turns += 1
|
|
351
|
+
prompt = ("Continue toward the goal. Check what is left, do the next piece, and verify it. "
|
|
352
|
+
"Call goal_done(summary) only when everything is finished.")
|
|
353
|
+
if self._gate_failure:
|
|
354
|
+
prompt = ("goal_done was refused because a check failed:\n"
|
|
355
|
+
f"{self._gate_failure[:GATE_OUTPUT]}\n\nFix what it reports, then call "
|
|
356
|
+
"goal_done(summary) again.")
|
|
357
|
+
self._gate_failure = ""
|
|
358
|
+
if self.durable:
|
|
359
|
+
self._pending_prompt = prompt
|
|
360
|
+
self.save()
|
|
361
|
+
return prompt
|
|
362
|
+
|
|
363
|
+
def command_goal(self, arg: str) -> str:
|
|
364
|
+
words = arg.split()
|
|
365
|
+
if not words:
|
|
366
|
+
return self.describe()
|
|
367
|
+
if words[0] in ("pause", "resume", "clear"):
|
|
368
|
+
if self.goal.status == "none":
|
|
369
|
+
return "No goal."
|
|
370
|
+
if words[0] == "clear":
|
|
371
|
+
self.goal = Goal()
|
|
372
|
+
self.stop_autonomous("goal cleared")
|
|
373
|
+
else:
|
|
374
|
+
self.goal.status = "paused" if words[0] == "pause" else "active"
|
|
375
|
+
self.save()
|
|
376
|
+
return self.describe()
|
|
377
|
+
budget = None
|
|
378
|
+
if "--tokens" in words:
|
|
379
|
+
at = words.index("--tokens")
|
|
380
|
+
try:
|
|
381
|
+
budget = int(words[at + 1])
|
|
382
|
+
except (IndexError, ValueError):
|
|
383
|
+
return "Usage: /goal TEXT [--tokens N] | pause | resume | clear"
|
|
384
|
+
words = words[:at] + words[at + 2:]
|
|
385
|
+
return self.set_goal(" ".join(words), budget)
|
|
386
|
+
|
|
387
|
+
def command_autonomous(self, arg: str) -> str:
|
|
388
|
+
words = arg.split()
|
|
389
|
+
if not words or words == ["on"]:
|
|
390
|
+
return self.start_autonomous()
|
|
391
|
+
if words[0] == "off":
|
|
392
|
+
self.stop_autonomous("turned off")
|
|
393
|
+
return "Autonomous mode off."
|
|
394
|
+
if words[0] == "status":
|
|
395
|
+
state = "on" if self.autonomous else f"off{' (' + self.stopped_because + ')' if self.stopped_because else ''}"
|
|
396
|
+
return (f"Autonomous mode {state}. Limits: {self.limits.max_turns} turns, "
|
|
397
|
+
f"{self.limits.max_tokens or 'no'} tokens, {self.limits.max_seconds or 'no'} "
|
|
398
|
+
f"seconds, gates: {', '.join(self.gates()) or 'none'}.")
|
|
399
|
+
if words[0] in ("turns", "tokens", "seconds") and len(words) == 2:
|
|
400
|
+
try:
|
|
401
|
+
value = float(words[1])
|
|
402
|
+
except ValueError:
|
|
403
|
+
return "Give a number."
|
|
404
|
+
if words[0] == "turns":
|
|
405
|
+
self.limits.max_turns = int(value)
|
|
406
|
+
elif words[0] == "tokens":
|
|
407
|
+
self.limits.max_tokens = int(value) or None
|
|
408
|
+
else:
|
|
409
|
+
self.limits.max_seconds = value or None
|
|
410
|
+
self.save()
|
|
411
|
+
return self.command_autonomous("status")
|
|
412
|
+
if words[0] == "gate":
|
|
413
|
+
_, *command_parts = arg.strip().split(maxsplit=1)
|
|
414
|
+
command = command_parts[0] if command_parts else ""
|
|
415
|
+
action, *arguments = command.split(maxsplit=1) or [""]
|
|
416
|
+
if action == "list":
|
|
417
|
+
return "\n".join(
|
|
418
|
+
f"{name}: {cmd} ({self.gate_results.get(name, {}).get('status', 'pending')})"
|
|
419
|
+
for name, cmd in self.gates().items()
|
|
420
|
+
) or "No verification gates."
|
|
421
|
+
if self._verifying:
|
|
422
|
+
return "Wait for the active verification to finish."
|
|
423
|
+
if action in ("add", "remove"):
|
|
424
|
+
name, *gate_parts = (arguments[0].split(maxsplit=1) if arguments else [""])
|
|
425
|
+
gate = gate_parts[0] if gate_parts else ""
|
|
426
|
+
if not re.fullmatch(r"[a-zA-Z0-9_-]{1,40}", name) or name == "default":
|
|
427
|
+
return "Use a gate name of 1 to 40 letters, numbers, hyphens, or underscores."
|
|
428
|
+
if action == "add":
|
|
429
|
+
if not gate.strip():
|
|
430
|
+
return "Use /autonomous gate add NAME COMMAND."
|
|
431
|
+
self.limits.gates[name] = gate.strip()
|
|
432
|
+
else:
|
|
433
|
+
self.limits.gates.pop(name, None)
|
|
434
|
+
self.gate_results.pop(name, None)
|
|
435
|
+
else:
|
|
436
|
+
self.limits.gate = command or None
|
|
437
|
+
self.gate_results.pop("default", None)
|
|
438
|
+
self.save()
|
|
439
|
+
return self.command_autonomous("status")
|
|
440
|
+
return ("Usage: /autonomous [on | off | status | turns N | tokens N | seconds N | "
|
|
441
|
+
"gate CMD | gate list | gate add NAME CMD | gate remove NAME]")
|
|
442
|
+
|
|
443
|
+
def functions(self) -> dict:
|
|
444
|
+
return {"goal": self.goal_status, "goal_done": self.goal_done}
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
__all__ = ["Autopilot", "Goal", "Limits"]
|