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/backend.py ADDED
@@ -0,0 +1,946 @@
1
+ """Adapt the backend's recursive solver to the application's model providers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextvars
7
+ import copy
8
+ import json
9
+ import queue
10
+ import re
11
+ import threading
12
+ from concurrent.futures import TimeoutError as FutureTimeout
13
+ from dataclasses import asdict, is_dataclass
14
+ from pathlib import Path
15
+ from time import time
16
+
17
+ from rlmness import Config, solve
18
+ from rlmness.providers import Spend
19
+ from rlmness.session import Session
20
+ from rlmness.wasm_runtime import WasmRuntime
21
+
22
+ from rlmagent.agents.workspace import Workspace
23
+ from rlmagent.harness.contracts.transcript import (
24
+ HumanEntry,
25
+ ImageSegment,
26
+ ModelEntry,
27
+ TextSegment,
28
+ UsageStats,
29
+ )
30
+ from rlmagent.harness.provider.wire import ContentChunkEvent, StreamCloseEvent, StreamFaultEvent
31
+ from rlmagent.kept_agents import KeptAgents
32
+
33
+
34
+ def wait_for_host(future, agent, background):
35
+ while True:
36
+ key = background.owners.get(agent) if background is not None else None
37
+ if key is not None and background.jobs[key].token.is_set():
38
+ future.cancel()
39
+ from rlmness.limits import Abandoned
40
+
41
+ raise Abandoned("background agent cancelled")
42
+ try:
43
+ return future.result(timeout=0.1)
44
+ except FutureTimeout:
45
+ if future.done():
46
+ raise
47
+
48
+
49
+ _STATE_FIELDS = ("answered", "asking", "cells", "variables", "functions", "modules", "dropped")
50
+ MAX_CHECKPOINT_CHARS = 20_000_000
51
+
52
+
53
+ def limits_of(tree) -> dict:
54
+ """The settings in force, read from where they are enforced."""
55
+ allowance = tree.allowance
56
+ return {
57
+ "approval": tree.approver.policy,
58
+ "network": tree.network,
59
+ "kernel": getattr(tree, "kernel_kind", None),
60
+ "max_depth": allowance.max_depth,
61
+ "calls_used": allowance.calls,
62
+ "max_calls": allowance.max_calls,
63
+ "cost_used": allowance.cost,
64
+ "max_cost": allowance.max_cost,
65
+ }
66
+
67
+
68
+ SHOWN_OUTPUT_LINES = 8
69
+
70
+
71
+ def summed_usage(accounting):
72
+ return UsageStats(**{name: sum(getattr(item, name) or 0 for item in accounting) for name in
73
+ ("input", "output", "total_tokens", "cache_read", "cache_write", "cache_write_1h", "reasoning")},
74
+ cost={name: sum(getattr(item.cost, name) for item in accounting) for name in
75
+ ("total", "input", "output", "cache_read", "cache_write")})
76
+
77
+
78
+ def shown_output(output: str) -> str:
79
+ """A cell's output for the screen: the start of it. The model still gets all of it."""
80
+ lines = output.splitlines()
81
+ if len(lines) <= SHOWN_OUTPUT_LINES:
82
+ return output
83
+ return "\n".join(lines[:SHOWN_OUTPUT_LINES]) + f"\n... ({len(lines) - SHOWN_OUTPUT_LINES} more lines)"
84
+
85
+
86
+ #: The engine's own first cells (its helpers, then the context): replayed after a restart.
87
+ SETUP_CELLS = 2
88
+ MAX_RESTARTS = 3
89
+
90
+ RESTARTED = ("The kernel stopped (it crashed or was killed) and has been started again. "
91
+ "Variables, imports and functions from this run are gone; set up again what "
92
+ "you need. Files on disk and background jobs are unaffected.")
93
+
94
+
95
+ def shape_cells(runtime, policy, rebuild=None) -> None:
96
+ """Cap what a cell shows the model, add a hint when it failed in a familiar way, and
97
+ start a fresh kernel when this one dies, rather than ending the run."""
98
+ from rlmness.runtime import RuntimeGone
99
+
100
+ from rlmagent.hints import hint_for
101
+ from rlmagent.policy import DEFAULT_OUTPUT_BYTES, cap_output
102
+
103
+ limit = getattr(policy, "max_tool_output_bytes", DEFAULT_OUTPUT_BYTES)
104
+ setup: list[str] = []
105
+
106
+ def run(code):
107
+ return type(runtime).execute(runtime, code)
108
+
109
+ def restart():
110
+ fresh = rebuild()
111
+ # The engine holds this object, so the new kernel moves into it.
112
+ runtime.__dict__.clear()
113
+ runtime.__dict__.update(fresh.__dict__)
114
+ runtime.execute = execute
115
+ for cell in setup:
116
+ run(cell)
117
+
118
+ def execute(code):
119
+ if len(setup) < SETUP_CELLS:
120
+ setup.append(code)
121
+ try:
122
+ outcome = run(code)
123
+ except RuntimeGone:
124
+ restarts = getattr(runtime, "restarts", 0)
125
+ if rebuild is None or restarts >= MAX_RESTARTS:
126
+ raise
127
+ restart()
128
+ runtime.restarts = restarts + 1
129
+ from rlmness.runtime import CellOutcome
130
+
131
+ return CellOutcome(error=RESTARTED)
132
+ outcome.stdout = cap_output(outcome.stdout or "", limit)
133
+ hint = hint_for(code, (outcome.stdout or "") + "\n" + (outcome.error or ""))
134
+ if hint and outcome.error:
135
+ outcome.error = f"{outcome.error}\nHint: {hint}"
136
+ elif hint and code is not None and not code.strip():
137
+ outcome.stdout = f"{outcome.stdout}\nHint: {hint}".lstrip()
138
+ return outcome
139
+
140
+ runtime.execute = execute
141
+
142
+
143
+ class Client:
144
+ def __init__(
145
+ self,
146
+ provider,
147
+ loop,
148
+ signal,
149
+ pending=None,
150
+ tree=None,
151
+ context=None,
152
+ instructions="",
153
+ background=None,
154
+ accounting=None,
155
+ record=None,
156
+ images=(),
157
+ activity_waits=None,
158
+ ):
159
+ self.provider, self.loop, self.signal = provider, loop, signal
160
+ self.pending = pending if pending is not None else set()
161
+ self.tree, self.context = tree, context
162
+ self.instructions = instructions
163
+ self.background = background
164
+ self.accounting, self.record = accounting, record
165
+ self.images = images
166
+ self.activity_waits = activity_waits if activity_waits is not None else {}
167
+ self.host_context = contextvars.copy_context()
168
+
169
+ def complete(self, messages, *, model):
170
+ agent = getattr(self.context, "agent", "root")
171
+ contract_model = getattr(self.tree, "contract_model", None)
172
+ if contract_model is not None and model != contract_model:
173
+ raise RuntimeError("model requests must use the model specified by the runtime contract")
174
+
175
+ async def request():
176
+ system = "\n\n".join(m["content"] for m in messages if m["role"] == "system")
177
+ system += "\n\n" + self.instructions
178
+ refinement = getattr(self.tree, "refinement", None)
179
+ if refinement is not None:
180
+ system = refinement.filter_system(system)
181
+ entries = [
182
+ (
183
+ ModelEntry(content=[TextSegment(text=m["content"])])
184
+ if m["role"] == "assistant"
185
+ else HumanEntry(content=m["content"])
186
+ )
187
+ for m in messages
188
+ if m["role"] != "system"
189
+ ]
190
+ if agent == "root" and self.images:
191
+ for index, entry in enumerate(entries):
192
+ if isinstance(entry, HumanEntry):
193
+ entries[index] = HumanEntry(
194
+ content=[TextSegment(text=entry.text), *self.images]
195
+ )
196
+ break
197
+ child = self.background.owners.get(agent) if self.background is not None else None
198
+ session = getattr(self.tree, "session", None)
199
+ ledger = getattr(session, "steering", None)
200
+ root = child is None and getattr(self.tree, "backend_agents", {}).get(agent, {}).get("depth", 0) == 0
201
+ if waiting := self.activity_waits.pop(agent, None):
202
+ subscriptions, request = waiting
203
+ outcome = await subscriptions.wait_for_change(
204
+ request, lambda: ledger is not None and bool(ledger.context(agent, root=root, child=child)[1]))
205
+ entries.append(HumanEntry(content="Activity wait ended: " + json.dumps(outcome)))
206
+ tracer = getattr(self.tree, "tracer", None)
207
+ policy = getattr(self.tree, "policy", None)
208
+ limit = getattr(policy, "agent_max_tokens", None)
209
+ if limit is not None and tracer is not None:
210
+ spent = tracer._totals.get(agent, {}).get("output", 0)
211
+ if spent >= limit:
212
+ raise RuntimeError(f"agent {agent} reached its limit of {limit} "
213
+ "completion tokens")
214
+ call = tracer.begin(agent, model, child=child) if tracer is not None else None
215
+ if tracer is not None:
216
+ def close_unfinished(task):
217
+ if call in tracer._open:
218
+ tracer.end(call, None, "aborted" if task.cancelled() else "error")
219
+ asyncio.current_task().add_done_callback(close_unfinished)
220
+ pending_steering = []
221
+ if ledger is not None:
222
+ text, pending_steering = ledger.context(agent, root=root, child=child)
223
+ if text:
224
+ entries.append(HumanEntry(content="Steering instructions:\n" + text))
225
+ from rlmagent.agents.trace import correlated
226
+ from rlmagent.model.deadline import bounded_stream
227
+
228
+ arguments = dict(model=model, system=system, messages=entries, tools=[], signal=self.signal,
229
+ cache_key=str(getattr(session, "session_id", "run")) + ":" + (
230
+ "root" if getattr(self.tree, "backend_agents", {}).get(agent, {}).get("depth", 0) == 0 else agent))
231
+ budget = getattr(getattr(self.tree, "allowance", None), "new_token_budget", None)
232
+ stream = (self.tree.provider.model_stream(self.provider, **arguments)
233
+ if budget is not None else self.provider.stream_response(**arguments))
234
+ async for event in correlated(bounded_stream(stream, model,
235
+ getattr(self.tree, "request_timeout", 300.0)), call, tracer):
236
+ if isinstance(event, StreamFaultEvent):
237
+ if tracer is not None:
238
+ tracer.end(call, event.error.usage, event.reason)
239
+ raise RuntimeError(event.error.error_message or "model request failed")
240
+ if isinstance(event, StreamCloseEvent):
241
+ if ledger is not None:
242
+ ledger.consumed(pending_steering, agent, call, child=child)
243
+ if pending_steering and tracer is not None:
244
+ tracer._write("steering", agent=agent, request=call,
245
+ messages=pending_steering)
246
+ usage = event.message.usage
247
+ if self.accounting is not None:
248
+ self.accounting.append(usage)
249
+ if self.record is not None:
250
+ self.record(
251
+ {
252
+ "kind": "model_usage",
253
+ "request_id": call,
254
+ "run_id": agent,
255
+ "model": model,
256
+ "usage": usage.model_dump(by_alias=False),
257
+ "text": event.message.text,
258
+ "reason": event.reason,
259
+ }
260
+ )
261
+ if tracer is not None:
262
+ tracer.end(call, usage, event.reason)
263
+ return event.message.text, Spend(
264
+ prompt_tokens=usage.input + usage.cache_read + usage.cache_write,
265
+ completion_tokens=usage.output,
266
+ total_tokens=usage.total_tokens,
267
+ cost=usage.cost.total if usage.cost.total > 0 else None,
268
+ cached_tokens=usage.cache_read,
269
+ reasoning_tokens=usage.reasoning,
270
+ )
271
+ if tracer is not None:
272
+ tracer.end(call, None, "no_completion")
273
+ raise RuntimeError("model returned no completion")
274
+
275
+ future = self.host_context.copy().run(
276
+ asyncio.run_coroutine_threadsafe, request(), self.loop
277
+ )
278
+ self.pending.add(future)
279
+ try:
280
+ return wait_for_host(future, agent, self.background)
281
+ finally:
282
+ self.pending.discard(future)
283
+
284
+
285
+ class BackendProvider:
286
+ """A complete recursive backend run appears as one frontend completion."""
287
+
288
+ is_backend = True
289
+
290
+ def __init__(self, provider, fallback, tree):
291
+ self.provider, self.fallback, self.tree = provider, fallback, tree
292
+ self.follows_tree = True
293
+ self.active = None
294
+ self.background = KeptAgents(getattr(tree, "max_agents", 50))
295
+ self.background.resolve_role = getattr(tree, "_role", None)
296
+ self.pending = set()
297
+ self.state = Session()
298
+ self.instructions = ""
299
+ self.prompt_context = {}
300
+ self.events = []
301
+ self._serial = asyncio.Lock()
302
+ self.event_path = None
303
+ self._event_lock = threading.Lock()
304
+ # Called with each run event, from the engine's thread. A screen that
305
+ # draws steps itself sets cards, and the reply then carries only text.
306
+ self.listeners = []
307
+ self.cards = False
308
+ self.background.record_event = self.record_event
309
+
310
+ def record_event(self, event):
311
+ with self._event_lock:
312
+ event = {"timestamp": time(), **event}
313
+ self.events.append(event)
314
+ for listener in list(self.listeners):
315
+ listener(event)
316
+ if self.event_path is not None:
317
+ path = Path(self.event_path)
318
+ path.parent.mkdir(parents=True, exist_ok=True)
319
+
320
+ def encode(value):
321
+ return asdict(value) if is_dataclass(value) else str(value)
322
+
323
+ with path.open("a", encoding="utf-8") as stream:
324
+ stream.write(json.dumps(event, default=encode, ensure_ascii=False) + "\n")
325
+ from rlmagent.execution_log import records
326
+
327
+ compatible = path.with_name(path.stem + ".run.jsonl")
328
+ for row in records(event, self.events):
329
+ with compatible.open("a", encoding="utf-8") as stream:
330
+ stream.write(json.dumps(row, default=encode, ensure_ascii=False) + "\n")
331
+
332
+ def __getattr__(self, name):
333
+ return getattr(self.fallback, name)
334
+
335
+ def function_names(self) -> list[str]:
336
+ """The names a cell can call, without starting a run."""
337
+ fixed = {"FINAL", "capabilities", "rlm", "gather_rlm", "spawn", "children", "tell_parent",
338
+ "name_child", "find_child", "child_info", "steer_child", "child_messages",
339
+ "read_child_message", "child_delivery_receipt"}
340
+ optional = {"fetch_url", "guidance", "improve", "playbook"}
341
+ workspace = Workspace(self.tree.cwd, self.tree.approver).functions()
342
+ extended = getattr(self.tree, "extension_functions", {})
343
+ builtin = getattr(self.tree, "host_functions", {})
344
+ optional |= set(workspace) | set(extended) | set(builtin)
345
+ if hasattr(getattr(getattr(self.tree, "session", None), "jobs", None), "subscriptions"):
346
+ optional.update(("wait_for_activity", "watch_child"))
347
+ policy = getattr(self.tree, "policy", None)
348
+ if policy is not None:
349
+ optional = {name for name in optional if policy.allows_tool(name)}
350
+ if "playbook" in optional:
351
+ optional.add("skill")
352
+ return sorted(fixed | optional)
353
+
354
+ def checkpoint_state(self):
355
+ """A copy of the saved variables for /undo, or None when they are too large to keep."""
356
+ fields = {name: getattr(self.state, name) for name in _STATE_FIELDS}
357
+ if len(json.dumps(fields, default=str)) > MAX_CHECKPOINT_CHARS:
358
+ return None
359
+ return copy.deepcopy(fields)
360
+
361
+ def restore_state(self, checkpoint) -> None:
362
+ self.state.close_runtime()
363
+ for name, value in copy.deepcopy(checkpoint).items():
364
+ setattr(self.state, name, value)
365
+ if self.state.path is not None:
366
+ self.state.save()
367
+
368
+ def stream_response(self, *, model, system, messages, tools, signal=None, cache_key=None):
369
+ if not any(tool.name == "python" for tool in tools):
370
+ return self.fallback.stream_response(
371
+ model=model,
372
+ system=system,
373
+ messages=messages,
374
+ tools=tools,
375
+ signal=signal,
376
+ cache_key=cache_key,
377
+ )
378
+ return self._run(model, messages, signal)
379
+
380
+ async def _run(self, model, messages, signal):
381
+ from rlmagent.model.external.textcalls import render
382
+
383
+ latest = next((entry for entry in reversed(messages) if isinstance(entry, HumanEntry)), None)
384
+ images = (
385
+ [part for part in latest.content if isinstance(part, ImageSegment)]
386
+ if latest is not None and isinstance(latest.content, list) else []
387
+ )
388
+ async with self._serial:
389
+ self.last_stop_reason = None
390
+ self.background.steering = getattr(getattr(self.tree, "session", None), "steering", None)
391
+ self.background.open(self.state.path)
392
+ loop = asyncio.get_running_loop()
393
+ host_context = contextvars.copy_context()
394
+ tree = self.tree
395
+ from rlmagent.model.external import with_external
396
+
397
+ if hasattr(tree, "provider"):
398
+ self.provider = with_external(tree.provider.inner)
399
+ workspace = Workspace(tree.cwd, tree.approver)
400
+ workspace.policy = getattr(tree, "policy", None)
401
+ workspace.on_output = lambda text: updates.put({"kind": "command_output", "text": text})
402
+ updates = queue.Queue()
403
+ context = threading.local()
404
+ accounting = []
405
+ activity_waits = {}
406
+ runtimes = []
407
+ self.active = (None, runtimes)
408
+
409
+ owner = self
410
+
411
+ class Sink:
412
+ def __getattr__(self, name):
413
+ def record(**fields):
414
+ if name in ("step_started", "run_started"):
415
+ context.agent = fields["run_id"]
416
+ event = {"kind": name, **fields}
417
+ owner.record_event(event)
418
+ updates.put(event)
419
+ if name == "run_started":
420
+ if fields.get("depth", 0) > 0:
421
+ count = sum(
422
+ 1
423
+ for item in owner.events
424
+ if item["kind"] == "run_started" and item.get("depth", 0) > 0
425
+ )
426
+ if count > getattr(tree, "max_agents", 50):
427
+ raise RuntimeError("recursive agent limit reached")
428
+ tree.__dict__.setdefault("backend_agents", {})[fields["run_id"]] = dict(
429
+ fields, status="running"
430
+ )
431
+ if fields.get("parent_run_id") and hasattr(tree, "tracer"):
432
+ loop.call_soon_threadsafe(
433
+ tree.tracer.spawned, fields["parent_run_id"], fields["run_id"]
434
+ )
435
+ elif name in ("run_completed", "run_failed"):
436
+ agent = tree.__dict__.get("backend_agents", {}).get(fields["run_id"])
437
+ if agent is not None:
438
+ agent["status"] = (
439
+ "completed" if name == "run_completed" else "failed"
440
+ )
441
+ if agent.get("parent_run_id") and hasattr(tree, "tracer"):
442
+ loop.call_soon_threadsafe(
443
+ tree.tracer.returned,
444
+ agent["parent_run_id"],
445
+ fields["run_id"],
446
+ )
447
+
448
+ return record
449
+
450
+ def factory(prompt, bridges, timeout, tools=(), **kwargs):
451
+ agent = getattr(context, "agent", "root")
452
+ reuse = kwargs.pop("reuse", None)
453
+ owner_session = kwargs.pop("owner_session", None)
454
+ node_prepare = kwargs.pop("_node_prepare", False)
455
+ if child_id := self.background.owners.get(agent):
456
+ self.record_event({"kind": "child_bound", "run_id": agent,
457
+ "child_id": child_id,
458
+ "name": self.background.names.get(child_id)})
459
+
460
+ def wrap(fn, *, record_result=True):
461
+ def call(*args, **kw):
462
+ async def invoke():
463
+ from rlmagent.agents.approval import Refused
464
+
465
+ try:
466
+ value = await fn(*args, **kw)
467
+ except (Refused, OSError, ValueError, UnicodeError) as error:
468
+ value = f"Error: {error}"
469
+ if record_result:
470
+ self.record_event({"kind": "host_result", "run_id": agent,
471
+ "tool": fn.__name__, "result": value})
472
+ return value
473
+
474
+ future = host_context.copy().run(
475
+ asyncio.run_coroutine_threadsafe, invoke(), loop
476
+ )
477
+ self.pending.add(future)
478
+ try:
479
+ return wait_for_host(future, agent, self.background)
480
+ finally:
481
+ self.pending.discard(future)
482
+
483
+ return call
484
+
485
+ from rlmagent.plugins import as_host_function
486
+
487
+ joined = {
488
+ **{name: wrap(as_host_function(fn))
489
+ for name, fn in getattr(tree, "extension_functions", {}).items()},
490
+ **bridges,
491
+ **{name: wrap(fn) for name, fn in workspace.functions().items()},
492
+ **{name: wrap(fn)
493
+ for name, fn in getattr(tree, "host_functions", {}).items()},
494
+ }
495
+ remote = getattr(tree, "remote_tools", None)
496
+ if remote is not None:
497
+ # Split execution: files, commands and jobs live in the tool host.
498
+ joined.update({name: wrap(fn) for name, fn in remote.functions(
499
+ tree.approver, getattr(tree, "policy", None)).items()})
500
+
501
+ async def playbook(id=None):
502
+ improver = getattr(tree, "improver", None)
503
+ if improver is None:
504
+ raise RuntimeError("playbook unavailable in this run")
505
+ if id is not None:
506
+ return improver.entry(id)
507
+ return [
508
+ {"id": e.id, "kind": e.kind, "title": e.title} for e in improver.entries()
509
+ ]
510
+
511
+ async def improve(instructions=None, shared=False):
512
+ if not getattr(tree, "playbook_writable", True):
513
+ raise RuntimeError("contract playbook is read-only")
514
+ improver = getattr(tree, "improver", None)
515
+ if improver is None:
516
+ raise RuntimeError("playbook unavailable in this run")
517
+ improver.request(instructions, shared)
518
+ return "Playbook update queued for the turn boundary."
519
+
520
+ async def fetch_url(url, max_chars=50000, raw=False):
521
+ from rlmagent.agents.approval import Refused
522
+ from rlmagent.network import fetch_text
523
+
524
+ if not tree.network:
525
+ raise Refused("network access is disabled")
526
+ return await fetch_text(
527
+ url, max_chars=max_chars, raw=raw,
528
+ allow_private=getattr(tree, "allow_private_fetch", False))
529
+
530
+ async def guidance(id=None):
531
+ refinement = getattr(tree, "refinement", None)
532
+ return refinement.read_guidance(id) if refinement is not None else []
533
+
534
+ async def capabilities():
535
+ names = {name for name in joined if not name.startswith("_")}
536
+ if "_spawn" in joined:
537
+ names |= {"spawn", "children", "name_child", "find_child", "child_info",
538
+ "steer_child", "child_messages", "read_child_message", "child_delivery_receipt"}
539
+ if watch_child_available:
540
+ names.add("watch_child")
541
+ return {
542
+ "functions": sorted(names | {tool.name for tool in tools} | {"FINAL"}),
543
+ **limits_of(tree),
544
+ }
545
+
546
+ jobs = getattr(getattr(tree, "session", None), "jobs", None)
547
+ if jobs is not None and hasattr(jobs, "subscriptions"):
548
+ jobs.child_snapshot = self.background.watch_snapshot
549
+ watch_owner = self.background.owners.get(agent, agent)
550
+ joined.update({name: wrap(fn) for name, fn in jobs.subscriptions.functions(owner=watch_owner).items()})
551
+
552
+ async def wait_for_activity(watch_id=None, timeout=60.0):
553
+ """Park at the next model boundary. Call as the last action in a cell."""
554
+ subscriptions = jobs.subscriptions
555
+ request = subscriptions.prepare_wait(watch_id, timeout, owner=watch_owner)
556
+ activity_waits[agent] = subscriptions, request
557
+ return True
558
+
559
+ async def activity_wait_status():
560
+ waiting = activity_waits.get(agent)
561
+ if waiting is None:
562
+ return {"event": "cancelled"}
563
+ subscriptions, request = waiting
564
+ child = self.background.owners.get(agent)
565
+ session = getattr(tree, "session", None)
566
+ ledger = getattr(session, "steering", None)
567
+ depth = getattr(tree, "backend_agents", {}).get(agent, {}).get("depth", 0)
568
+ status = subscriptions.wait_status(request, lambda: ledger is not None and bool(
569
+ ledger.context(agent, root=child is None and depth == 0, child=child)[1]))
570
+ if "event" in status:
571
+ activity_waits.pop(agent, None)
572
+ return status
573
+
574
+ joined["wait_for_activity"] = wrap(wait_for_activity)
575
+ joined["guidance"] = wrap(guidance)
576
+ joined["capabilities"] = wrap(capabilities)
577
+ joined.update(
578
+ playbook=wrap(playbook), improve=wrap(improve), fetch_url=wrap(fetch_url)
579
+ )
580
+ policy = getattr(tree, "policy", None)
581
+ watch_child_available = (hasattr(jobs, "subscriptions") and
582
+ (policy is None or policy.allows_tool("watch_child")))
583
+ if policy is not None:
584
+ joined = {name: fn for name, fn in joined.items()
585
+ if name in bridges or name == "capabilities" or policy.allows_tool(name)}
586
+ startup = (
587
+ "async def skill(id):\n"
588
+ " entry = await playbook(id)\n"
589
+ " namespace = {}\n"
590
+ " exec(entry['content'], namespace)\n"
591
+ " return namespace[entry['callable']]"
592
+ ) if "playbook" in joined else ""
593
+ if "_delivery" in joined:
594
+ startup += ("\nasync def child_delivery(handle, receipt):\n"
595
+ " return await _delivery(handle.id, receipt)\n")
596
+ if "_spawn" in bridges:
597
+ from rlmagent.agents.child_handles import HELPERS
598
+
599
+ async def child_registry(action, key=None, name=None, text=None, message_id=None,
600
+ identity=None, new_only=True):
601
+ background = self.background
602
+ parent = background.owners.get(agent, agent)
603
+ if not node_prepare and parent not in background.saved:
604
+ parent = agent
605
+ # Node launches the job before returning the ID, but its first
606
+ # metadata callback can arrive just after the spawn response.
607
+ for attempt in range(40):
608
+ try:
609
+ with background.lock:
610
+ operation = "info" if action in ("steer", "watch") else action
611
+ info = background.child_handle(parent, operation, key, name,
612
+ identity=identity, new_only=new_only)
613
+ if action == "watch":
614
+ if not watch_child_available or not info["keep"]:
615
+ raise ValueError("child watches require a kept child and permission to watch")
616
+ return jobs.subscriptions.watch_child(key, parent)
617
+ ledger = background.steering
618
+ if action == "steer":
619
+ if ledger is None:
620
+ raise ValueError("child steering requires an attached session")
621
+ active = info["status"] in ("starting", "running")
622
+ if not active:
623
+ ledger.expire_child(key)
624
+ return ledger.accept(text, message_id, key, once=True,
625
+ allow_new=active)
626
+ if action == "info" and ledger is not None:
627
+ if info["status"] not in ("starting", "running"):
628
+ ledger.expire_child(key)
629
+ info["steering"] = ledger.receipts(target=key)
630
+ break
631
+ except KeyError:
632
+ if not node_prepare or attempt == 39:
633
+ raise ValueError("unknown child handle") from None
634
+ await asyncio.sleep(0.025)
635
+ if action == "info":
636
+ tracer = getattr(tree, "tracer", None)
637
+ info["usage"] = tracer.child_usage(key) if tracer is not None else None
638
+ info.setdefault("steering", [])
639
+ return info
640
+
641
+ joined["_child_registry"] = wrap(child_registry)
642
+ startup += (
643
+ "\nasync def _child_operation(action, key=None, name=None, **arguments):\n"
644
+ " result = await _child_registry(action, key, name, **arguments)\n"
645
+ " if isinstance(result, str):\n"
646
+ " raise ValueError(result)\n"
647
+ " return result\n"
648
+ "def _child_handle(key):\n"
649
+ " return _AgentHandle(key)\n"
650
+ ) + HELPERS + (
651
+ "\nasync def child_delivery_receipt(handle, message_id):\n"
652
+ " return await _child_operation('delivery', handle.id, identity=message_id)\n"
653
+ )
654
+ if watch_child_available:
655
+ startup += (
656
+ "async def watch_child(handle):\n"
657
+ " return await _child_operation('watch', handle.id)\n"
658
+ )
659
+ if node_prepare:
660
+ from rlmagent.hints import EMPTY_CELL, _RULES
661
+ from rlmagent.policy import DEFAULT_OUTPUT_BYTES
662
+
663
+ if tree.network:
664
+ from rlmagent.agents.wasm_api import NETWORK
665
+ startup += "\n" + NETWORK
666
+ activity = None
667
+ if "wait_for_activity" in joined:
668
+ activity = {"schedule": "wait_for_activity", "status": "_activity_wait_status"}
669
+ joined[activity["status"]] = wrap(activity_wait_status, record_result=False)
670
+ return {
671
+ "host_functions": {key: value for key, value in joined.items() if value is not None},
672
+ "activity_wait": activity,
673
+ "startup": startup,
674
+ "cell_policy": {
675
+ "maxOutputBytes": getattr(workspace.policy, "max_tool_output_bytes",
676
+ DEFAULT_OUTPUT_BYTES),
677
+ "emptyHint": EMPTY_CELL,
678
+ "hints": [[pattern.pattern, bool(pattern.flags & re.IGNORECASE), hint]
679
+ for pattern, hint in _RULES],
680
+ },
681
+ }
682
+ from rlmness.runtime import SubprocessRuntime
683
+
684
+ runtime_type = (
685
+ WasmRuntime
686
+ if getattr(tree, "kernel_kind", "wasm") == "wasm"
687
+ else SubprocessRuntime
688
+ )
689
+ # Host startup and helper installation have their own bounded window.
690
+ # The requested cell deadline still applies to every model-written cell.
691
+ def build():
692
+ if owner_session is not None:
693
+ kwargs["session"] = owner_session.state_for_guest()
694
+ built = runtime_type(prompt, joined, max(30.0, timeout), tools, **kwargs)
695
+ built.execute(startup)
696
+ if tree.network and isinstance(built, WasmRuntime):
697
+ from rlmagent.agents.wasm_api import NETWORK
698
+
699
+ outcome = built.execute(NETWORK)
700
+ if outcome.error:
701
+ built.close()
702
+ raise RuntimeError(outcome.error)
703
+ built.timeout = timeout
704
+ return built
705
+
706
+ if reuse is not None and isinstance(reuse, runtime_type) and not reuse._closed:
707
+ reuse.rebind(prompt, joined, max(30.0, timeout), tools)
708
+ reuse.timeout = timeout
709
+ runtime = reuse
710
+ else:
711
+ if reuse is not None:
712
+ reuse.close()
713
+ runtime = build()
714
+ runtimes.append(runtime)
715
+ shape_cells(runtime, getattr(tree, "policy", None), rebuild=build)
716
+ return runtime
717
+
718
+ factory.SEALED = True
719
+ factory.PERSISTENT = True
720
+ factory.NEEDS_SOURCE = True
721
+ factory.MAX_LIVE = WasmRuntime.MAX_LIVE
722
+ cancel = threading.Event()
723
+ self.active = (cancel, runtimes)
724
+ from rlmness.background import PRELUDE
725
+
726
+ self.state.functions["_AgentHandle"] = PRELUDE.split("async def spawn(")[0]
727
+ def prepare_node(prompt, bridge_names, timeout, *, session, agent):
728
+ context.agent = agent
729
+ return factory(prompt, dict.fromkeys(bridge_names), timeout,
730
+ owner_session=session, _node_prepare=True)
731
+
732
+ factory.NODE_PREPARE = prepare_node
733
+ config = Config(
734
+ engine=getattr(tree, "recursive_engine",
735
+ "node" if getattr(tree, "kernel_kind", "wasm") == "wasm" else "python"),
736
+ primary_agent=model,
737
+ max_steps=tree.allowance.max_calls,
738
+ max_calls=tree.allowance.max_calls,
739
+ max_cost=tree.allowance.max_cost,
740
+ max_depth=tree.allowance.max_depth,
741
+ max_live=tree.allowance.max_live,
742
+ timeout=tree.cell_timeout,
743
+ inherit_tools=True,
744
+ )
745
+ instruction = (
746
+ self.instructions
747
+ + "\n\n"
748
+ + (
749
+ "Complete the user's coding task. Workspace access uses async read_file(path, start=None, end=None) "
750
+ "(line numbers from 1, both ends included), list_files(pattern), "
751
+ "search_files(regex, path='.', glob=None, ignore_case=False) for matching lines, write_file(path, content), edit_file(path, old, new), "
752
+ "apply_patch(unified_diff) for several edits or files, all or none, "
753
+ "and run(cmd, timeout=120). Changes and commands use host approval. "
754
+ "await ask_user(question, choices=None, multiple=False) asks the user; use it only "
755
+ "when you truly cannot decide. "
756
+ "Failed or refused tool calls return text starting with Error:; inspect results "
757
+ "before continuing. "
758
+ "await fetch_url(url, max_chars=50000) fetches bounded text "
759
+ "when network is enabled. "
760
+ "await instruction_docs() discovers project instruction and skill files; "
761
+ "await instruction_docs(path) reads a selected file. "
762
+ "Read relevant instructions before editing. "
763
+ "await capabilities() reports the functions and limits you actually have; "
764
+ "check it rather than answering from memory about what is available. "
765
+ "Use the backend's rlm(piece, instruction=question) and gather_rlm APIs. "
766
+ "Background jobs use await spawn(task, data, keep=False, "
767
+ "role=None, model=None); "
768
+ "the returned handle provides result(timeout=None), status(), send(text), "
769
+ "messages(), cancel(), and id. Persistent jobs use keep=True. "
770
+ "Children can await tell_parent(text). await children() lists handles. "
771
+ "await name_child(handle, name) names a child, await find_child(name) finds it, "
772
+ "and await child_info(handle) shows status and recorded usage. "
773
+ "Use await steer_child(handle, text) to guide an active child's next model request. "
774
+ "Playbooks use await playbook(id=None), await skill(id), and "
775
+ "await improve(instructions, shared=False). "
776
+ "For work with several parts keep a task list: await todo('add', text), "
777
+ "await todo('done', n), await todo() to see it; the user sees it too. "
778
+ "await mcp_tools() lists tools on configured MCP servers and "
779
+ "await mcp(server, tool, arguments) calls one. "
780
+ "await factory_run(name) runs a named workflow from the user's library "
781
+ "when workspace approval allows it. "
782
+ "await web_search(query) searches the web when a search key is set. "
783
+ "await context_usage() reports how full the context is; await compact() "
784
+ "summarizes older conversation when this turn ends. "
785
+ "Other agents: await list_agents(), await send_message(to, text), "
786
+ "await read_messages(); send_message accepts receipt=True for later lookup with "
787
+ "await message_receipt(to, message_id, kind). Await schedule(task, in_seconds=..., "
788
+ "every=None, worker=None) queues work on a background worker; "
789
+ "await heartbeat(text) reports progress on long work. "
790
+ "Long commands: await job_start(cmd) then job_read(id, cursor), "
791
+ "job_wait(id), job_stop(id); await service_start(name, cmd, ready=regex) "
792
+ "for servers; await watch(job=id | path=p | messages=True) waits for a "
793
+ "change. Durable watches use watch_start, watch_events and watch_read. "
794
+ "End a cell with await wait_for_activity(watch_id) to park the next model request. "
795
+ "Await history(start, end, agent=None) to read the full record "
796
+ "after compaction. "
797
+ "When the user set a goal, await goal() shows it and "
798
+ "await goal_done(summary) marks it achieved. "
799
+ "Return the answer with FINAL. The conversation is in PROMPT."
800
+ )
801
+ )
802
+ policy = getattr(tree, "policy", None)
803
+ if policy is not None and policy.allowed_tools is not None:
804
+ instruction = (
805
+ self.instructions + "\n\nComplete the task in Python and return the answer with FINAL. "
806
+ "The conversation is in PROMPT. Use await capabilities() to inspect the permitted "
807
+ "host functions. Delegation follows the configured depth and inherits the same policy."
808
+ )
809
+ task = asyncio.create_task(
810
+ asyncio.to_thread(
811
+ solve,
812
+ {**self.prompt_context, "conversation": render(messages)},
813
+ Client(
814
+ self.provider,
815
+ loop,
816
+ signal,
817
+ self.pending,
818
+ tree,
819
+ context,
820
+ instruction,
821
+ self.background,
822
+ accounting,
823
+ self.record_event,
824
+ images=images,
825
+ activity_waits=activity_waits,
826
+ ),
827
+ config=config,
828
+ instruction=instruction,
829
+ runtime_factory=factory,
830
+ trace=Sink(),
831
+ allowance=tree.allowance,
832
+ cancel=cancel,
833
+ session=self.state,
834
+ background=self.background,
835
+ run_id="root",
836
+ )
837
+ )
838
+ try:
839
+ progress = ""
840
+ while not task.done() or not updates.empty():
841
+ while not updates.empty():
842
+ event = updates.get_nowait()
843
+ name = event["kind"]
844
+ text = ""
845
+ if self.cards:
846
+ pass
847
+ elif (
848
+ name == "code_generated"
849
+ and event.get("step", 0) > 0
850
+ and event.get("code")
851
+ ):
852
+ text = f"\n```python\n{event['code']}\n```\n"
853
+ elif (
854
+ name == "output_received"
855
+ and event.get("step", 0) > 0
856
+ and event.get("output")
857
+ ):
858
+ text = f"\n```text\n{shown_output(str(event['output']))}\n```\n"
859
+ elif name == "command_output":
860
+ text = f"\n```text\n{event['text'].rstrip()}\n```\n"
861
+ elif name in ("run_started", "run_completed", "run_failed"):
862
+ text = f"[{name}: {event.get('run_id', '')}]\n"
863
+ if text:
864
+ progress += text
865
+ yield ContentChunkEvent(
866
+ content_index=0,
867
+ delta=text,
868
+ partial=ModelEntry(
869
+ model=model, content=[TextSegment(text=progress)]
870
+ ),
871
+ )
872
+ if signal is not None and signal.is_cancelled():
873
+ self.cancel()
874
+ await asyncio.wait({task}, timeout=0.1)
875
+ answer = task.result()
876
+ except asyncio.CancelledError:
877
+ self.cancel()
878
+ self.active = None
879
+ if asyncio.current_task().cancelling():
880
+ raise
881
+ yield StreamFaultEvent(
882
+ reason="aborted",
883
+ error=ModelEntry(model=model, stop_reason="aborted", error_message="cancelled",
884
+ usage=summed_usage(accounting)),
885
+ )
886
+ return
887
+ except Exception as error:
888
+ from rlmness import AllowanceSpent
889
+ from rlmness.engine import StepsUsedUp
890
+ from rlmness.runtime import CellTimeout, RuntimeGone
891
+
892
+ if isinstance(error, AllowanceSpent):
893
+ tree.provider.refusal = str(error)
894
+ if isinstance(error, StepsUsedUp):
895
+ self.last_stop_reason = "max_calls"
896
+ if isinstance(error, RuntimeGone):
897
+ for future in list(self.pending):
898
+ future.cancel()
899
+ if isinstance(error, CellTimeout):
900
+ self.last_stop_reason = "cell_timeout"
901
+ from rlmness.wasm_runtime import reset_host
902
+
903
+ if not getattr(WasmRuntime, "ISOLATED", False):
904
+ await asyncio.to_thread(reset_host)
905
+ self.active = None
906
+ aborted = cancel.is_set()
907
+ yield StreamFaultEvent(
908
+ reason="aborted" if aborted else "error",
909
+ error=ModelEntry(
910
+ model=model,
911
+ stop_reason="aborted" if aborted else "error",
912
+ error_message=str(error),
913
+ usage=summed_usage(accounting),
914
+ ),
915
+ )
916
+ return
917
+ self.active = None
918
+ usage = summed_usage(accounting)
919
+ message = ModelEntry(
920
+ model=model, content=[TextSegment(text=str(answer.output))], usage=usage
921
+ )
922
+ yield StreamCloseEvent(reason="stop", message=message)
923
+
924
+ def cancel(self):
925
+ if self.active is not None:
926
+ cancel, runtimes = self.active
927
+ if cancel is not None:
928
+ cancel.set()
929
+ for future in list(self.pending):
930
+ future.cancel()
931
+ for runtime in list(runtimes):
932
+ runtime.channel.inbox.put(None)
933
+ runtime.close()
934
+ if (runtimes and not getattr(WasmRuntime, "ISOLATED", False)
935
+ and any(isinstance(runtime, WasmRuntime) for runtime in runtimes)):
936
+ import threading
937
+
938
+ from rlmness.wasm_runtime import reset_host
939
+
940
+ threading.Thread(target=reset_host, daemon=True).start()
941
+
942
+ async def close(self):
943
+ self.cancel()
944
+ await asyncio.to_thread(self.state.close_runtime)
945
+ self.background.close()
946
+ await asyncio.to_thread(self.background.pool.shutdown, wait=True, cancel_futures=True)