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/kept_agents.py
ADDED
|
@@ -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,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
|