rlmness-cli 0.2.0__py3-none-any.whl

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