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,397 @@
1
+ """Checkpoint kept recursive agents alongside a saved conversation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import uuid
8
+ from pathlib import Path
9
+
10
+ from rlmness.background import Background, Job
11
+ from rlmness.session import Session
12
+
13
+ from rlmagent.agents.child_handles import checked_name
14
+ from rlmagent.agents.child_mailbox import notes_metadata, read_note, record_notes
15
+
16
+
17
+ class KeptAgents(Background):
18
+ def __init__(self, max_agents):
19
+ super().__init__(max_agents)
20
+ self.directory: Path | None = None
21
+ self.saved: dict[str, dict] = {}
22
+ self.live_states: dict[str, Session] = {}
23
+ self.names: dict[str, str] = {}
24
+ self.notes: dict[str, list[dict]] = {}
25
+ self.live_deliveries: dict[str, dict] = {}
26
+ self.steering = None
27
+ self.record_event = None
28
+
29
+ def _progress(self, key, text):
30
+ if self.record_event is not None:
31
+ self.record_event({"kind": "child_progress", "child_id": key, "text": str(text)[:500]})
32
+
33
+ def _expire_steering(self, key: str) -> None:
34
+ if self.steering is not None:
35
+ self.steering.expire_child(key)
36
+
37
+ def close(self):
38
+ super().close()
39
+ for state in list(self.live_states.values()):
40
+ state.close_runtime()
41
+ self.live_states.clear()
42
+
43
+ def open(self, state_path: Path | None) -> None:
44
+ if state_path is None or self.directory is not None:
45
+ return
46
+ self.directory = state_path.with_suffix(".children")
47
+ for path in sorted(self.directory.glob("*.json")):
48
+ if not re.fullmatch(r"[a-f0-9]{32}", path.stem):
49
+ continue
50
+ record = json.loads(path.read_text(encoding="utf-8"))
51
+ deliveries = record.get("deliveries", {})
52
+ interrupted = [item for item in deliveries.values() if item["status"] == "running"]
53
+ for item in interrupted:
54
+ item["status"] = "interrupted"
55
+ job = Job(parent=record.get("parent", "root"), keep=True, status="idle")
56
+ job.value = record.get("value")
57
+ job.outbox = record.get("messages", [])
58
+ job.ready.set()
59
+ self.saved[path.stem] = record
60
+ self.jobs[path.stem] = job
61
+ if name := record.get("name"):
62
+ self.names[path.stem] = name
63
+ migrated = "notes" not in record and bool(job.outbox)
64
+ if "notes" not in record:
65
+ record["notes"] = record_notes([], [], job.outbox)
66
+ self.notes[path.stem] = record["notes"]
67
+ if interrupted:
68
+ self._expire_steering(path.stem)
69
+ if interrupted or migrated:
70
+ self._write(path.stem)
71
+
72
+ def _write(self, key: str, record=None) -> None:
73
+ self.directory.mkdir(parents=True, exist_ok=True)
74
+ path = self.directory / f"{key}.json"
75
+ staged = path.with_suffix(f".{uuid.uuid4().hex}.tmp")
76
+ try:
77
+ data = self.saved[key] if record is None else record
78
+ staged.write_text(json.dumps(data, allow_nan=False), encoding="utf-8")
79
+ staged.replace(path)
80
+ finally:
81
+ staged.unlink(missing_ok=True)
82
+
83
+ def node_records(self):
84
+ with self.lock:
85
+ return {key: dict(record) for key, record in self.saved.items()}
86
+
87
+ def node_state(self, key):
88
+ with self.lock:
89
+ state = self.live_states.get(key)
90
+ if state is None:
91
+ state = (Session.load(self.directory / f"{key}.state.json")
92
+ if self.directory is not None else Session())
93
+ self.live_states[key] = state
94
+ return state
95
+
96
+ def _save_notes(self, key, notes, messages):
97
+ record = self.saved.get(key)
98
+ if record is not None:
99
+ updated = {**record, "notes": notes, "messages": messages}
100
+ self._write(key, updated)
101
+ # Legacy workers retain a reference to the saved record while running.
102
+ record.update(updated)
103
+ self.notes[key] = notes
104
+
105
+ def watch_snapshot(self, key):
106
+ with self.lock:
107
+ job = self.jobs.get(key)
108
+ if job is None:
109
+ return {"status": "missing", "notes": 0, "settled": 0}
110
+ record = self.saved.get(key, {})
111
+ deliveries = record.get("deliveries", self.live_deliveries.get(key, {}))
112
+ return {"status": job.status, "notes": len(self.notes.get(key, [])),
113
+ "settled": sum(item["status"] not in ("queued", "running")
114
+ for item in deliveries.values())}
115
+
116
+ def child_handle(self, parent, action, key=None, name=None, *, identity=None, new_only=True):
117
+ with self.lock:
118
+ if action in ("name", "find"):
119
+ name = checked_name(name)
120
+ match = next((other for other, alias in self.names.items()
121
+ if alias == name and self.jobs[other].parent == parent
122
+ and self.jobs[other].status != "cancelled"), None)
123
+ if action == "find":
124
+ if match is None:
125
+ raise ValueError(f"no child named {name!r}")
126
+ return {"id": match}
127
+ if match is not None and match != key:
128
+ raise ValueError(f"child name {name!r} is already in use")
129
+ job = self.jobs[key]
130
+ if job.parent != parent or job.status == "cancelled":
131
+ raise ValueError("child is not available to this agent")
132
+ notes = self.notes.get(key, [])
133
+ if action == "messages":
134
+ return notes_metadata(notes, new_only)
135
+ if action == "read_message":
136
+ updated, note = read_note(notes, identity)
137
+ if updated is not notes:
138
+ self._save_notes(key, updated, job.outbox)
139
+ return note
140
+ if action == "delivery":
141
+ deliveries = self.saved.get(key, {}).get("deliveries", self.live_deliveries.get(key, {}))
142
+ item = deliveries.get(identity)
143
+ return {"id": identity, "status": item["status"] if item else "unknown"}
144
+ if action == "name":
145
+ if key in self.saved:
146
+ old = self.saved[key].get("name")
147
+ self.saved[key]["name"] = name
148
+ try:
149
+ self._write(key)
150
+ except OSError:
151
+ if old is None:
152
+ self.saved[key].pop("name", None)
153
+ else:
154
+ self.saved[key]["name"] = old
155
+ raise
156
+ self.names[key] = name
157
+ if self.record_event is not None:
158
+ self.record_event({"kind": "child_named", "child_id": key, "name": name})
159
+ elif action != "info":
160
+ raise ValueError("invalid child handle operation")
161
+ deliveries = self.saved.get(key, {}).get("deliveries", {})
162
+ return {"id": key, "name": self.names.get(key), "status": job.status, "keep": job.keep,
163
+ "queued": sum(item["status"] == "queued" for item in deliveries.values())}
164
+
165
+ def node_record(self, key, record):
166
+ """Mirror an execution-side job and persist its settled mailbox."""
167
+ if not re.fullmatch(r"[a-f0-9]{32}", key):
168
+ raise ValueError("invalid child identity")
169
+ with self.lock:
170
+ job = self.jobs.setdefault(key, Job(parent=record["parent"], keep=record["keep"]))
171
+ prior = self.saved.get(key, {}).get("deliveries", {})
172
+ current = record.get("deliveries", {})
173
+ if record["status"] in ("idle", "done", "failed", "cancelled") or any(
174
+ item["status"] == "running" and current.get(receipt, {}).get("status") != "running"
175
+ for receipt, item in prior.items()
176
+ ):
177
+ self._expire_steering(key)
178
+ previous_messages = job.outbox
179
+ messages = list(record.get("messages", []))
180
+ notes = record_notes(self.notes.get(key, []), previous_messages, messages)
181
+ if record["status"] != "cancelled" and job.keep and self.directory is not None:
182
+ saved = {**record, "notes": notes}
183
+ if name := self.names.get(key):
184
+ saved["name"] = name
185
+ self._write(key, saved)
186
+ self.saved[key] = saved
187
+ self.notes[key] = notes
188
+ if key not in self.saved and current:
189
+ self.live_deliveries[key] = current
190
+ job.status = record["status"]
191
+ job.value = record.get("value")
192
+ job.outbox = messages
193
+ if job.status in ("idle", "done", "failed", "cancelled"):
194
+ job.ready.set()
195
+ else:
196
+ job.ready.clear()
197
+ if job.status == "cancelled":
198
+ self.names.pop(key, None)
199
+ job.token.set()
200
+ self.saved.pop(key, None)
201
+ self.notes.pop(key, None)
202
+ self.live_deliveries.pop(key, None)
203
+ state = self.live_states.pop(key, None)
204
+ if state is not None:
205
+ state.close_runtime()
206
+ if self.directory is not None:
207
+ (self.directory / f"{key}.json").unlink(missing_ok=True)
208
+ (self.directory / f"{key}.state.json").unlink(missing_ok=True)
209
+ if job.outbox[:len(previous_messages)] == previous_messages:
210
+ for message in job.outbox[len(previous_messages):]:
211
+ self._progress(key, message)
212
+
213
+ def bindings(self, parent, child):
214
+ owner = self.owners.get(parent)
215
+ if owner in self.saved:
216
+ # Each reopened agent gets a fresh run ID. Its mailbox and children use its saved ID.
217
+ parent = owner
218
+ self.owners[parent] = owner
219
+ bindings = super().bindings(parent, child)
220
+ tell_parent = bindings["tell_parent"]
221
+
222
+ def tell(text):
223
+ with self.lock:
224
+ key = self.owners.get(parent)
225
+ if key is None:
226
+ return tell_parent(text)
227
+ job = self.jobs[key]
228
+ messages = [*job.outbox, text]
229
+ notes = record_notes(self.notes.get(key, []), job.outbox, messages)
230
+ self._save_notes(key, notes, messages)
231
+ job.outbox = messages
232
+ self._progress(key, text)
233
+
234
+ bindings["tell_parent"] = tell
235
+ def messages(key):
236
+ with self.lock:
237
+ job = self.jobs[key]
238
+ if job.parent != parent:
239
+ raise ValueError("job does not belong to this agent")
240
+ items = job.outbox
241
+ if items:
242
+ self._save_notes(key, record_notes(self.notes.get(key, []), items, []), [])
243
+ job.outbox = []
244
+ return items
245
+
246
+ bindings["_messages"] = messages
247
+ if (parent != "root" and parent not in self.saved) or self.directory is None:
248
+ return bindings
249
+
250
+ def enqueue(record, text):
251
+ receipt = uuid.uuid4().hex
252
+ record.setdefault("deliveries", {})[receipt] = {"status": "queued", "text": text}
253
+ return receipt
254
+
255
+ def start(key):
256
+ job = self.jobs[key]
257
+ record = self.saved[key]
258
+ job.status = "starting"
259
+ job.error = None
260
+ job.ready.clear()
261
+
262
+ def work():
263
+ current = None
264
+ try:
265
+ with self.lock:
266
+ state = self.live_states.get(key)
267
+ if state is None:
268
+ state = Session.load(self.directory / f"{key}.state.json")
269
+ self.live_states[key] = state
270
+ while not job.token.is_set():
271
+ with self.lock:
272
+ if job.token.is_set():
273
+ return
274
+ current = next((item for item in record.get("deliveries", {}).values()
275
+ if item["status"] == "queued"), None)
276
+ if current is None:
277
+ job.status = "idle"
278
+ job.ready.set()
279
+ return
280
+ question = current.pop("text")
281
+ current["status"] = "running"
282
+ job.status = "running"
283
+ self._write(key)
284
+ value = child(record["data"], question, job.token, saved=state,
285
+ owner=key, model=record["model"])
286
+ with self.lock:
287
+ if job.token.is_set():
288
+ return
289
+ try:
290
+ json.dumps(value, allow_nan=False)
291
+ except (ValueError, TypeError):
292
+ record["value"] = str(value)
293
+ else:
294
+ record["value"] = value
295
+ record["messages"] = list(job.outbox)
296
+ current["status"] = "done"
297
+ self._expire_steering(key)
298
+ self._write(key)
299
+ job.value = value
300
+ except Exception as exc:
301
+ with self.lock:
302
+ if not job.token.is_set():
303
+ job.error = exc
304
+ job.status = "failed"
305
+ if current is not None and current["status"] == "running":
306
+ current["status"] = "failed"
307
+ job.ready.set()
308
+ self._expire_steering(key)
309
+ self._write(key)
310
+
311
+ job.future = self.pool.submit(work)
312
+
313
+ def spawn(task, data=None, keep=False, role=None, model=None):
314
+ if not keep:
315
+ return bindings["_spawn"](task, data, keep, role, model)
316
+ if role:
317
+ if self.resolve_role is None:
318
+ raise ValueError("no role resolver configured")
319
+ task = self.resolve_role(role) + "\n\n" + task
320
+ record = {"data": data, "model": model, "parent": parent}
321
+ json.dumps(record, allow_nan=False)
322
+ with self.lock:
323
+ if len(self.jobs) >= self.max_agents:
324
+ raise RuntimeError("background agent limit reached")
325
+ key = uuid.uuid4().hex
326
+ enqueue(record, task)
327
+ self.saved[key] = record
328
+ self.jobs[key] = Job(parent=parent, keep=True)
329
+ try:
330
+ self._write(key)
331
+ except OSError:
332
+ del self.saved[key]
333
+ del self.jobs[key]
334
+ raise
335
+ start(key)
336
+ return key
337
+
338
+ def send(key, text):
339
+ if key not in self.saved:
340
+ return bindings["_send"](key, text)
341
+ with self.lock:
342
+ job = self.jobs[key]
343
+ if job.parent != parent:
344
+ raise ValueError("job does not belong to this agent")
345
+ record = self.saved[key]
346
+ receipt = enqueue(record, str(text))
347
+ try:
348
+ self._write(key)
349
+ except OSError:
350
+ del record["deliveries"][receipt]
351
+ raise
352
+ if job.status in ("idle", "failed"):
353
+ start(key)
354
+ return receipt
355
+
356
+ def delivery(key, receipt):
357
+ with self.lock:
358
+ if self.jobs[key].parent != parent:
359
+ raise ValueError("job does not belong to this agent")
360
+ item = self.saved.get(key, {}).get("deliveries", {}).get(receipt)
361
+ return item["status"] if item is not None else None
362
+
363
+ def cancel(key):
364
+ with self.lock:
365
+ bindings["_cancel"](key)
366
+ pending = [key]
367
+ while pending:
368
+ item = pending.pop()
369
+ self.names.pop(item, None)
370
+ self.notes.pop(item, None)
371
+ self._expire_steering(item)
372
+ if item not in self.saved:
373
+ continue
374
+ del self.saved[item]
375
+ job = self.jobs[item]
376
+ job.token.set()
377
+ live = self.live_states.pop(item, None)
378
+ if live is not None:
379
+ live.close_runtime()
380
+ job.status = "cancelled"
381
+ job.ready.set()
382
+ pending.extend(k for k, record in self.saved.items() if record.get("parent") == item)
383
+ (self.directory / f"{item}.json").unlink(missing_ok=True)
384
+ state = self.directory / f"{item}.state.json"
385
+ if job.future is not None:
386
+ job.future.add_done_callback(lambda _future, path=state: path.unlink(missing_ok=True))
387
+ else:
388
+ state.unlink(missing_ok=True)
389
+
390
+ with self.lock:
391
+ for key, record in self.saved.items():
392
+ if (self.jobs[key].parent == parent and self.jobs[key].status == "idle"
393
+ and any(item["status"] == "queued" for item in record.get("deliveries", {}).values())):
394
+ start(key)
395
+
396
+ return {**bindings, "_spawn": spawn, "_send": send, "_cancel": cancel, "_messages": messages,
397
+ "_delivery": delivery}
@@ -0,0 +1,5 @@
1
+ """A persistent IPython kernel per agent."""
2
+
3
+ from rlmagent.kernel.session import CellResult, KernelSession, clip
4
+
5
+ __all__ = ["CellResult", "KernelSession", "clip"]
@@ -0,0 +1,205 @@
1
+ """One IPython kernel, started on first use, that keeps its variables between cells."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import os
8
+ import re
9
+ from collections.abc import Mapping
10
+ from dataclasses import dataclass
11
+
12
+ from jupyter_client.manager import AsyncKernelManager
13
+ from rlmness.environment import execution_environment
14
+
15
+ _ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
16
+ _POLL = 0.5
17
+ _STUCK = "__still_running_after_interrupt__"
18
+ _UNAVAILABLE = (
19
+ "The kernel is unavailable: it stopped again after {} restarts in a row and will "
20
+ "not be restarted. Finish the task without the python tool."
21
+ )
22
+
23
+
24
+ @dataclass
25
+ class CellResult:
26
+ output: str
27
+ error: str | None = None
28
+ timed_out: bool = False
29
+ restarted: bool = False
30
+
31
+
32
+ def clip(text: str, limit: int) -> str:
33
+ """Keep the start and the end of long output, and say how much was dropped."""
34
+ if len(text) <= limit:
35
+ return text
36
+ half = limit // 2
37
+ dropped = len(text) - 2 * half
38
+ return f"{text[:half]}\n... [{dropped:,} characters omitted] ...\n{text[-half:]}"
39
+
40
+
41
+ class KernelSession:
42
+ def __init__(
43
+ self,
44
+ cwd: str,
45
+ env: Mapping[str, str] | None = None,
46
+ startup_code: str = "",
47
+ timeout: float = 300.0,
48
+ output_limit: int = 20_000,
49
+ interrupt_grace: float = 10.0,
50
+ max_restarts: int = 3,
51
+ ) -> None:
52
+ self.cwd = cwd
53
+ self.env = dict(env or {})
54
+ self.startup_code = startup_code
55
+ self.timeout = timeout
56
+ self.output_limit = output_limit
57
+ self.interrupt_grace = interrupt_grace
58
+ self.max_restarts = max_restarts
59
+ self._restarts_in_row = 0
60
+ self._waiting_outside = 0
61
+ self._manager: AsyncKernelManager | None = None
62
+ self._client = None
63
+ self._lock = asyncio.Lock()
64
+
65
+ @contextlib.contextmanager
66
+ def waiting_outside(self):
67
+ """Pause the cell timeout while the cell awaits sub-agents."""
68
+ self._waiting_outside += 1
69
+ try:
70
+ yield
71
+ finally:
72
+ self._waiting_outside -= 1
73
+
74
+ @property
75
+ def started(self) -> bool:
76
+ return self._manager is not None
77
+
78
+ async def _start(self) -> str | None:
79
+ """Start the kernel; return the setup code's error, if any."""
80
+ manager = AsyncKernelManager(kernel_name="python3")
81
+ try:
82
+ await manager.start_kernel(
83
+ cwd=self.cwd,
84
+ env=execution_environment(self.env),
85
+ extra_arguments=["--IPKernelApp.log_level=ERROR"],
86
+ )
87
+ client = manager.client()
88
+ client.start_channels()
89
+ await client.wait_for_ready(timeout=60)
90
+ except Exception:
91
+ try:
92
+ await manager.shutdown_kernel(now=True)
93
+ except Exception:
94
+ pass
95
+ raise
96
+ self._manager, self._client = manager, client
97
+ if self.startup_code:
98
+ return (await self._execute(self.startup_code, self.timeout)).error
99
+ return None
100
+
101
+ async def _alive(self) -> bool:
102
+ return self._manager is not None and await self._manager.is_alive()
103
+
104
+ async def _restart(self) -> str | None:
105
+ await self.shutdown()
106
+ return await self._start()
107
+
108
+ async def run(self, code: str, timeout: float | None = None) -> CellResult:
109
+ async with self._lock:
110
+ restarted = False
111
+ try:
112
+ if self._manager is None:
113
+ setup_error = await self._start()
114
+ elif not await self._alive():
115
+ if self._restarts_in_row >= self.max_restarts:
116
+ return CellResult(output="", error=_UNAVAILABLE.format(self._restarts_in_row))
117
+ setup_error = await self._restart()
118
+ self._restarts_in_row += 1
119
+ restarted = True
120
+ else:
121
+ setup_error = None
122
+ except Exception as exc:
123
+ return CellResult(output="", error=f"The kernel could not start: {exc}")
124
+ if setup_error:
125
+ return CellResult(output="", error=f"Kernel setup failed:\n{setup_error}")
126
+ result = await self._execute(code, timeout or self.timeout)
127
+ if result.error == _STUCK:
128
+ # Ignored the interrupt (a blocking call can): a fresh kernel beats a stuck one.
129
+ await self._restart()
130
+ self._restarts_in_row += 1
131
+ result.error = None
132
+ restarted = True
133
+ elif result.error == "__kernel_died__":
134
+ result.error = "The kernel process exited while running this cell."
135
+ else:
136
+ self._restarts_in_row = 0
137
+ result.restarted = restarted
138
+ result.output = clip(result.output, self.output_limit)
139
+ return result
140
+
141
+ async def _execute(self, code: str, timeout: float) -> CellResult:
142
+ client = self._client
143
+ msg_id = client.execute(code, store_history=True, allow_stdin=False)
144
+ loop = asyncio.get_running_loop()
145
+ deadline = loop.time() + timeout
146
+ parts: list[str] = []
147
+ error: str | None = None
148
+ timed_out = False
149
+ while True:
150
+ if self._waiting_outside and not timed_out:
151
+ deadline = loop.time() + timeout
152
+ remaining = deadline - loop.time()
153
+ if remaining <= 0 and not timed_out:
154
+ timed_out = True
155
+ await self._manager.interrupt_kernel()
156
+ deadline = loop.time() + self.interrupt_grace
157
+ continue
158
+ if remaining <= 0:
159
+ return CellResult(output="".join(parts), error=_STUCK, timed_out=True)
160
+ try:
161
+ msg = await asyncio.wait_for(client.get_iopub_msg(), min(_POLL, remaining))
162
+ except (asyncio.TimeoutError, TimeoutError):
163
+ if not await self._alive():
164
+ return CellResult(output="".join(parts), error="__kernel_died__")
165
+ continue
166
+ if msg.get("parent_header", {}).get("msg_id") != msg_id:
167
+ continue
168
+ kind, content = msg["msg_type"], msg["content"]
169
+ if kind == "stream":
170
+ parts.append(content["text"])
171
+ elif kind in ("execute_result", "display_data"):
172
+ text = content.get("data", {}).get("text/plain")
173
+ if text:
174
+ parts.append(text + "\n")
175
+ elif kind == "error":
176
+ error = _ANSI.sub("", "\n".join(content.get("traceback", [])))
177
+ elif kind == "status" and content.get("execution_state") == "idle":
178
+ break
179
+ if timed_out and error and "KeyboardInterrupt" in error:
180
+ error = None
181
+ return CellResult(output="".join(parts), error=error, timed_out=timed_out)
182
+
183
+ async def kill(self) -> None:
184
+ """Stop the kernel now, even mid-cell; the next cell gets a fresh one."""
185
+ manager = self._manager
186
+ if manager is not None:
187
+ try:
188
+ await manager.shutdown_kernel(now=True)
189
+ except Exception:
190
+ pass
191
+
192
+ async def interrupt(self) -> None:
193
+ if self._manager is not None:
194
+ await self._manager.interrupt_kernel()
195
+
196
+ async def shutdown(self) -> None:
197
+ client, manager = self._client, self._manager
198
+ self._client = self._manager = None
199
+ if client is not None:
200
+ client.stop_channels()
201
+ if manager is not None:
202
+ try:
203
+ await manager.shutdown_kernel(now=True)
204
+ except Exception:
205
+ pass