rlmness-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. rlmagent/__init__.py +1 -0
  2. rlmagent/__main__.py +8 -0
  3. rlmagent/acp/__init__.py +1 -0
  4. rlmagent/acp/runtime.py +71 -0
  5. rlmagent/acp/server.py +598 -0
  6. rlmagent/acp/updates.py +128 -0
  7. rlmagent/agentnet.py +237 -0
  8. rlmagent/agents/__init__.py +1 -0
  9. rlmagent/agents/approval.py +90 -0
  10. rlmagent/agents/bridge.py +109 -0
  11. rlmagent/agents/budget.py +212 -0
  12. rlmagent/agents/child_handles.py +46 -0
  13. rlmagent/agents/child_mailbox.py +30 -0
  14. rlmagent/agents/kernel_api.py +156 -0
  15. rlmagent/agents/output.py +34 -0
  16. rlmagent/agents/patch.py +107 -0
  17. rlmagent/agents/prompts.py +156 -0
  18. rlmagent/agents/trace.py +290 -0
  19. rlmagent/agents/tree.py +682 -0
  20. rlmagent/agents/wasm_api.py +105 -0
  21. rlmagent/agents/workspace.py +357 -0
  22. rlmagent/autopilot.py +447 -0
  23. rlmagent/backend.py +946 -0
  24. rlmagent/backend_context.py +74 -0
  25. rlmagent/cli/__init__.py +5 -0
  26. rlmagent/cli/config_cmd.py +177 -0
  27. rlmagent/cli/health.py +156 -0
  28. rlmagent/cli/main.py +1194 -0
  29. rlmagent/cli/update.py +188 -0
  30. rlmagent/clipboard.py +63 -0
  31. rlmagent/config/__init__.py +37 -0
  32. rlmagent/config/config.py +297 -0
  33. rlmagent/config/loader.py +119 -0
  34. rlmagent/config/prompts.py +124 -0
  35. rlmagent/config/secure_input.py +213 -0
  36. rlmagent/config/store.py +275 -0
  37. rlmagent/context/__init__.py +47 -0
  38. rlmagent/context/budget.py +557 -0
  39. rlmagent/contract.py +311 -0
  40. rlmagent/contract_run.py +232 -0
  41. rlmagent/conversation.py +2547 -0
  42. rlmagent/cron.py +77 -0
  43. rlmagent/directives.py +893 -0
  44. rlmagent/discovery.py +356 -0
  45. rlmagent/execution_log.py +66 -0
  46. rlmagent/factory.py +189 -0
  47. rlmagent/factory_graph.py +413 -0
  48. rlmagent/harness/__init__.py +63 -0
  49. rlmagent/harness/contracts/__init__.py +1 -0
  50. rlmagent/harness/contracts/stream.py +129 -0
  51. rlmagent/harness/contracts/tooling.py +127 -0
  52. rlmagent/harness/contracts/transcript/__init__.py +68 -0
  53. rlmagent/harness/contracts/transcript/_base.py +29 -0
  54. rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
  55. rlmagent/harness/contracts/transcript/entries.py +180 -0
  56. rlmagent/harness/contracts/transcript/segments.py +64 -0
  57. rlmagent/harness/contracts/values.py +6 -0
  58. rlmagent/harness/driver.py +397 -0
  59. rlmagent/harness/engine.py +489 -0
  60. rlmagent/harness/mending.py +153 -0
  61. rlmagent/harness/provider/__init__.py +1 -0
  62. rlmagent/harness/provider/base.py +27 -0
  63. rlmagent/harness/provider/wire.py +189 -0
  64. rlmagent/harness/py.typed +0 -0
  65. rlmagent/harness/session/__init__.py +5 -0
  66. rlmagent/harness/session/index.py +218 -0
  67. rlmagent/harness/session/lease.py +66 -0
  68. rlmagent/harness/session/records.py +118 -0
  69. rlmagent/harness/session/replay.py +165 -0
  70. rlmagent/harness/session/store.py +155 -0
  71. rlmagent/harness/session/summary.py +46 -0
  72. rlmagent/hints.py +47 -0
  73. rlmagent/history.py +92 -0
  74. rlmagent/hooks/__init__.py +1 -0
  75. rlmagent/hooks/hooks.py +1 -0
  76. rlmagent/instructions.py +157 -0
  77. rlmagent/job_runner.py +117 -0
  78. rlmagent/jobs.py +365 -0
  79. rlmagent/kept_agents.py +397 -0
  80. rlmagent/kernel/__init__.py +5 -0
  81. rlmagent/kernel/session.py +205 -0
  82. rlmagent/kernel/wasm.py +169 -0
  83. rlmagent/logs.py +208 -0
  84. rlmagent/managed_release.py +368 -0
  85. rlmagent/mcp.py +411 -0
  86. rlmagent/mcp_oauth.py +213 -0
  87. rlmagent/model/__init__.py +29 -0
  88. rlmagent/model/_claude/__init__.py +1 -0
  89. rlmagent/model/_claude/courier.py +117 -0
  90. rlmagent/model/_claude/emitter.py +378 -0
  91. rlmagent/model/_oai/__init__.py +1 -0
  92. rlmagent/model/_oai/helpers.py +256 -0
  93. rlmagent/model/_oai/normalize.py +263 -0
  94. rlmagent/model/_oai/parsers.py +317 -0
  95. rlmagent/model/_oai/payloads.py +226 -0
  96. rlmagent/model/_oai/transport.py +127 -0
  97. rlmagent/model/claude.py +552 -0
  98. rlmagent/model/copilot.py +142 -0
  99. rlmagent/model/correlation.py +21 -0
  100. rlmagent/model/deadline.py +19 -0
  101. rlmagent/model/discovery.py +181 -0
  102. rlmagent/model/external/__init__.py +11 -0
  103. rlmagent/model/external/acp.py +202 -0
  104. rlmagent/model/external/acp_install.py +109 -0
  105. rlmagent/model/external/cli.py +248 -0
  106. rlmagent/model/external/textcalls.py +57 -0
  107. rlmagent/model/failover.py +129 -0
  108. rlmagent/model/google.py +199 -0
  109. rlmagent/model/limits.py +46 -0
  110. rlmagent/model/oai_compatible.py +414 -0
  111. rlmagent/model/preferences.py +56 -0
  112. rlmagent/model/py.typed +0 -0
  113. rlmagent/model/registered_oauth.py +195 -0
  114. rlmagent/model/scripted.py +55 -0
  115. rlmagent/model/settings.py +271 -0
  116. rlmagent/model/subscription.py +398 -0
  117. rlmagent/model/token_bounds.py +15 -0
  118. rlmagent/model/transport/__init__.py +1 -0
  119. rlmagent/model/transport/backoff.py +87 -0
  120. rlmagent/model/transport/client.py +87 -0
  121. rlmagent/model/transport/faults.py +65 -0
  122. rlmagent/model/transport/responses_socket.py +223 -0
  123. rlmagent/model/vertex.py +93 -0
  124. rlmagent/network.py +123 -0
  125. rlmagent/packages.py +246 -0
  126. rlmagent/planning.py +84 -0
  127. rlmagent/playbook/__init__.py +1 -0
  128. rlmagent/playbook/apply.py +106 -0
  129. rlmagent/playbook/digest.py +63 -0
  130. rlmagent/playbook/improver.py +272 -0
  131. rlmagent/playbook/plan.py +99 -0
  132. rlmagent/playbook/store.py +67 -0
  133. rlmagent/plugins.py +327 -0
  134. rlmagent/policy.py +105 -0
  135. rlmagent/prompt_refs.py +57 -0
  136. rlmagent/prompts.py +331 -0
  137. rlmagent/prune.py +60 -0
  138. rlmagent/py.typed +0 -0
  139. rlmagent/reasoning.py +135 -0
  140. rlmagent/refinement/__init__.py +1 -0
  141. rlmagent/refinement/controller.py +448 -0
  142. rlmagent/refinement/engine.py +220 -0
  143. rlmagent/refinement/evidence.py +131 -0
  144. rlmagent/refinement/scoped.py +90 -0
  145. rlmagent/refinement/store.py +193 -0
  146. rlmagent/refresh.py +202 -0
  147. rlmagent/routing.py +52 -0
  148. rlmagent/runtime.py +575 -0
  149. rlmagent/safety.py +267 -0
  150. rlmagent/session_html.py +57 -0
  151. rlmagent/session_import.py +52 -0
  152. rlmagent/session_share.py +55 -0
  153. rlmagent/sessions.py +249 -0
  154. rlmagent/shell_sandbox.py +88 -0
  155. rlmagent/skillset.py +320 -0
  156. rlmagent/steering.py +148 -0
  157. rlmagent/stops.py +62 -0
  158. rlmagent/subscriptions.py +219 -0
  159. rlmagent/tasks.py +98 -0
  160. rlmagent/toolhost.py +310 -0
  161. rlmagent/tools/__init__.py +56 -0
  162. rlmagent/tools/_shared.py +210 -0
  163. rlmagent/tools/files.py +462 -0
  164. rlmagent/tools/kernel.py +93 -0
  165. rlmagent/tools/patch_tool.py +68 -0
  166. rlmagent/tools/search.py +188 -0
  167. rlmagent/trust.py +383 -0
  168. rlmagent/tui/__init__.py +1 -0
  169. rlmagent/tui/adapter.py +506 -0
  170. rlmagent/tui/app.py +711 -0
  171. rlmagent/tui/approval_screen.py +57 -0
  172. rlmagent/tui/choice_screen.py +47 -0
  173. rlmagent/tui/completions.py +116 -0
  174. rlmagent/tui/components.py +25 -0
  175. rlmagent/tui/composer.py +40 -0
  176. rlmagent/tui/factory_view.py +86 -0
  177. rlmagent/tui/inspect.py +193 -0
  178. rlmagent/tui/messages.py +215 -0
  179. rlmagent/tui/model_screen.py +307 -0
  180. rlmagent/tui/prompt.py +98 -0
  181. rlmagent/tui/question_screen.py +52 -0
  182. rlmagent/tui/replay.py +141 -0
  183. rlmagent/tui/rlm_agent.tcss +405 -0
  184. rlmagent/tui/search_box.py +91 -0
  185. rlmagent/tui/sidebar.py +182 -0
  186. rlmagent/tui/status.py +49 -0
  187. rlmagent/tui/steps.py +283 -0
  188. rlmagent/tui/suggest.py +778 -0
  189. rlmagent/tui/transcript.py +172 -0
  190. rlmagent/tui/trust_dialog.py +58 -0
  191. rlmagent/tui/worker_attach.py +141 -0
  192. rlmagent/ui/__init__.py +1 -0
  193. rlmagent/ui/render.py +211 -0
  194. rlmagent/undo.py +109 -0
  195. rlmagent/utils/__init__.py +1 -0
  196. rlmagent/websearch.py +75 -0
  197. rlmagent/worker.py +1275 -0
  198. rlmagent/worker_service.py +203 -0
  199. rlmness_cli-0.2.0.dist-info/METADATA +882 -0
  200. rlmness_cli-0.2.0.dist-info/RECORD +203 -0
  201. rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
  202. rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
  203. rlmness_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,682 @@
1
+ """Every agent in one run: the root, its sub-agents, and theirs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import itertools
8
+ import json
9
+ import re
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from time import time
14
+ from uuid import uuid4
15
+
16
+ from rlmness import Allowance, AllowanceSpent
17
+
18
+ from rlmagent.agents.budget import BudgetedProvider
19
+ from rlmagent.agents.child_handles import checked_name
20
+ from rlmagent.agents.child_mailbox import notes_metadata, read_note, record_notes
21
+ from rlmagent.agents.trace import TracedProvider, Tracer
22
+ from rlmagent.agents.bridge import BridgeServer, kernel_env
23
+ from rlmagent.agents.kernel_api import KERNEL_API
24
+ from rlmagent.agents.approval import Approver, Refused
25
+ from rlmagent.agents.wasm_api import wasm_api
26
+ from rlmagent.agents.workspace import Workspace
27
+ from rlmagent.kernel import KernelSession
28
+ from rlmagent.kernel.wasm import WasmKernel
29
+ from rlmagent.tools import build_tool_registry
30
+ from rlmagent.model.external import is_external, parse_model, with_external
31
+ from rlmagent.harness.contracts.tooling import ToolSpec
32
+
33
+ NUDGE = (
34
+ "You have not called FINAL. When your work is done, call FINAL(value) in the "
35
+ "python tool with the result the agent that started you asked for."
36
+ )
37
+ NEVER_FINAL = "[this sub-agent never called FINAL; this is its last reply]"
38
+ _ENDED = ("done", "failed", "cancelled")
39
+ _SETTLED = (*_ENDED, "idle")
40
+
41
+
42
+ @dataclass
43
+ class AgentNode:
44
+ id: str
45
+ depth: int
46
+ parent_id: str | None
47
+ task: str = ""
48
+ context: object = None
49
+ final_value: object = None
50
+ final_given: bool = False
51
+ final_note: str = ""
52
+ role_text: str = ""
53
+ model: str | None = None
54
+
55
+
56
+ @dataclass
57
+ class ChildRecord:
58
+ node: AgentNode
59
+ keep: bool = False
60
+ status: str = "starting"
61
+ task: asyncio.Task | None = None
62
+ session: object = None
63
+ value: object = None
64
+ error: str | None = None
65
+ holds_slot: bool = False
66
+ inbox: asyncio.Queue = field(default_factory=asyncio.Queue)
67
+ outbox: list = field(default_factory=list)
68
+ notes: list = field(default_factory=list)
69
+ changed: asyncio.Event = field(default_factory=asyncio.Event)
70
+ confirm: tuple[int, int] | None = None
71
+ name: str | None = None
72
+ on_status: Callable[[str], None] | None = None
73
+
74
+ def set(self, status: str) -> None:
75
+ self.status = status
76
+ self.changed.set()
77
+ self.changed = asyncio.Event()
78
+ if self.on_status is not None:
79
+ self.on_status(status)
80
+
81
+
82
+ GUARD_MIN_CHARS = 5000
83
+ GUARD_RATIO = 0.6
84
+
85
+ CONFIRM = (
86
+ "Before starting, check this hand-off. Your CONTEXT holds {child:,} characters; the agent "
87
+ "that started you holds {parent:,}, so you got {pct}% of it. Handing on nearly everything "
88
+ "usually means the work of narrowing it down was skipped.\n"
89
+ "Answer with YES or NO as the very first word. YES: this hand-off makes sense as it is "
90
+ "(for example, the same step applied to one piece). NO: the starting agent should narrow "
91
+ "the data before handing it on. After a NO, say why in one sentence and add a short "
92
+ "```python cell that agent could run on its own data to narrow it (pick the relevant part, "
93
+ "search, split) for this task.")
94
+ CONFIRM_BATCH = (
95
+ "Before these {n} sub-agents start together, check the hand-off. Your data holds "
96
+ "{parent:,} characters, and each would get:\n{lines}\nFor {flagged} of them that is most of "
97
+ "your data, barely narrowed.\n"
98
+ "Answer with YES or NO as the very first word (YES lets all of them start), then one "
99
+ "sentence on why.")
100
+ REJECTED = (
101
+ "Sub-agent not started: it would have received {pct}% of your data, barely narrowed. "
102
+ "Narrow it in your own kernel and hand on only the part the task needs. "
103
+ "The check said:\n{reason}")
104
+ BATCH_REJECTED = (
105
+ "gather_rlm not started: some jobs would have received most of your data, barely "
106
+ "narrowed. Narrow each one in your own kernel first. The check said:\n{reason}")
107
+
108
+
109
+ def _size(value: object) -> int:
110
+ return len(value) if isinstance(value, str) else len(json.dumps(value, default=repr))
111
+
112
+
113
+ class AgentTree:
114
+ def __init__(
115
+ self,
116
+ *,
117
+ provider,
118
+ provider_name: str,
119
+ model: str,
120
+ cwd: str,
121
+ allowance: Allowance,
122
+ sessions_dir: Path | None,
123
+ system_for: Callable[[AgentNode, list[ToolSpec]], str],
124
+ first_message_for: Callable[[AgentNode], str],
125
+ cell_timeout: float = 300.0,
126
+ max_agents: int = 50,
127
+ tracer: Tracer | None = None,
128
+ kernel: str = "local",
129
+ approver: Approver | None = None,
130
+ network: bool = False,
131
+ guard: bool = True,
132
+ restore_kernel: bool = False,
133
+ ) -> None:
134
+ self.guard = guard
135
+ self.restore_kernel = restore_kernel
136
+ self.kernel_kind = kernel
137
+ self.network = network
138
+ self.approver = approver or Approver("deny")
139
+ self.provider = BudgetedProvider(with_external(provider), allowance)
140
+ self.provider_name = provider_name
141
+ self.model = model
142
+ self.cwd = cwd
143
+ self.allowance = allowance
144
+ self.sessions_dir = sessions_dir
145
+ self.system_for = system_for
146
+ self.first_message_for = first_message_for
147
+ self.cell_timeout = cell_timeout
148
+ self.max_agents = max_agents
149
+ self.tracer = tracer or Tracer(None)
150
+ self.tool_filter = None
151
+ self.improver = None
152
+ self._finished = False
153
+ self.records: dict[str, ChildRecord] = {}
154
+ self._kids: dict[str, list[str]] = {}
155
+ self._agents_started = 0
156
+ self.nodes: dict[str, AgentNode] = {}
157
+ self.kernels: dict[str, KernelSession] = {}
158
+ self.sessions: list = []
159
+ self._children: dict[str, list[str]] = {}
160
+ self._session_of: dict[str, object] = {}
161
+ self._bridge = BridgeServer(self._handle)
162
+ self._live = asyncio.Semaphore(max(1, allowance.max_live))
163
+ self._ids = itertools.count(1)
164
+ self._root_id: str | None = None
165
+ self.listeners = []
166
+ self.event_path: Path | None = None
167
+ self._event_prefix = uuid4().hex
168
+ self._reported_root = False
169
+
170
+ async def start(self) -> None:
171
+ await self._bridge.start()
172
+
173
+ def provider_for(self, agent_id: str) -> TracedProvider:
174
+ return TracedProvider(self, agent_id)
175
+
176
+ def use_provider(self, provider, provider_name: str) -> BudgetedProvider:
177
+ """Switch every agent, from the next call on, to `provider`, under the same budget."""
178
+ self.provider = BudgetedProvider(with_external(provider), self.allowance)
179
+ self.provider_name = provider_name
180
+ return self.provider
181
+
182
+ def attach(self, agent_id: str, session) -> None:
183
+ """Record sub-agents this agent starts in `session`'s saved file."""
184
+ self._session_of[agent_id] = session
185
+ if agent_id == self.root_id():
186
+ self.session = session
187
+
188
+ def root_id(self) -> str:
189
+ if self._root_id is None:
190
+ self.root_kernel()
191
+ return self._root_id
192
+
193
+ def _can_delegate(self, node: AgentNode) -> bool:
194
+ return node.depth < self.allowance.max_depth
195
+
196
+ def _kernel_for(self, node: AgentNode) -> KernelSession:
197
+ if self.kernel_kind == "wasm":
198
+ return self._sandbox_for(node)
199
+ if not self._bridge.port:
200
+ raise RuntimeError("start() the agent tree before creating kernels")
201
+ env = kernel_env(
202
+ self._bridge.port,
203
+ self._bridge.issue(node.id),
204
+ node.id,
205
+ can_delegate=self._can_delegate(node),
206
+ is_child=node.parent_id is not None,
207
+ )
208
+ kernel = KernelSession(
209
+ cwd=self.cwd, env=env, startup_code=KERNEL_API, timeout=self.cell_timeout
210
+ )
211
+ self.kernels[node.id] = kernel
212
+ return kernel
213
+
214
+ def _sandbox_for(self, node: AgentNode) -> WasmKernel:
215
+ """Give each recursive agent a private interpreter in the shared Node host."""
216
+ workspace = Workspace(self.cwd, self.approver)
217
+ workspace.policy = getattr(self, "policy", None)
218
+ functions = workspace.functions()
219
+ if workspace.policy is not None:
220
+ functions = {name: fn for name, fn in functions.items()
221
+ if workspace.policy.allows_tool(name)}
222
+
223
+ async def call(op, args):
224
+ return await self._handle(node.id, op, args)
225
+
226
+ async def final(value):
227
+ await self._handle(node.id, "final", {"value": value, "note": ""})
228
+
229
+ kernel = WasmKernel(
230
+ cwd=self.cwd, bridges={"_rlmagent": call, **functions},
231
+ startup_code=wasm_api(self._can_delegate(node), node.parent_id is not None,
232
+ network=self.network),
233
+ timeout=self.cell_timeout, on_final=final, restore=self.restore_kernel,
234
+ )
235
+ self.kernels[node.id] = kernel
236
+ return kernel
237
+
238
+ def root_kernel(self) -> KernelSession:
239
+ if self._root_id is None:
240
+ node = AgentNode(id="root", depth=0, parent_id=None)
241
+ self.nodes[node.id] = node
242
+ self._root_id = node.id
243
+ self._kernel_for(node)
244
+ return self.kernels[self._root_id]
245
+
246
+ async def _handle(self, agent_id: str, op: str, args: dict) -> object:
247
+ node = self.nodes.get(agent_id)
248
+ if node is None:
249
+ raise RuntimeError(f"unknown agent {agent_id!r}")
250
+ policy = getattr(self, "policy", None)
251
+ if op in ("guidance", "improve", "playbook") and policy is not None and not policy.allows_tool(op):
252
+ raise Refused(f"{op} is disabled by the tool policy")
253
+ if op == "context":
254
+ return node.context
255
+ if op == "final":
256
+ node.final_value = args.get("value")
257
+ node.final_note = str(args.get("note") or "")
258
+ node.final_given = True
259
+ return None
260
+ if op == "capabilities":
261
+ from rlmagent.backend import limits_of
262
+
263
+ return limits_of(self)
264
+ if op == "guidance":
265
+ refinement = getattr(self, "refinement", None)
266
+ if refinement is None:
267
+ raise RuntimeError("No refined guidance is attached to this run")
268
+ return refinement.read_guidance(args.get("id"))
269
+ if op in ("improve", "playbook"):
270
+ improver = self._improver_of(node)
271
+ if improver is None:
272
+ raise RuntimeError("the playbook is not available in this run")
273
+ if op == "playbook":
274
+ if args.get("id"):
275
+ return improver.entry(str(args["id"]))
276
+ return [{"id": e.id, "kind": e.kind, "title": e.title} for e in improver.entries()]
277
+ improver.request(args.get("instructions"), bool(args.get("shared")))
278
+ return "Noted: the playbook will be updated when this turn ends."
279
+ if op == "tell_parent":
280
+ rec = self.records.get(agent_id)
281
+ if rec is None:
282
+ raise RuntimeError("only sub-agents have a parent")
283
+ text = str(args.get("text", ""))
284
+ messages = [*rec.outbox, text]
285
+ rec.notes = record_notes(rec.notes, rec.outbox, messages)
286
+ rec.outbox = messages
287
+ self._child_event("child_progress", agent_id, text=text[:500])
288
+ return None
289
+ if op == "spawn":
290
+ return self.spawn(
291
+ agent_id, str(args.get("task", "")), args.get("context"), bool(args.get("keep")),
292
+ role=args.get("role"), model=args.get("model"),
293
+ )
294
+ if op == "children":
295
+ return list(self._kids.get(agent_id, []))
296
+ if op == "child_handle":
297
+ action = args.get("action")
298
+ if action in ("name", "find"):
299
+ name = checked_name(args.get("name"))
300
+ match = next((key for key in self._kids.get(agent_id, ())
301
+ if self.records[key].name == name
302
+ and self.records[key].status != "cancelled"), None)
303
+ if action == "find":
304
+ if match is None:
305
+ raise ValueError(f"no child named {name!r}")
306
+ return {"id": match}
307
+ if match is not None and match != args.get("id"):
308
+ raise ValueError(f"child name {name!r} is already in use")
309
+ rec = self._own(agent_id, str(args.get("id")))
310
+ if rec.status == "cancelled":
311
+ raise ValueError("child is cancelled")
312
+ ledger = getattr(getattr(self, "session", None), "steering", None)
313
+ if action == "messages":
314
+ return notes_metadata(rec.notes, args.get("new_only", True))
315
+ if action == "read_message":
316
+ rec.notes, note = read_note(rec.notes, args.get("identity"))
317
+ return note
318
+ if action == "steer":
319
+ if ledger is None:
320
+ raise ValueError("child steering requires an attached session")
321
+ return ledger.accept(args.get("text"), args.get("message_id"), rec.node.id, once=True,
322
+ allow_new=rec.status in ("starting", "running"))
323
+ if action == "name":
324
+ rec.name = name
325
+ self._child_event("child_named", rec.node.id, name=name)
326
+ elif action != "info":
327
+ raise ValueError("invalid child handle operation")
328
+ return {"id": rec.node.id, "name": rec.name, "status": rec.status, "keep": rec.keep,
329
+ "queued": rec.inbox.qsize(),
330
+ "steering": ledger.receipts(target=rec.node.id) if ledger is not None else [],
331
+ "usage": self.tracer.child_usage(rec.node.id, compatibility=True)}
332
+ if op == "rlm":
333
+ child = self.records[self.spawn(agent_id, str(args.get("task", "")), args.get("context"),
334
+ role=args.get("role"), model=args.get("model"))]
335
+ async with self._waiting_on_children(node):
336
+ try:
337
+ return await self._result(child, None)
338
+ except asyncio.CancelledError:
339
+ await self._cancel(child)
340
+ raise
341
+ if op == "gather":
342
+ jobs = [(str(task), context) for task, context in args.get("jobs", [])]
343
+ children = []
344
+ try:
345
+ await self._confirm_batch(node, jobs)
346
+ for task, context in jobs:
347
+ children.append(self.records[self.spawn(agent_id, task, context, confirm=False)])
348
+ async with self._waiting_on_children(node):
349
+ return await self._gather(children)
350
+ except BaseException:
351
+ for child in children:
352
+ await self._cancel(child)
353
+ raise
354
+ rec = self._own(agent_id, str(args.get("id")))
355
+ if op == "status":
356
+ return rec.status
357
+ if op == "messages":
358
+ out, rec.outbox = rec.outbox, []
359
+ rec.notes = record_notes(rec.notes, out, [])
360
+ return out
361
+ if op == "cancel":
362
+ await self._cancel(rec)
363
+ return None
364
+ if op == "send":
365
+ await self._send(rec, str(args.get("text", "")))
366
+ return None
367
+ if op == "result":
368
+ async with self._waiting_on_children(node):
369
+ return await self._result(rec, args.get("timeout"))
370
+ raise RuntimeError(f"unknown request {op!r}")
371
+
372
+ def _improver_of(self, node: AgentNode):
373
+ if node.parent_id is None:
374
+ return self.improver
375
+ rec = self.records.get(node.id)
376
+ return getattr(rec.session, "improver", None) if rec else None
377
+
378
+ def _role(self, role: str) -> str:
379
+ try:
380
+ entry = self.improver.entry(role) if self.improver else None
381
+ except KeyError:
382
+ entry = None
383
+ if entry is None or entry["kind"] != "role":
384
+ raise RuntimeError(f"no playbook role named {role!r}")
385
+ return f"## Your role: {entry['title']}\n{entry['content']}"
386
+
387
+ def _own(self, agent_id: str, child_id: str) -> ChildRecord:
388
+ rec = self.records.get(child_id)
389
+ if rec is None or rec.node.parent_id != agent_id:
390
+ raise RuntimeError(f"{child_id} is not your sub-agent")
391
+ return rec
392
+
393
+ async def _gather(self, children: list[ChildRecord]) -> list:
394
+ try:
395
+ async with asyncio.TaskGroup() as group:
396
+ waits = [group.create_task(self._result(c, None)) for c in children]
397
+ except* Exception as failed:
398
+ raise failed.exceptions[0] from None
399
+ return [w.result() for w in waits]
400
+
401
+ @contextlib.asynccontextmanager
402
+ async def _waiting_on_children(self, node: AgentNode):
403
+ """Pause the agent's cell timeout and free its live slot while it awaits sub-agents."""
404
+ with self.kernels[node.id].waiting_outside():
405
+ rec = self.records.get(node.id)
406
+ if rec is None or not rec.holds_slot:
407
+ yield
408
+ return
409
+ self._release(rec)
410
+ try:
411
+ yield
412
+ finally:
413
+ await self._acquire(rec)
414
+
415
+ async def _acquire(self, rec: ChildRecord) -> None:
416
+ await self._live.acquire()
417
+ rec.holds_slot = True
418
+
419
+ def _release(self, rec: ChildRecord) -> None:
420
+ if rec.holds_slot:
421
+ rec.holds_slot = False
422
+ self._live.release()
423
+
424
+ def _child_event(self, kind: str, agent_id: str, **fields) -> None:
425
+ run_id = f"{self._event_prefix}/{agent_id}"
426
+ event = {"timestamp": time(), "kind": kind, "run_id": run_id, **fields}
427
+ if agent_id != self._root_id:
428
+ event["child_id"] = run_id
429
+ if self.event_path is not None:
430
+ self.event_path.parent.mkdir(parents=True, exist_ok=True)
431
+ with self.event_path.open("a", encoding="utf-8") as stream:
432
+ stream.write(json.dumps(event, ensure_ascii=False) + "\n")
433
+ for listener in tuple(self.listeners):
434
+ listener(event)
435
+
436
+ def spawn(self, parent_id: str, task: str, context: object, keep: bool = False,
437
+ role: str | None = None, model: str | None = None, confirm: bool = True) -> str:
438
+ parent = self.nodes[parent_id]
439
+ contract_model = getattr(self, "contract_model", None)
440
+ if contract_model is not None and model is not None and model != contract_model:
441
+ raise RuntimeError("sub-agents must use the model specified by the runtime contract")
442
+ role_text = self._role(role) if role else ""
443
+ if is_external(model):
444
+ parse_model(model)
445
+ if not self._can_delegate(parent):
446
+ raise RuntimeError("sub-agents are not available at this depth")
447
+ if self._agents_started >= self.max_agents:
448
+ raise RuntimeError(f"the limit of {self.max_agents} sub-agents started is reached")
449
+ self._agents_started += 1
450
+ node = AgentNode(
451
+ id=f"{parent_id}.{next(self._ids)}",
452
+ depth=parent.depth + 1,
453
+ parent_id=parent_id,
454
+ task=task,
455
+ context=context,
456
+ role_text=role_text,
457
+ model=model or parent.model,
458
+ )
459
+ self.nodes[node.id] = node
460
+ rec = ChildRecord(node=node, keep=keep,
461
+ confirm=self._barely_compressed(parent, context) if confirm else None)
462
+ self.records[node.id] = rec
463
+ self._kids.setdefault(parent_id, []).append(node.id)
464
+ self.tracer.spawned(parent_id, node.id)
465
+ if not self._reported_root:
466
+ root = self.nodes[self._root_id]
467
+ self._child_event("run_started", root.id, depth=0, model=root.model)
468
+ self._reported_root = True
469
+ self._child_event("run_started", node.id, parent_run_id=f"{self._event_prefix}/{parent_id}",
470
+ depth=node.depth, model=node.model)
471
+ rec.on_status = lambda status: self._child_event("child_status", node.id, status=status)
472
+ rec.on_status(rec.status)
473
+ rec.task = asyncio.get_running_loop().create_task(self._run(rec))
474
+ return node.id
475
+
476
+ async def run_child(self, parent_id: str, task: str, context: object) -> object:
477
+ return await self._result(self.records[self.spawn(parent_id, task, context)], None)
478
+
479
+ async def _result(self, rec: ChildRecord, timeout: float | None) -> object:
480
+ async def settled() -> object:
481
+ while rec.status not in _SETTLED:
482
+ await rec.changed.wait()
483
+ if rec.status == "failed":
484
+ raise RuntimeError(rec.error)
485
+ if rec.status == "cancelled":
486
+ raise RuntimeError(f"{rec.node.id} was cancelled")
487
+ self.tracer.returned(rec.node.parent_id, rec.node.id)
488
+ return rec.value
489
+
490
+ if timeout is None:
491
+ return await settled()
492
+ try:
493
+ return await asyncio.wait_for(settled(), float(timeout))
494
+ except (asyncio.TimeoutError, TimeoutError):
495
+ raise TimeoutError(f"{rec.node.id} is still {rec.status}") from None
496
+
497
+ async def _cancel(self, rec: ChildRecord) -> None:
498
+ if rec.task is None:
499
+ return
500
+ if rec.status not in _ENDED:
501
+ rec.task.cancel()
502
+ # A finished agent may still be shutting its kernel down; let that end.
503
+ await asyncio.gather(rec.task, return_exceptions=True)
504
+
505
+ async def _send(self, rec: ChildRecord, text: str) -> None:
506
+ if rec.status in _ENDED:
507
+ raise RuntimeError(f"{rec.node.id} has finished; start a new sub-agent")
508
+ if rec.status == "idle":
509
+ rec.set("starting")
510
+ rec.inbox.put_nowait(text)
511
+ elif rec.session is not None:
512
+ rec.session.inject(text)
513
+ else:
514
+ rec.inbox.put_nowait(text)
515
+
516
+ async def _run(self, rec: ChildRecord) -> None:
517
+ from rlmagent.conversation import CodingSession
518
+
519
+ node = rec.node
520
+ ledger = getattr(getattr(self, "session", None), "steering", None)
521
+ kernel = None
522
+ try:
523
+ await self._acquire(rec)
524
+ rec.set("running")
525
+ if rec.confirm is not None:
526
+ await self._confirm(node, *rec.confirm)
527
+ kernel = self._kernel_for(node)
528
+ tools = (self.tool_filter or build_tool_registry)(kernel)
529
+ session = await CodingSession.create(
530
+ provider=self.provider_for(node.id),
531
+ provider_name=self.provider_name,
532
+ model=node.model or self.model,
533
+ system=self.system_for(node, tools) + (f"\n\n{node.role_text}" if node.role_text else ""),
534
+ tools=tools,
535
+ sessions_dir=self.sessions_dir,
536
+ cwd=self.cwd,
537
+ )
538
+ rec.session = session
539
+ if self.improver is not None:
540
+ from rlmagent.playbook.improver import Improver
541
+
542
+ own = self.sessions_dir / f"{session.session_id}.playbook.json" if self.sessions_dir else None
543
+ session.improver = Improver(session, local_path=own,
544
+ shared_path=self.improver.paths["global"])
545
+ session.improver.auto = False
546
+ session.on_compacted = lambda: self.tracer.compacted(node.id)
547
+ await session.set_name(f"sub-agent {node.id}: {node.task[:60]}")
548
+ self.sessions.append(session)
549
+ self._children.setdefault(node.parent_id, []).append(session.session_id)
550
+ self.attach(node.id, session)
551
+ parent_session = self._session_of.get(node.parent_id)
552
+ if parent_session is not None:
553
+ await parent_session.note_sub_agent(session.session_id)
554
+ message = self.first_message_for(node)
555
+ while True:
556
+ rec.value = await self._round(session, node, message)
557
+ if ledger is not None:
558
+ ledger.expire_child(node.id)
559
+ if not rec.keep:
560
+ rec.set("done")
561
+ return
562
+ self._release(rec)
563
+ rec.set("idle")
564
+ message = await rec.inbox.get()
565
+ await self._acquire(rec)
566
+ node.final_given = False
567
+ rec.set("running")
568
+ except asyncio.CancelledError:
569
+ rec.set("cancelled")
570
+ except Exception as exc:
571
+ rec.error = str(exc) or type(exc).__name__
572
+ rec.set("failed")
573
+ finally:
574
+ self._release(rec)
575
+ for child_id in self._kids.get(node.id, []):
576
+ await self._cancel(self.records[child_id])
577
+ if kernel is not None:
578
+ if "RLM_AGENT_BRIDGE_TOKEN" in kernel.env:
579
+ self._bridge.revoke(kernel.env["RLM_AGENT_BRIDGE_TOKEN"])
580
+ await kernel.shutdown()
581
+ if ledger is not None and rec.status in ("failed", "cancelled"):
582
+ ledger.expire_child(node.id)
583
+
584
+ # -- delegation guard: a sub-agent handed most of its parent's context confirms first --
585
+
586
+ def _barely_compressed(self, parent: AgentNode, context: object) -> tuple[int, int] | None:
587
+ if not self.guard or parent.context is None:
588
+ return None
589
+ parent_chars, child_chars = _size(parent.context), _size(context)
590
+ if parent_chars >= GUARD_MIN_CHARS and child_chars >= GUARD_RATIO * parent_chars:
591
+ return child_chars, parent_chars
592
+ return None
593
+
594
+ async def _judge(self, node: AgentNode, question: str) -> tuple[bool, str]:
595
+ """One tool-less call with the sub-agent's own instructions; only an explicit NO refuses."""
596
+ from rlmagent.conversation import CodingSession
597
+
598
+ session = await CodingSession.create(
599
+ provider=self.provider_for(node.id), provider_name=self.provider_name,
600
+ model=node.model or self.model, system=self.system_for(node, []), tools=[],
601
+ sessions_dir=None, cwd=self.cwd)
602
+ try:
603
+ async for _event in session.submit(question):
604
+ pass
605
+ reply = _last_text(session).strip()
606
+ finally:
607
+ await session.shutdown()
608
+ if self.provider.refusal is not None:
609
+ raise AllowanceSpent(self.provider.refusal)
610
+ word = re.search(r"[A-Za-z]+", reply)
611
+ return (word is None or word.group().upper() != "NO"), reply or "(no reason given)"
612
+
613
+ async def _confirm(self, node: AgentNode, child_chars: int, parent_chars: int) -> None:
614
+ pct = round(100 * child_chars / max(1, parent_chars))
615
+ ok, reason = await self._judge(node, self.first_message_for(node) + "\n\n" + CONFIRM.format(
616
+ child=child_chars, pct=pct, parent=parent_chars))
617
+ if not ok:
618
+ raise RuntimeError(REJECTED.format(pct=pct, reason=reason))
619
+
620
+ async def _confirm_batch(self, parent: AgentNode, jobs: list[tuple[str, object]]) -> None:
621
+ """One check for a whole gather_rlm, and only if some job gets most of the parent's context."""
622
+ flagged = [self._barely_compressed(parent, context) for _, context in jobs]
623
+ if not any(flagged):
624
+ return
625
+ parent_chars = _size(parent.context)
626
+ lines = "\n".join(
627
+ f" [{i}] {_size(c):,} chars ({round(100 * _size(c) / max(1, parent_chars))}% of the "
628
+ f"parent's context): {task[:140]}" for i, (task, c) in enumerate(jobs, 1))
629
+ ok, reason = await self._judge(parent, CONFIRM_BATCH.format(
630
+ n=len(jobs), parent=parent_chars, lines=lines, flagged=sum(1 for f in flagged if f)))
631
+ if not ok:
632
+ raise RuntimeError(BATCH_REJECTED.format(reason=reason))
633
+
634
+ async def _round(self, session, node: AgentNode, message: str) -> object:
635
+ for _ in range(2):
636
+ async for _event in session.submit(message):
637
+ pass
638
+ if self.provider.refusal is not None:
639
+ raise AllowanceSpent(self.provider.refusal)
640
+ if node.final_given:
641
+ if node.final_note:
642
+ return f"{node.final_value}\n[{node.final_note}]"
643
+ return node.final_value
644
+ message = NUDGE
645
+ return f"{_last_text(session)}\n{NEVER_FINAL}"
646
+
647
+ def child_session_ids(self, parent_id: str) -> list[str]:
648
+ """Saved-session ids of the sub-agents an agent started, in order."""
649
+ return list(self._children.get(parent_id, []))
650
+
651
+ async def close(self) -> None:
652
+ for rec in list(self.records.values()):
653
+ await self._cancel(rec)
654
+ for kernel in list(self.kernels.values()):
655
+ await kernel.shutdown()
656
+ await self._bridge.close()
657
+ self.finish_trace()
658
+
659
+ def finish_trace(self, complete: bool = True) -> dict | None:
660
+ if self._finished:
661
+ return None
662
+ self._finished = True
663
+ root = self._session_of.get(self._root_id or "root")
664
+ agents = [{"agent": "root", "parent": None, "task": None, "status": "done",
665
+ "session": getattr(root, "session_id", None)}]
666
+ agents += [
667
+ {"agent": r.node.id, "parent": r.node.parent_id, "task": r.node.task,
668
+ "status": r.status, "session": getattr(r.session, "session_id", None)}
669
+ for r in self.records.values()
670
+ ]
671
+ agents += [{"agent": key, "parent": item.get("parent_run_id"),
672
+ "task": item.get("instruction"), "status": item.get("status"), "session": None}
673
+ for key, item in getattr(self, "backend_agents", {}).items()
674
+ if item.get("depth", 0) > 0]
675
+ return self.tracer.finish(agents, self.provider.refusal, complete)
676
+
677
+
678
+ def _last_text(session) -> str:
679
+ for entry in reversed(session.transcript):
680
+ if getattr(entry, "role", None) == "assistant":
681
+ return entry.text
682
+ return ""