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/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)
|