@steerable/runtime-win32-x64 0.9.2 → 0.10.1

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 (68) hide show
  1. package/bin/steerable-egress-proxy.exe +0 -0
  2. package/bin/win-spawn-helper.exe +0 -0
  3. package/package.json +1 -1
  4. package/python-runtime/manifest.json +1 -1
  5. package/python-runtime/python/Lib/site-packages/cffi-2.1.1.dist-info/RECORD +1 -1
  6. package/python-runtime/python/Lib/site-packages/httpx-0.28.1.dist-info/RECORD +1 -1
  7. package/python-runtime/python/Lib/site-packages/idna-3.20.dist-info/RECORD +1 -1
  8. package/python-runtime/python/Lib/site-packages/jsonschema-4.26.0.dist-info/RECORD +1 -1
  9. package/python-runtime/python/Lib/site-packages/mcp-1.30.0.dist-info/RECORD +1 -1
  10. package/python-runtime/python/Lib/site-packages/python_dotenv-1.2.4.dist-info/RECORD +1 -1
  11. package/python-runtime/python/Lib/site-packages/pywin32-312.dist-info/RECORD +2 -2
  12. package/python-runtime/python/Lib/site-packages/{steerable_agent_harness-0.9.2.dist-info → steerable_agent_harness-0.10.1.dist-info}/METADATA +1 -1
  13. package/python-runtime/python/Lib/site-packages/{steerable_agent_harness-0.9.2.dist-info → steerable_agent_harness-0.10.1.dist-info}/RECORD +7 -7
  14. package/python-runtime/python/Lib/site-packages/{steerable_agent_protocol-0.9.2.dist-info → steerable_agent_protocol-0.10.1.dist-info}/METADATA +1 -1
  15. package/python-runtime/python/Lib/site-packages/steerable_agent_protocol-0.10.1.dist-info/RECORD +11 -0
  16. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/__init__.py +48 -0
  17. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/budget.py +124 -0
  18. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/child_notice.py +84 -0
  19. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/child_record.py +174 -0
  20. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/engines.py +499 -0
  21. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/history.py +114 -1
  22. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/orchestration.py +496 -33
  23. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/pool.py +662 -16
  24. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/registry.py +345 -0
  25. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/storage/__init__.py +52 -0
  26. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/storage/in_memory.py +74 -0
  27. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/storage/sqlalchemy_store.py +153 -0
  28. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/storage/sqlite_store.py +117 -1
  29. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/subagent.py +157 -6
  30. package/python-runtime/python/Lib/site-packages/{steerable_agent_runtime-0.9.2.dist-info → steerable_agent_runtime-0.10.1.dist-info}/METADATA +39 -4
  31. package/python-runtime/python/Lib/site-packages/{steerable_agent_runtime-0.9.2.dist-info → steerable_agent_runtime-0.10.1.dist-info}/RECORD +26 -16
  32. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime_native/steerable_agent_runtime_native.pyd +0 -0
  33. package/python-runtime/python/Lib/site-packages/{steerable_agent_runtime_native-1.0.8.dist-info → steerable_agent_runtime_native-1.0.9.dist-info}/METADATA +1 -1
  34. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime_native-1.0.9.dist-info/RECORD +9 -0
  35. package/python-runtime/python/Lib/site-packages/steerable_sidecar/sidecar.py +545 -6
  36. package/python-runtime/python/Lib/site-packages/steerable_sidecar-0.10.1.dist-info/METADATA +97 -0
  37. package/python-runtime/python/Lib/site-packages/{steerable_sidecar-0.9.2.dist-info → steerable_sidecar-0.10.1.dist-info}/RECORD +12 -12
  38. package/python-runtime/python/Lib/site-packages/uvicorn-0.54.0.dist-info/RECORD +1 -1
  39. package/python-runtime/python/Scripts/cffi-gen-src.exe +0 -0
  40. package/python-runtime/python/Scripts/dotenv.exe +0 -0
  41. package/python-runtime/python/Scripts/httpx.exe +0 -0
  42. package/python-runtime/python/Scripts/idna.exe +0 -0
  43. package/python-runtime/python/Scripts/jsonschema.exe +0 -0
  44. package/python-runtime/python/Scripts/mcp.exe +0 -0
  45. package/python-runtime/python/Scripts/pip.exe +0 -0
  46. package/python-runtime/python/Scripts/pip3.12.exe +0 -0
  47. package/python-runtime/python/Scripts/pip3.exe +0 -0
  48. package/python-runtime/python/Scripts/pywin32_postinstall.exe +0 -0
  49. package/python-runtime/python/Scripts/pywin32_testall.exe +0 -0
  50. package/python-runtime/python/Scripts/steerable-sidecar-acp.exe +0 -0
  51. package/python-runtime/python/Scripts/steerable-sidecar-headless.exe +0 -0
  52. package/python-runtime/python/Scripts/steerable-sidecar.exe +0 -0
  53. package/python-runtime/python/Scripts/uvicorn.exe +0 -0
  54. package/runtime.json +90 -70
  55. package/python-runtime/python/Lib/site-packages/steerable_agent_protocol-0.9.2.dist-info/RECORD +0 -11
  56. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime_native-1.0.8.dist-info/RECORD +0 -9
  57. package/python-runtime/python/Lib/site-packages/steerable_sidecar-0.9.2.dist-info/METADATA +0 -58
  58. /package/python-runtime/python/Lib/site-packages/{steerable_agent_harness-0.9.2.dist-info → steerable_agent_harness-0.10.1.dist-info}/INSTALLER +0 -0
  59. /package/python-runtime/python/Lib/site-packages/{steerable_agent_harness-0.9.2.dist-info → steerable_agent_harness-0.10.1.dist-info}/WHEEL +0 -0
  60. /package/python-runtime/python/Lib/site-packages/{steerable_agent_protocol-0.9.2.dist-info → steerable_agent_protocol-0.10.1.dist-info}/INSTALLER +0 -0
  61. /package/python-runtime/python/Lib/site-packages/{steerable_agent_protocol-0.9.2.dist-info → steerable_agent_protocol-0.10.1.dist-info}/WHEEL +0 -0
  62. /package/python-runtime/python/Lib/site-packages/{steerable_agent_runtime-0.9.2.dist-info → steerable_agent_runtime-0.10.1.dist-info}/INSTALLER +0 -0
  63. /package/python-runtime/python/Lib/site-packages/{steerable_agent_runtime-0.9.2.dist-info → steerable_agent_runtime-0.10.1.dist-info}/WHEEL +0 -0
  64. /package/python-runtime/python/Lib/site-packages/{steerable_agent_runtime_native-1.0.8.dist-info → steerable_agent_runtime_native-1.0.9.dist-info}/INSTALLER +0 -0
  65. /package/python-runtime/python/Lib/site-packages/{steerable_agent_runtime_native-1.0.8.dist-info → steerable_agent_runtime_native-1.0.9.dist-info}/WHEEL +0 -0
  66. /package/python-runtime/python/Lib/site-packages/{steerable_sidecar-0.9.2.dist-info → steerable_sidecar-0.10.1.dist-info}/INSTALLER +0 -0
  67. /package/python-runtime/python/Lib/site-packages/{steerable_sidecar-0.9.2.dist-info → steerable_sidecar-0.10.1.dist-info}/WHEEL +0 -0
  68. /package/python-runtime/python/Lib/site-packages/{steerable_sidecar-0.9.2.dist-info → steerable_sidecar-0.10.1.dist-info}/entry_points.txt +0 -0
@@ -25,19 +25,35 @@ Design:
25
25
  - **Session state is isolated.** A child runs with its durable record id as
26
26
  ``chat_id`` when available, otherwise a pool-local id, so session-scoped
27
27
  tools such as ``todo_write`` cannot overwrite a sibling or parent list.
28
+ - **The pool is not the index.** A pool is built per turn and shut down with
29
+ it, so it can only answer "what is running now". ``ChildRegistry`` is the
30
+ durable answer to "what did this session spawn"; the pool writes to it at
31
+ spawn and at every terminal exit, and ``adopt`` lets a resumed child rejoin
32
+ a fresh pool under its original lineage id.
28
33
  """
29
34
 
30
35
  from __future__ import annotations
31
36
 
32
37
  import asyncio
33
38
  import logging
34
- from collections.abc import Callable
39
+ from collections.abc import Callable, Sequence
35
40
  from dataclasses import dataclass
36
41
  from itertools import count
37
42
  from typing import Any
38
43
 
39
- from .llm import LLMMessage
44
+ from .budget import SessionBudgetExceeded, TreeBudget, TreeBudgetReport
45
+ from .engines import (
46
+ AcpEngineSpec,
47
+ AcpOutcome,
48
+ AcpPermissionPolicy,
49
+ AcpToolBridge,
50
+ AcpUnavailable,
51
+ run_acp_child,
52
+ )
53
+ from .history import HistorySeed, entry_to_dict
54
+ from .llm import LLMMessage, LLMUsage
40
55
  from .loop import CoreLoop
56
+ from .registry import ChildRegistry, SpawnedChild, default_record_id, registry_status
41
57
 
42
58
  logger = logging.getLogger(__name__)
43
59
 
@@ -65,6 +81,12 @@ class OrchestrationConfig:
65
81
  per-tool backstop (default 5 min) so a hung child surfaces as a
66
82
  ``running`` outcome, not a tool timeout. The six tool names configure
67
83
  the orchestration family the ``OrchestrationExecutor`` exposes.
84
+
85
+ ``fork_turns`` caps how many of the parent's user requests a forked child
86
+ inherits. ``None`` (the default) keeps the whole answered prefix — the
87
+ right choice for "continue what we were doing" — and a number is the
88
+ escape hatch for a parent conversation long enough to crowd the child's
89
+ own work out of its context window.
68
90
  """
69
91
 
70
92
  max_depth: int = 1
@@ -72,6 +94,7 @@ class OrchestrationConfig:
72
94
  child_max_rounds: int = 8
73
95
  child_max_tool_errors: int = 3
74
96
  wait_timeout_s: float = 120.0
97
+ fork_turns: int | None = None
75
98
  spawn_tool: str = "agent_spawn"
76
99
  send_tool: str = "agent_send"
77
100
  wait_tool: str = "agent_wait"
@@ -104,8 +127,10 @@ class ChildOutcome:
104
127
 
105
128
 
106
129
  class _ChildHandle:
107
- def __init__(self, child_id: str, loop: CoreLoop) -> None:
130
+ def __init__(self, child_id: str, loop: CoreLoop | None) -> None:
108
131
  self.child_id = child_id
132
+ #: ``None`` for an external (ACP) child: it has no local loop. Every
133
+ #: local-only path checks for it rather than pretending one exists.
109
134
  self.loop = loop
110
135
  self.task: asyncio.Task[None] | None = None
111
136
  self.outcome: ChildOutcome | None = None
@@ -121,6 +146,23 @@ class _ChildHandle:
121
146
  #: no dangling tool_calls). ``None`` before the first clean finish;
122
147
  #: the hard-cancel backstop path leaves no resumable record.
123
148
  self.resume_messages: list[LLMMessage] | None = None
149
+ #: Bill of the last settled run. ``None`` means either "not settled
150
+ #: yet" or "settled without a bill" — ``usage_known`` separates the
151
+ #: two: an external engine reports no tokens at all, while a local
152
+ #: child that reported none is a known zero.
153
+ self.usage: LLMUsage | None = None
154
+ self.usage_known = True
155
+ #: The profile this child was created under, so a usage breakdown can
156
+ #: say which role spent the tokens.
157
+ self.profile: str | None = None
158
+ #: Which engine ran this child. ``coreloop`` bills tokens it can count;
159
+ #: an external engine (ACP) reports no token bill at all, which is a
160
+ #: different fact from "ran and reported zero".
161
+ self.engine = "coreloop"
162
+ #: Set only for an external child (see ``AgentPool.spawn``).
163
+ self.acp_engine: AcpEngineSpec | None = None
164
+ self.acp_permission: AcpPermissionPolicy | None = None
165
+ self.acp_tool_bridge: AcpToolBridge | None = None
124
166
 
125
167
 
126
168
  #: Builds a child's loop and advertised tool schemas for a child id and tool
@@ -132,6 +174,37 @@ LoopFactory = Callable[
132
174
  ]
133
175
 
134
176
 
177
+ @dataclass(frozen=True, slots=True)
178
+ class SeedProvenance:
179
+ """Where a fork child's seed came from.
180
+
181
+ The child's record has to answer "why does it already know this?" without
182
+ reading the parent's transcript, so the seed is written as one
183
+ ``HistorySeed`` entry carrying its source record, the cut it used, and the
184
+ model that produced the text — the same format a branch fork writes, so
185
+ ``agent.session.tree`` reads both as the same kind of edge.
186
+ """
187
+
188
+ source_record_id: str
189
+ source_until_seq: int | None = None
190
+ source_model: str | None = None
191
+
192
+
193
+ def _usage_payload(usage: LLMUsage) -> dict[str, int]:
194
+ """Wire shape for a child's token bill.
195
+
196
+ Mirrors the stream's own ``usage`` keys (camelCase, ``promptTokens`` …) so a
197
+ host that already reads the turn's usage does not learn a second spelling
198
+ for the same numbers.
199
+ """
200
+ return {
201
+ "promptTokens": usage.prompt_tokens,
202
+ "completionTokens": usage.completion_tokens,
203
+ "totalTokens": usage.total_tokens,
204
+ "cachedPromptTokens": usage.cached_prompt_tokens,
205
+ }
206
+
207
+
135
208
  class AgentPool:
136
209
  """Concurrent child CoreLoops for one parent, budgeted and lineage-tracked.
137
210
 
@@ -149,6 +222,10 @@ class AgentPool:
149
222
  lineage: str,
150
223
  loop_factory: LoopFactory | None = None,
151
224
  event_sink: Callable[[str, dict[str, Any]], None] | None = None,
225
+ registry: ChildRegistry | None = None,
226
+ parent_record_id: str | None = None,
227
+ budget: TreeBudget | None = None,
228
+ history_store: Any | None = None,
152
229
  ) -> None:
153
230
  self._config = config
154
231
  self._depth = depth
@@ -158,6 +235,35 @@ class AgentPool:
158
235
  self._event_sink = event_sink
159
236
  self._children: dict[str, _ChildHandle] = {}
160
237
  self._seq = 0
238
+ #: Children whose terminal outcome has already been handed to the
239
+ #: model by ``wait_any``. A fan-out collects one answer per call, so
240
+ #: a child that was already reported must not be reported again —
241
+ #: otherwise "spawn five, wait five times" returns the same child
242
+ #: five times and the other four answers are unreachable.
243
+ self._reported: set[str] = set()
244
+ #: Terminal notices awaiting delivery to the parent, as
245
+ #: ``(child_id, status, answer)``. Filled by ``_settle_child`` and
246
+ #: drained by the ``ChildNoticeHooks`` a host can install, so the
247
+ #: parent learns a child finished without polling ``agent_wait``.
248
+ #: A plain list, not a queue: draining happens on the same event loop
249
+ #: and must stay ordered by settlement.
250
+ self._notices: list[tuple[str, str, str]] = []
251
+ #: Durable index (``registry.py``). ``None`` keeps the pool exactly
252
+ #: as it was before the index existed: runs work, nothing is
253
+ #: remembered across turns.
254
+ self._registry = registry
255
+ #: The parent's own durable record id, needed to name a child record
256
+ #: (``<parent>:child:<lineage>``). Absent for a parent that never had
257
+ #: one (a bare test loop), in which case children go unregistered.
258
+ self._parent_record_id = parent_record_id
259
+ #: One token limit for the whole tree. ``None`` means each run keeps
260
+ #: its own accounting, which is the pre-delegation behaviour — a pool
261
+ #: without a shared budget cannot charge siblings against each other.
262
+ self._budget = budget
263
+ #: The record channel. Needed only to write a fork seed's provenance
264
+ #: into the child's own record; ``None`` keeps the legacy behaviour
265
+ #: (the seed is just the child's opening messages).
266
+ self._history_store = history_store
161
267
 
162
268
  def _emit(self, kind: str, data: dict[str, Any]) -> None:
163
269
  if self._event_sink is not None:
@@ -171,19 +277,49 @@ class AgentPool:
171
277
  loop_factory: LoopFactory | None = None,
172
278
  event_extra: dict[str, Any] | None = None,
173
279
  system_prompt: str | None = None,
280
+ profile: str | None = None,
281
+ engine: str = "coreloop",
282
+ seed: list[LLMMessage] | None = None,
283
+ seed_provenance: SeedProvenance | None = None,
284
+ acp_engine: AcpEngineSpec | None = None,
285
+ acp_permission: AcpPermissionPolicy | None = None,
286
+ acp_tool_bridge: AcpToolBridge | None = None,
174
287
  ) -> _ChildHandle:
175
288
  """Start a child loop immediately; returns its handle.
176
289
 
177
290
  ``loop_factory`` overrides the constructor's default for this spawn;
178
291
  ``event_extra`` merges into the ``child_spawned`` payload (the
179
292
  delegation seam adds the resolved ``profile`` name there).
293
+
180
294
  ``system_prompt`` seeds the child's transcript ahead of the task —
181
295
  the delegation seam passes the resolved profile's system prompt
182
296
  (CC ``.claude/agents`` body parity); without one the child starts
183
297
  from the bare task, as before.
298
+
299
+ ``profile`` and ``engine`` are what the durable index records: resume
300
+ rebuilds the child's tool domain and nesting from them, so a resumed
301
+ child keeps the permissions it was created with.
302
+
303
+ ``seed`` prepends already-built messages ahead of the task (the fork
304
+ path); omitting it keeps the legacy ``[user(task)]`` transcript.
305
+ ``seed_provenance`` writes those messages into the child's own record
306
+ as one ``HistorySeed`` entry (source record, cut, producing model),
307
+ the same shape a branch fork writes — so "why does this child already
308
+ know that?" is answerable from its record alone.
309
+
310
+ ``acp_engine`` switches this child to an external agent process: the
311
+ pool then runs ``run_acp_child`` instead of a ``CoreLoop``, and the
312
+ remaining ``acp_*`` arguments configure that run. ``loop_factory`` is
313
+ ignored in that case (there is no local loop), and the handle's
314
+ ``loop`` attribute is ``None``.
315
+
316
+ Registration happens **before** the child runs, and only when this
317
+ pool has both a registry and the parent's record id: a crash during
318
+ the spawn then still leaves a resumable row behind, while a bare pool
319
+ (tests, hosts without storage) behaves exactly as before.
184
320
  """
185
321
  factory = loop_factory or self._loop_factory
186
- if factory is None:
322
+ if acp_engine is None and factory is None:
187
323
  raise RuntimeError(
188
324
  "AgentPool.spawn needs a loop factory — none was given at "
189
325
  "construction or per spawn"
@@ -195,17 +331,54 @@ class AgentPool:
195
331
  f"({len(live)} children running); wait for one to finish "
196
332
  "or close one before spawning again"
197
333
  )
334
+ # A tree that already spent its budget must not start new work: the
335
+ # child would run until its own wall and then be refused anyway, which
336
+ # costs tokens for nothing.
337
+ if self._budget is not None and self._budget.exhausted:
338
+ raise OrchestrationBudgetExceeded(
339
+ f"the turn tree has spent {self._budget.report().spent} of its "
340
+ f"{self._budget.limit} tokens; no further children can start"
341
+ )
198
342
  self._seq += 1
199
343
  child_id = f"{self._lineage}.{self._seq}"
200
- loop, schemas = factory(child_id, tool_filter)
344
+ if acp_engine is not None:
345
+ # An external child has no local loop: it is a process, driven by
346
+ # `run_acp_child`, and its answer is the only thing that comes
347
+ # back. The pool still owns its lifecycle, capacity and lineage.
348
+ loop = None
349
+ schemas = None
350
+ else:
351
+ assert factory is not None
352
+ loop, schemas = factory(child_id, tool_filter)
201
353
  handle = _ChildHandle(child_id, loop)
202
354
  handle.task_desc = task
203
355
  handle.schemas = schemas
356
+ handle.profile = profile
357
+ handle.engine = engine
358
+ # An engine that reports no token bill at all is "unknown", never zero:
359
+ # the difference decides whether a turn's total can be read as complete.
360
+ handle.usage_known = engine == "coreloop"
361
+ handle.acp_engine = acp_engine
362
+ handle.acp_permission = acp_permission
363
+ handle.acp_tool_bridge = acp_tool_bridge
204
364
  self._children[child_id] = handle
205
- seed = [LLMMessage.text_of("user", task)]
365
+ record_id = self._register_child(
366
+ child_id, depth=self._depth + 1, profile=profile, engine=engine
367
+ )
368
+ self._watch_child(handle, loop)
369
+ if seed and seed_provenance is not None and loop is not None and loop.record_id:
370
+ # Persist the inherited prefix with its provenance *before* the
371
+ # child's first request, so the child's record explains where its
372
+ # opening context came from and `agent.session.tree` sees the edge.
373
+ self._write_seed_entry(loop, seed, seed_provenance)
374
+ child_seed = [
375
+ *(seed or []),
376
+ LLMMessage.text_of("user", task),
377
+ ]
206
378
  if system_prompt:
207
- seed.insert(0, LLMMessage.text_of("system", system_prompt))
208
- handle.task = asyncio.ensure_future(self._run_child(handle, seed))
379
+ child_seed.insert(0, LLMMessage.text_of("system", system_prompt))
380
+ handle.task = asyncio.ensure_future(self._run_child(handle, child_seed))
381
+ loop_record_id = loop.record_id if loop is not None else None
209
382
  self._emit(
210
383
  "child_spawned",
211
384
  {
@@ -214,12 +387,150 @@ class AgentPool:
214
387
  "task": task,
215
388
  # Present only when the factory gave the child a durable
216
389
  # record; hosts read that record to show the child's process.
217
- **({"recordId": loop.record_id} if loop.record_id else {}),
390
+ **({"recordId": loop_record_id} if loop_record_id else {}),
391
+ **({"profile": profile} if profile else {}),
392
+ **({"engine": engine} if engine != "coreloop" else {}),
393
+ **(
394
+ {"recordId": record_id}
395
+ if record_id and not loop_record_id
396
+ else {}
397
+ ),
218
398
  **(event_extra or {}),
219
399
  },
220
400
  )
221
401
  return handle
222
402
 
403
+ def adopt(
404
+ self,
405
+ *,
406
+ child_id: str,
407
+ loop: CoreLoop,
408
+ schemas: list[dict[str, Any]] | None,
409
+ seed: list[LLMMessage],
410
+ task: str = "",
411
+ profile: str | None = None,
412
+ engine: str = "coreloop",
413
+ record_id: str | None = None,
414
+ ) -> _ChildHandle:
415
+ """Run an already-built child under an **existing** lineage id.
416
+
417
+ Resume path: ``child_id`` (``0.2``) and the loop come from the
418
+ registry row, so the resumed child keeps its lineage, depth and tool
419
+ domain instead of gaining a new sequence number. It still consumes
420
+ ``max_parallel`` capacity, because it is a real running child.
421
+ """
422
+ if child_id in self._children:
423
+ handle = self._children[child_id]
424
+ if handle.outcome is None:
425
+ raise OrchestrationBudgetExceeded(
426
+ f"child {child_id!r} is already running in this pool"
427
+ )
428
+ live = [h for h in self._children.values() if h.outcome is None]
429
+ if len(live) >= self._config.max_parallel:
430
+ raise OrchestrationBudgetExceeded(
431
+ f"parallel cap {self._config.max_parallel} reached "
432
+ f"({len(live)} children running); wait for a child to "
433
+ "finish before resuming this one"
434
+ )
435
+ handle = _ChildHandle(child_id, loop)
436
+ handle.task_desc = task
437
+ handle.schemas = schemas
438
+ handle.profile = profile
439
+ handle.engine = engine
440
+ handle.usage_known = engine == "coreloop"
441
+ self._children[child_id] = handle
442
+ resumed_record = self._register_child(
443
+ child_id,
444
+ depth=self._depth + 1,
445
+ profile=profile,
446
+ engine=engine,
447
+ record_id=record_id,
448
+ )
449
+ self._watch_child(handle, loop)
450
+ handle.task = asyncio.ensure_future(self._run_child(handle, list(seed)))
451
+ self._emit(
452
+ "child_resumed",
453
+ {
454
+ "childId": child_id,
455
+ "depth": self._depth + 1,
456
+ **({"recordId": resumed_record} if resumed_record else {}),
457
+ **({"profile": profile} if profile else {}),
458
+ },
459
+ )
460
+ return handle
461
+
462
+ def _write_seed_entry(
463
+ self,
464
+ loop: CoreLoop,
465
+ seed: list[LLMMessage],
466
+ provenance: SeedProvenance,
467
+ ) -> None:
468
+ """Write the fork seed into the child's record with its provenance.
469
+
470
+ Synchronous and best-effort: ``spawn`` must not yield, and a storage
471
+ failure must not turn a spawn into an error. The seed is also passed
472
+ to the run, so a lost entry costs the audit trail, not the child's
473
+ context.
474
+ """
475
+ store = self._history_store
476
+ if store is None:
477
+ return
478
+ try:
479
+ entry = HistorySeed(
480
+ seq=0,
481
+ messages=tuple(seed),
482
+ token_estimate=0,
483
+ source_record_id=provenance.source_record_id,
484
+ source_until_seq=provenance.source_until_seq,
485
+ source_model=provenance.source_model,
486
+ )
487
+ # A scheduling hook, not an await: the write is queued on the
488
+ # pool's own loop and lands before the child's first request,
489
+ # which cannot start in the same tick.
490
+ asyncio.ensure_future(
491
+ store.append_history(loop.record_id, [entry_to_dict(entry)])
492
+ )
493
+ except Exception as exc: # noqa: BLE001 — bookkeeping never fails a spawn
494
+ logger.warning("fork seed provenance write failed: %s", exc)
495
+
496
+ def _register_child(
497
+ self,
498
+ child_id: str,
499
+ *,
500
+ depth: int,
501
+ profile: str | None,
502
+ engine: str,
503
+ record_id: str | None = None,
504
+ ) -> str | None:
505
+ """Note one child in the registry; returns the record id it uses.
506
+
507
+ Synchronous so the row is visible to ``agent_list`` the instant
508
+ ``spawn`` returns, and durable before the child's first model request
509
+ (``_run_child`` awaits the write). ``spawned`` is idempotent, so
510
+ noting here and persisting there is one row, not two.
511
+ """
512
+ if self._registry is None or self._parent_record_id is None:
513
+ return None
514
+ resolved = record_id or default_record_id(self._parent_record_id, child_id)
515
+ self._registry.note_spawn(
516
+ SpawnedChild(
517
+ parent_record_id=self._parent_record_id,
518
+ child_id=child_id,
519
+ depth=depth,
520
+ profile=profile,
521
+ engine=engine,
522
+ ),
523
+ record_id=resolved,
524
+ )
525
+ return resolved
526
+
527
+ def _watch_child(self, handle: _ChildHandle, loop: CoreLoop | None) -> None:
528
+ """Teach the registry how to answer "is this child running now"."""
529
+ if self._registry is None or self._parent_record_id is None:
530
+ return
531
+ record_id = default_record_id(self._parent_record_id, handle.child_id)
532
+ self._registry.mark_live(record_id, lambda: handle.outcome is None)
533
+
223
534
  async def run(
224
535
  self,
225
536
  task: str,
@@ -228,6 +539,12 @@ class AgentPool:
228
539
  loop_factory: LoopFactory | None = None,
229
540
  event_extra: dict[str, Any] | None = None,
230
541
  system_prompt: str | None = None,
542
+ profile: str | None = None,
543
+ engine: str = "coreloop",
544
+ seed: list[LLMMessage] | None = None,
545
+ acp_engine: AcpEngineSpec | None = None,
546
+ acp_permission: AcpPermissionPolicy | None = None,
547
+ acp_tool_bridge: AcpToolBridge | None = None,
231
548
  ) -> ChildOutcome:
232
549
  """Synchronous delegation: spawn a child and await its terminal
233
550
  outcome. The caller's cancellation propagates into the child run
@@ -238,6 +555,12 @@ class AgentPool:
238
555
  loop_factory=loop_factory,
239
556
  event_extra=event_extra,
240
557
  system_prompt=system_prompt,
558
+ profile=profile,
559
+ engine=engine,
560
+ seed=seed,
561
+ acp_engine=acp_engine,
562
+ acp_permission=acp_permission,
563
+ acp_tool_bridge=acp_tool_bridge,
241
564
  )
242
565
  assert handle.task is not None
243
566
  await handle.task
@@ -256,6 +579,14 @@ class AgentPool:
256
579
  request_text: list[str] = []
257
580
  last_text = ""
258
581
  status = "completed"
582
+ # The spawn row is noted synchronously (so ``agent_list`` sees it at
583
+ # once) and persisted here, before the first model request: a crash
584
+ # after this point leaves a resumable ``interrupted`` row.
585
+ await self._flush_child_row(handle)
586
+ if handle.acp_engine is not None:
587
+ await self._run_acp_child(handle, seed)
588
+ return
589
+ assert handle.loop is not None
259
590
  try:
260
591
  async for event in handle.loop.run(
261
592
  seed,
@@ -276,7 +607,22 @@ class AgentPool:
276
607
  # Hard-cancel backstop path (close/shutdown grace expired). No
277
608
  # resume snapshot: the record may hold dangling tool_calls.
278
609
  handle.outcome = ChildOutcome(handle.child_id, "cancelled", "")
279
- self._emit("child_cancelled", {"childId": handle.child_id})
610
+ cancelled_usage = handle.loop.last_run_usage if handle.loop else None
611
+ handle.usage = cancelled_usage
612
+ self._emit(
613
+ "child_cancelled",
614
+ {
615
+ "childId": handle.child_id,
616
+ **(
617
+ {"usage": _usage_payload(cancelled_usage)}
618
+ if cancelled_usage
619
+ else {}
620
+ ),
621
+ },
622
+ )
623
+ await self._settle_child(
624
+ handle, status="cancelled", usage=cancelled_usage
625
+ )
280
626
  raise
281
627
  except Exception as exc: # noqa: BLE001 — a child run can raise anything
282
628
  # (provider, executor, bugs); the pool must survive with the
@@ -284,15 +630,214 @@ class AgentPool:
284
630
  # leaves wait() hanging on an unset outcome.
285
631
  logger.warning("child %s failed: %s", handle.child_id, exc)
286
632
  handle.outcome = ChildOutcome(handle.child_id, "error", str(exc))
287
- self._emit("child_failed", {"childId": handle.child_id, "error": str(exc)})
633
+ # A failed child still burned tokens before it raised; dropping
634
+ # them under-reports the turn exactly when the user is looking for
635
+ # what went wrong.
636
+ failed_usage = handle.loop.last_run_usage
637
+ handle.usage = failed_usage
638
+ self._emit(
639
+ "child_failed",
640
+ {
641
+ "childId": handle.child_id,
642
+ "error": str(exc),
643
+ **({"usage": _usage_payload(failed_usage)} if failed_usage else {}),
644
+ },
645
+ )
646
+ await self._settle_child(handle, status="error", usage=failed_usage)
288
647
  return
289
648
  # Clean finish (completion or cooperative cancel): the record has no
290
649
  # dangling tool_calls, so it is a valid resume seed for follow-ups.
291
650
  handle.resume_messages = list(handle.loop.history.projection)
292
651
  answer = "".join(request_text).strip() or last_text
293
652
  handle.outcome = ChildOutcome(handle.child_id, status, answer)
653
+ usage = handle.loop.last_run_usage
654
+ handle.usage = usage
655
+ # Charge the tree before announcing: a child that pushed the tree over
656
+ # its limit must be reported as having done so, not settled quietly
657
+ # and letting the parent discover it on the next spawn.
658
+ budget_exceeded: str | None = None
659
+ if self._budget is not None:
660
+ try:
661
+ self._budget.charge(usage, agent=handle.child_id)
662
+ except SessionBudgetExceeded as exc:
663
+ budget_exceeded = str(exc)
664
+ logger.warning("child %s crossed the tree budget: %s", handle.child_id, exc)
294
665
  kind = "child_completed" if status == "completed" else "child_failed"
295
- self._emit(kind, {"childId": handle.child_id, "status": status})
666
+ self._emit(
667
+ kind,
668
+ {
669
+ "childId": handle.child_id,
670
+ "status": status,
671
+ # The child's own bill, on the event that announces its end:
672
+ # a host rendering the child list needs it without a second
673
+ # read, and a parent computing turn cost needs it before the
674
+ # stream closes.
675
+ **({"usage": _usage_payload(usage)} if usage is not None else {}),
676
+ **({"budgetExceeded": budget_exceeded} if budget_exceeded else {}),
677
+ },
678
+ )
679
+ await self._settle_child(handle, status=status, usage=usage)
680
+
681
+ async def _run_acp_child(
682
+ self, handle: _ChildHandle, seed: list[LLMMessage]
683
+ ) -> None:
684
+ """Run an external agent process as this child, then settle it.
685
+
686
+ The task is the last user message in the seed (the spawn builds it
687
+ that way); anything before it is context we cannot hand to a foreign
688
+ engine, so only the task travels — the peer never sees our transcript.
689
+
690
+ An engine that cannot start is reported as a failed child, not raised
691
+ into the pool: ``spawn`` has already returned, so the caller's only
692
+ way to learn about it is this child's outcome.
693
+ """
694
+ engine = handle.acp_engine
695
+ assert engine is not None
696
+ task = ""
697
+ for message in reversed(seed):
698
+ if message.role == "user":
699
+ task = message.content_text
700
+ break
701
+
702
+ try:
703
+ outcome: AcpOutcome = await run_acp_child(
704
+ engine,
705
+ task,
706
+ tool_bridge=handle.acp_tool_bridge or AcpToolBridge(),
707
+ permission=handle.acp_permission or AcpPermissionPolicy(),
708
+ on_event=lambda event: self._emit(
709
+ "child_progress", {"childId": handle.child_id, **event}
710
+ ),
711
+ )
712
+ except AcpUnavailable as exc:
713
+ # The command is wrong or the peer never greeted us. Report it on
714
+ # the child so the parent sees a failed delegation with a reason.
715
+ handle.outcome = ChildOutcome(handle.child_id, "error", str(exc))
716
+ handle.usage = None
717
+ handle.usage_known = False
718
+ self._emit(
719
+ "child_failed", {"childId": handle.child_id, "error": str(exc)}
720
+ )
721
+ await self._settle_child(handle, status="error", usage=None)
722
+ return
723
+ except asyncio.CancelledError:
724
+ handle.outcome = ChildOutcome(handle.child_id, "cancelled", "")
725
+ handle.usage = None
726
+ handle.usage_known = False
727
+ self._emit("child_cancelled", {"childId": handle.child_id})
728
+ await self._settle_child(handle, status="cancelled", usage=None)
729
+ raise
730
+
731
+ handle.outcome = ChildOutcome(handle.child_id, outcome.status, outcome.answer)
732
+ # An external engine reports no tokens: unknown, never zero.
733
+ handle.usage = None
734
+ handle.usage_known = False
735
+ kind = "child_completed" if outcome.status == "completed" else "child_failed"
736
+ self._emit(
737
+ kind,
738
+ {
739
+ "childId": handle.child_id,
740
+ "status": outcome.status,
741
+ **({"error": outcome.error} if outcome.error else {}),
742
+ },
743
+ )
744
+ await self._settle_child(handle, status=outcome.status, usage=None)
745
+
746
+ async def _flush_child_row(self, handle: _ChildHandle) -> None:
747
+ """Persist this child's spawn row; no-op for an unregistered pool."""
748
+ if self._registry is None or self._parent_record_id is None:
749
+ return
750
+ await self._registry.flush_spawn(
751
+ default_record_id(self._parent_record_id, handle.child_id)
752
+ )
753
+
754
+ async def _settle_child(
755
+ self, handle: _ChildHandle, *, status: str, usage: Any
756
+ ) -> None:
757
+ """Announce the terminal state, then write the durable row.
758
+
759
+ The notice comes first and does not depend on the registry: telling the
760
+ parent a child finished is useful whether or not this host persists
761
+ anything, and a host with no storage adapter is a supported deployment.
762
+
763
+ The row write is best-effort: a storage failure must not turn a
764
+ finished child into an error, and an unreachable registry must not
765
+ break a run that already produced an answer.
766
+ """
767
+ outcome = handle.outcome
768
+ if outcome is not None:
769
+ self._notices.append((handle.child_id, outcome.status, outcome.answer))
770
+ if self._registry is None or self._parent_record_id is None:
771
+ return
772
+ record_id = default_record_id(self._parent_record_id, handle.child_id)
773
+ try:
774
+ wrote = await self._registry.finished(
775
+ record_id,
776
+ status=registry_status(status),
777
+ usage=usage,
778
+ usage_known=handle.usage_known,
779
+ )
780
+ if wrote:
781
+ self._registry.mark_settled(record_id)
782
+ except Exception as exc: # noqa: BLE001 — bookkeeping never fails a run
783
+ logger.warning("child %s registry settle failed: %s", handle.child_id, exc)
784
+
785
+ def budget_report(self) -> TreeBudgetReport | None:
786
+ """The tree budget's snapshot, or ``None`` when this pool has none.
787
+
788
+ Exposed so a host can report the turn's real cost (parent plus every
789
+ child) without walking the registry, and can tell "no limit" from
790
+ "zero spent".
791
+ """
792
+ return self._budget.report() if self._budget is not None else None
793
+
794
+ def children_usage(self) -> tuple[list[dict[str, Any]], int]:
795
+ """Per-child bills for this turn's usage record, and the unknown count.
796
+
797
+ Only settled children appear: a child still running has not reported
798
+ a bill yet, and inventing one would overstate the turn. ``unknown``
799
+ counts settled children whose engine reports no tokens at all (an
800
+ external ACP agent), so a host can mark the turn's total as partial
801
+ instead of presenting a smaller number as complete.
802
+ """
803
+ entries: list[dict[str, Any]] = []
804
+ unknown = 0
805
+ for handle in self._children.values():
806
+ if handle.outcome is None:
807
+ continue
808
+ if not handle.usage_known:
809
+ unknown += 1
810
+ continue
811
+ usage = handle.usage
812
+ entries.append(
813
+ {
814
+ "childId": handle.child_id,
815
+ **(
816
+ {
817
+ "promptTokens": usage.prompt_tokens,
818
+ "completionTokens": usage.completion_tokens,
819
+ "totalTokens": usage.total_tokens,
820
+ }
821
+ if usage is not None
822
+ else {"promptTokens": 0, "completionTokens": 0, "totalTokens": 0}
823
+ ),
824
+ **({"profile": handle.profile} if handle.profile else {}),
825
+ }
826
+ )
827
+ return entries, unknown
828
+
829
+ def drain_notices(self) -> list[tuple[str, str, str]]:
830
+ """Take every notice awaiting delivery, oldest first.
831
+
832
+ Draining is destructive on purpose: the parent must be told once, not
833
+ on every subsequent step. The hook that consumes these runs at a round
834
+ boundary, so a child finishing mid-round is delivered at the next one.
835
+ """
836
+ if not self._notices:
837
+ return []
838
+ drained = self._notices
839
+ self._notices = []
840
+ return drained
296
841
 
297
842
  def get(self, child_id: str) -> _ChildHandle | None:
298
843
  return self._children.get(child_id)
@@ -314,6 +859,83 @@ class AgentPool:
314
859
  return ChildOutcome(child_id, "running", "")
315
860
  return handle.outcome or ChildOutcome(child_id, "cancelled", "")
316
861
 
862
+ async def wait_any(
863
+ self,
864
+ child_ids: Sequence[str] | None,
865
+ timeout_s: float | None,
866
+ ) -> tuple[ChildOutcome | None, list[str], list[str]]:
867
+ """Wait for the **first** child to reach a terminal state.
868
+
869
+ Returns ``(outcome, pending, unknown)``: the settled child (or ``None``
870
+ on timeout), the ids still running, and the ids this pool has never
871
+ heard of. Reporting all three lets the caller answer honestly instead
872
+ of collapsing "not finished" and "no such child" into one "running".
873
+
874
+ ``child_ids=None`` waits on every child of this pool, which is the
875
+ fan-out case: spawn several, then wait for whichever reports first.
876
+ Already-finished children count as immediately available — a caller
877
+ that waits after the work is done must not block on nothing.
878
+
879
+ Candidates exclude children already reported by an earlier ``wait_any``,
880
+ so a fan-out collects one distinct answer per call. Passing an explicit
881
+ ``child_ids`` overrides that: asking for a specific child means asking
882
+ for it again, not "whatever is left".
883
+
884
+ The wait is shielded, so a timeout never cancels a child.
885
+ """
886
+ if child_ids is not None:
887
+ candidates = list(child_ids)
888
+ else:
889
+ candidates = [
890
+ child_id
891
+ for child_id in self._children
892
+ if child_id not in self._reported
893
+ ]
894
+ known: list[_ChildHandle] = []
895
+ unknown: list[str] = []
896
+ for child_id in candidates:
897
+ handle = self._children.get(child_id)
898
+ if handle is None:
899
+ unknown.append(child_id)
900
+ continue
901
+ # Already settled: no need to wait for anyone.
902
+ if handle.outcome is not None:
903
+ pending = [
904
+ cid for cid, h in self._children.items()
905
+ if h.outcome is None and cid in candidates and cid != child_id
906
+ ]
907
+ self._reported.add(child_id)
908
+ return handle.outcome, pending, unknown
909
+ known.append(handle)
910
+ if not known:
911
+ return None, [], unknown
912
+ tasks = {asyncio.ensure_future(asyncio.shield(h.task)): h for h in known}
913
+ done, pending_tasks = await asyncio.wait(
914
+ tasks, timeout=timeout_s or self._config.wait_timeout_s,
915
+ return_when=asyncio.FIRST_COMPLETED,
916
+ )
917
+ for task in pending_tasks:
918
+ task.cancel()
919
+ if not done:
920
+ return None, [h.child_id for h in known], unknown
921
+ # Deterministic pick when several finish in the same tick: the pool's
922
+ # own order, not the set's iteration order.
923
+ finished = sorted(
924
+ (tasks[task] for task in done if task in tasks),
925
+ key=lambda h: h.child_id,
926
+ )
927
+ settled = finished[0]
928
+ self._reported.add(settled.child_id)
929
+ still_running = [
930
+ h.child_id for h in known
931
+ if h is not settled and h.outcome is None
932
+ ]
933
+ return (
934
+ settled.outcome or ChildOutcome(settled.child_id, "cancelled", ""),
935
+ still_running,
936
+ unknown,
937
+ )
938
+
317
939
  def send(self, child_id: str, message: str) -> str:
318
940
  """Deliver a message: steer a running child, resume a finished one.
319
941
 
@@ -327,6 +949,14 @@ class AgentPool:
327
949
  if handle.closed:
328
950
  return f"error:child_closed: {child_id!r} is closed; spawn a new child"
329
951
  if handle.outcome is None:
952
+ if handle.loop is None:
953
+ # An external child is a single prompt; there is no round
954
+ # boundary to steer at. Saying so lets the model close it and
955
+ # spawn a fresh one with the new instruction.
956
+ return (
957
+ f"error:child_not_steerable: {child_id!r} runs on an "
958
+ "external engine and cannot take a mid-run message"
959
+ )
330
960
  handle.loop.steer(message)
331
961
  return "steered"
332
962
  # Follow-up turn on a finished/interrupted child. The resume seed is
@@ -345,7 +975,11 @@ class AgentPool:
345
975
  )
346
976
  seed = [*handle.resume_messages, LLMMessage.text_of("user", message)]
347
977
  handle.outcome = None
348
- handle.loop.reset_cancel()
978
+ # It is running again, so its next terminal state is news: without
979
+ # this, a resumed child would never come back from wait_any.
980
+ self._reported.discard(child_id)
981
+ if handle.loop is not None:
982
+ handle.loop.reset_cancel()
349
983
  handle.task = asyncio.ensure_future(self._run_child(handle, seed))
350
984
  self._emit("child_resumed", {"childId": child_id})
351
985
  return "resumed"
@@ -358,7 +992,7 @@ class AgentPool:
358
992
  handle = self._children.get(child_id)
359
993
  if handle is None or handle.closed or handle.outcome is not None:
360
994
  return False
361
- handle.loop.cancel()
995
+ self._cancel_handle(handle)
362
996
  self._emit("child_interrupted", {"childId": child_id})
363
997
  return True
364
998
 
@@ -407,7 +1041,7 @@ class AgentPool:
407
1041
  handle.closed = True
408
1042
  if handle.outcome is not None:
409
1043
  return True
410
- handle.loop.cancel()
1044
+ self._cancel_handle(handle)
411
1045
  assert handle.task is not None
412
1046
  try:
413
1047
  await asyncio.wait_for(asyncio.shield(handle.task), timeout=_CLOSE_GRACE_S)
@@ -416,6 +1050,18 @@ class AgentPool:
416
1050
  handle.task.cancel()
417
1051
  return True
418
1052
 
1053
+ def _cancel_handle(self, handle: _ChildHandle) -> None:
1054
+ """Ask one child to stop, whichever engine it runs on.
1055
+
1056
+ An external child has no loop to cancel: its process is owned by the
1057
+ run, so cancelling the task is what winds it down (``run_acp_child``
1058
+ cancels the peer and then kills it).
1059
+ """
1060
+ if handle.loop is not None:
1061
+ handle.loop.cancel()
1062
+ elif handle.task is not None and not handle.task.done():
1063
+ handle.task.cancel()
1064
+
419
1065
  async def shutdown(self) -> None:
420
1066
  """Cooperatively cancel every live child; hard-cancel stragglers.
421
1067
 
@@ -426,7 +1072,7 @@ class AgentPool:
426
1072
  for handle in self._children.values():
427
1073
  handle.closed = True
428
1074
  for handle in live:
429
- handle.loop.cancel()
1075
+ self._cancel_handle(handle)
430
1076
  for handle in live:
431
1077
  assert handle.task is not None
432
1078
  try: