@steerable/runtime-darwin-arm64 0.9.1 → 0.10.0

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 (44) hide show
  1. package/bin/steerable-egress-proxy +0 -0
  2. package/package.json +1 -1
  3. package/python-runtime/manifest.json +1 -1
  4. package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_harness-0.9.1.dist-info → steerable_agent_harness-0.10.0.dist-info}/METADATA +1 -1
  5. package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_harness-0.9.1.dist-info → steerable_agent_harness-0.10.0.dist-info}/RECORD +7 -7
  6. package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_protocol-0.9.1.dist-info → steerable_agent_protocol-0.10.0.dist-info}/METADATA +1 -1
  7. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_protocol-0.10.0.dist-info/RECORD +11 -0
  8. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/__init__.py +48 -0
  9. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/budget.py +124 -0
  10. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/child_notice.py +84 -0
  11. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/child_record.py +174 -0
  12. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/engines.py +499 -0
  13. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/history.py +114 -1
  14. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/orchestration.py +496 -33
  15. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/pool.py +662 -16
  16. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/registry.py +345 -0
  17. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/storage/__init__.py +52 -0
  18. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/storage/in_memory.py +74 -0
  19. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/storage/sqlalchemy_store.py +153 -0
  20. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/storage/sqlite_store.py +117 -1
  21. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/subagent.py +157 -6
  22. package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_runtime-0.9.1.dist-info → steerable_agent_runtime-0.10.0.dist-info}/METADATA +39 -4
  23. package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_runtime-0.9.1.dist-info → steerable_agent_runtime-0.10.0.dist-info}/RECORD +26 -16
  24. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime_native/steerable_agent_runtime_native.abi3.so +0 -0
  25. package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_runtime_native-1.0.7.dist-info → steerable_agent_runtime_native-1.0.8.dist-info}/METADATA +1 -1
  26. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime_native-1.0.8.dist-info/RECORD +9 -0
  27. package/python-runtime/python/lib/python3.12/site-packages/steerable_sidecar/sidecar.py +545 -6
  28. package/python-runtime/python/lib/python3.12/site-packages/steerable_sidecar-0.10.0.dist-info/METADATA +97 -0
  29. package/python-runtime/python/lib/python3.12/site-packages/{steerable_sidecar-0.9.1.dist-info → steerable_sidecar-0.10.0.dist-info}/RECORD +9 -9
  30. package/runtime.json +66 -46
  31. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_protocol-0.9.1.dist-info/RECORD +0 -11
  32. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime_native-1.0.7.dist-info/RECORD +0 -9
  33. package/python-runtime/python/lib/python3.12/site-packages/steerable_sidecar-0.9.1.dist-info/METADATA +0 -58
  34. /package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_harness-0.9.1.dist-info → steerable_agent_harness-0.10.0.dist-info}/INSTALLER +0 -0
  35. /package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_harness-0.9.1.dist-info → steerable_agent_harness-0.10.0.dist-info}/WHEEL +0 -0
  36. /package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_protocol-0.9.1.dist-info → steerable_agent_protocol-0.10.0.dist-info}/INSTALLER +0 -0
  37. /package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_protocol-0.9.1.dist-info → steerable_agent_protocol-0.10.0.dist-info}/WHEEL +0 -0
  38. /package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_runtime-0.9.1.dist-info → steerable_agent_runtime-0.10.0.dist-info}/INSTALLER +0 -0
  39. /package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_runtime-0.9.1.dist-info → steerable_agent_runtime-0.10.0.dist-info}/WHEEL +0 -0
  40. /package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_runtime_native-1.0.7.dist-info → steerable_agent_runtime_native-1.0.8.dist-info}/INSTALLER +0 -0
  41. /package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_runtime_native-1.0.7.dist-info → steerable_agent_runtime_native-1.0.8.dist-info}/WHEEL +0 -0
  42. /package/python-runtime/python/lib/python3.12/site-packages/{steerable_sidecar-0.9.1.dist-info → steerable_sidecar-0.10.0.dist-info}/INSTALLER +0 -0
  43. /package/python-runtime/python/lib/python3.12/site-packages/{steerable_sidecar-0.9.1.dist-info → steerable_sidecar-0.10.0.dist-info}/WHEEL +0 -0
  44. /package/python-runtime/python/lib/python3.12/site-packages/{steerable_sidecar-0.9.1.dist-info → steerable_sidecar-0.10.0.dist-info}/entry_points.txt +0 -0
@@ -33,26 +33,36 @@ Design:
33
33
  from __future__ import annotations
34
34
 
35
35
  import json
36
+ import logging
36
37
  from collections.abc import Callable
38
+ from dataclasses import dataclass
37
39
  from typing import Any
38
40
 
39
41
  from steerable_agent_protocol.generated import ToolCall, ToolResult
40
42
 
43
+ from .budget import TreeBudget
41
44
  from .hooks import ChainHooks, CompletionAction, CompletionDraft, NoopHooks
42
- from .llm import LLMProvider
45
+ from .history import completed_turn_prefix
46
+ from .llm import LLMMessage, LLMProvider
43
47
  from .loop import CoreLoop, LoopConfig, LoopContext, LoopHooks, ToolExecutor
44
48
  from .pool import (
45
49
  AgentPool,
46
50
  ChildOutcome,
47
51
  OrchestrationBudgetExceeded,
48
52
  OrchestrationConfig,
53
+ SeedProvenance,
49
54
  )
55
+ from .registry import ChildRegistry, default_record_id
56
+ from .resume import load_history_transcript
50
57
  from .subagent import FilteredToolsExecutor, _schema_name
51
58
 
59
+ logger = logging.getLogger(__name__)
60
+
52
61
  __all__ = [
53
62
  "AgentPool",
54
63
  "ChildOutcome",
55
64
  "ChildBudgetWrapUp",
65
+ "ForkRoute",
56
66
  "OrchestrationBudgetExceeded",
57
67
  "OrchestrationConfig",
58
68
  "OrchestrationExecutor",
@@ -77,6 +87,41 @@ _RESUME_HINT = (
77
87
  )
78
88
 
79
89
 
90
+ @dataclass(frozen=True, slots=True)
91
+ class ForkRoute:
92
+ """How a fork child's model route relates to its parent's.
93
+
94
+ A fork is allowed to run on a different model or effort — the parent's
95
+ findings are the input, and the cheapest way to check them is often a
96
+ smaller model. The cost of doing that is real and has to be visible, not
97
+ silent: the parent's cached prompt prefix belongs to the parent's model,
98
+ so the child's first request is a full re-prefill, and the same
99
+ transcript is then interpreted by two different models.
100
+ """
101
+
102
+ parent_model: str | None
103
+ child_model: str | None
104
+ parent_effort: str | None
105
+ child_effort: str | None
106
+
107
+ @property
108
+ def diverged(self) -> bool:
109
+ """True when the fork re-reads the parent history on a different route."""
110
+ return (self.parent_model, self.parent_effort) != (
111
+ self.child_model,
112
+ self.child_effort,
113
+ )
114
+
115
+ def to_wire(self) -> dict[str, str | None]:
116
+ """The `forkRoute` object a spawn result carries."""
117
+ return {
118
+ "parentModel": self.parent_model,
119
+ "childModel": self.child_model,
120
+ "parentEffort": self.parent_effort,
121
+ "childEffort": self.child_effort,
122
+ }
123
+
124
+
80
125
  class ChildBudgetWrapUp(NoopHooks):
81
126
  """``before_completion``: one no-tools answer round at a child's budget wall.
82
127
 
@@ -134,13 +179,18 @@ def orchestration_tool_descriptors(
134
179
  config.spawn_tool,
135
180
  "Spawn a sub-agent with its own reasoning loop to work on a "
136
181
  "self-contained task concurrently. Returns immediately with a "
137
- "childId; use agent_wait to collect its answer.",
182
+ "childId. For independent subtasks, emit several agent_spawn "
183
+ "calls in ONE message: they run in parallel up to the pool's "
184
+ "concurrency limit, and a call beyond that limit fails while the "
185
+ "earlier ones keep running. Collect answers with agent_wait, "
186
+ "which returns whichever child settles first.",
138
187
  {
139
188
  "task": {
140
189
  "type": "string",
141
190
  "description": (
142
191
  "Complete, self-contained instructions for the "
143
- "sub-agent — it sees none of this conversation."
192
+ "sub-agent — it sees none of this conversation unless "
193
+ "context is \"fork\"."
144
194
  ),
145
195
  },
146
196
  "toolFilter": {
@@ -151,6 +201,36 @@ def orchestration_tool_descriptors(
151
201
  "call; omitted delegates the full tool domain."
152
202
  ),
153
203
  },
204
+ "context": {
205
+ "type": "string",
206
+ "enum": ["fresh", "fork"],
207
+ "description": (
208
+ "\"fresh\" (default) starts the child with only the "
209
+ "task. \"fork\" seeds it with this conversation's "
210
+ "completed turns — the questions that were answered "
211
+ "and the tool work behind them — so a child can "
212
+ "continue an investigation without being told again. "
213
+ "An in-flight turn is excluded: its tool calls have "
214
+ "no results yet."
215
+ ),
216
+ },
217
+ "model": {
218
+ "type": "string",
219
+ "description": (
220
+ "Model for the child. Requires context=\"fork\": a "
221
+ "fresh child has no parent route to diverge from. "
222
+ "The parent's cached prompt prefix does not apply to "
223
+ "another model, so the result reports "
224
+ "cacheNote=\"cold\"."
225
+ ),
226
+ },
227
+ "reasoningEffort": {
228
+ "type": "string",
229
+ "description": (
230
+ "Reasoning effort for the child; context=\"fork\" "
231
+ "required, same as model."
232
+ ),
233
+ },
154
234
  },
155
235
  ["task"],
156
236
  ),
@@ -168,16 +248,21 @@ def orchestration_tool_descriptors(
168
248
  ),
169
249
  fn(
170
250
  config.wait_tool,
171
- "Wait for a sub-agent to finish and return its outcome. A child "
172
- "still running at the timeout returns status 'running'. A child "
173
- "that hit its round budget returns status 'budget_exhausted' with "
174
- "an interim answer and resumable: true — continue it with "
175
- "agent_send instead of spawning a new child.",
251
+ "Wait for the first sub-agent to finish and return its outcome. "
252
+ "Omit childIds to wait for whichever unfinished child of this "
253
+ "pool settles first — after spawning several, one wait collects "
254
+ "one answer. Pass childIds to restrict the wait to those. A child "
255
+ "still running at the timeout returns status 'running' with the "
256
+ "ids still pending. A child that hit its round budget returns "
257
+ "status 'budget_exhausted' with an interim answer and "
258
+ "resumable: true — continue it with agent_send instead of "
259
+ "spawning a new child.",
176
260
  {
177
261
  "childId": {"type": "string"},
262
+ "childIds": {"type": "array", "items": {"type": "string"}},
178
263
  "timeoutMs": {"type": "integer"},
179
264
  },
180
- ["childId"],
265
+ [],
181
266
  ),
182
267
  fn(
183
268
  config.close_tool,
@@ -226,6 +311,13 @@ class OrchestrationExecutor:
226
311
  tools: list[dict[str, Any]] | None = None,
227
312
  hooks: LoopHooks | None = None,
228
313
  event_sink: Callable[[str, dict[str, Any]], None] | None = None,
314
+ registry: ChildRegistry | None = None,
315
+ history_store: Any | None = None,
316
+ parent_record_id: str | None = None,
317
+ parent_projection: Callable[[], list[LLMMessage]] | None = None,
318
+ budget: TreeBudget | None = None,
319
+ parent_route: Callable[[], tuple[str | None, str | None]] | None = None,
320
+ provider_factory: Callable[[str | None, str | None], LLMProvider] | None = None,
229
321
  _depth: int = 0,
230
322
  _lineage: str = "0",
231
323
  ) -> None:
@@ -235,6 +327,32 @@ class OrchestrationExecutor:
235
327
  self._parent_tools = list(tools or [])
236
328
  self._hooks = hooks
237
329
  self._event_sink = event_sink
330
+ self._registry = registry
331
+ #: The parent's durable record channel. Children get their own record
332
+ #: under ``<parent>:child:<lineage>`` so their reasoning is readable
333
+ #: afterwards (``delegate_subagent`` already does this; without the
334
+ #: same wiring here, the two multi-agent surfaces report differently).
335
+ self._history_store = history_store
336
+ self._parent_record_id = parent_record_id
337
+ #: The parent's model-visible transcript, read lazily at spawn time.
338
+ #: A callable rather than a list because the parent keeps appending
339
+ #: between the executor's construction and a ``fork`` spawn — a
340
+ #: snapshot taken at construction would seed the child with a stale
341
+ #: conversation. ``None`` means this executor cannot fork (the caller
342
+ #: did not wire it), and ``context: "fork"`` then fails closed rather
343
+ #: than silently degrading to ``fresh``.
344
+ self._parent_projection = parent_projection
345
+ #: The parent's model route, read lazily for the same reason as the
346
+ #: projection: the loop that answers it is built after this executor.
347
+ #: Only the fork path reads it, and only to say whether the child is
348
+ #: re-reading the prefix on a different route.
349
+ self._parent_route = parent_route
350
+ #: Rebuilds a provider at another model/effort. Only a cross-model
351
+ #: fork needs it; without one, `agent_spawn` cannot change the route
352
+ #: and says so instead of silently ignoring the request.
353
+ self._provider_factory = provider_factory
354
+ #: The tree's shared token limit; ``None`` keeps per-run accounting.
355
+ self._budget = budget
238
356
  self._depth = _depth
239
357
  self._lineage = _lineage
240
358
  self._pool = AgentPool(
@@ -243,6 +361,11 @@ class OrchestrationExecutor:
243
361
  lineage=_lineage,
244
362
  loop_factory=self._child_loop,
245
363
  event_sink=event_sink,
364
+ registry=registry,
365
+ parent_record_id=parent_record_id,
366
+ budget=budget,
367
+ # Needed to write a fork seed's provenance into the child's record.
368
+ history_store=history_store,
246
369
  )
247
370
 
248
371
  @property
@@ -256,9 +379,36 @@ class OrchestrationExecutor:
256
379
  # child construction
257
380
  # ------------------------------------------------------------------
258
381
 
382
+ def _child_record_id(self, child_id: str) -> str | None:
383
+ """The child's own durable record id, or ``None`` without a parent
384
+ record: a host that never persisted the parent has nothing to name
385
+ the child's log after."""
386
+ if self._parent_record_id is None:
387
+ return None
388
+ return default_record_id(self._parent_record_id, child_id)
389
+
259
390
  def _child_loop(
260
- self, child_id: str, tool_filter: frozenset[str] | None
391
+ self,
392
+ child_id: str,
393
+ tool_filter: frozenset[str] | None,
394
+ *,
395
+ depth: int | None = None,
396
+ provider: LLMProvider | None = None,
261
397
  ) -> tuple[CoreLoop, list[dict[str, Any]] | None]:
398
+ """Build a child loop and its advertised tools.
399
+
400
+ ``depth`` defaults to this executor's depth + 1 (the fresh-spawn
401
+ case). Resume passes the child's **recorded** depth instead: a child
402
+ created when ``max_depth`` was 1 must not gain orchestration tools
403
+ because the setting was later raised — the nesting decision is a
404
+ permission, so it follows creation, not the current configuration.
405
+
406
+ ``provider`` overrides the parent's for this child (a cross-model
407
+ fork). The default keeps the parent's, which is the only route a
408
+ same-model fork may use.
409
+ """
410
+ child_depth = self._depth + 1 if depth is None else depth
411
+ child_provider = provider or self._provider
262
412
  base: ToolExecutor = self._inner
263
413
  if tool_filter is not None:
264
414
  base = FilteredToolsExecutor(base, tool_filter)
@@ -269,28 +419,45 @@ class OrchestrationExecutor:
269
419
  if _schema_name(schema) not in orch_names
270
420
  and (tool_filter is None or _schema_name(schema) in tool_filter)
271
421
  ]
272
- if self._depth + 1 < self._config.max_depth:
422
+ # A nested executor is built before the loop that will drive it (the
423
+ # loop needs the executor), yet its fork seed must come from that very
424
+ # loop's transcript. This holder breaks the cycle: the executor reads
425
+ # through the closure at spawn time, by which point the loop exists.
426
+ own_loop: list[CoreLoop] = []
427
+ if child_depth < self._config.max_depth:
273
428
  base = OrchestrationExecutor(
274
429
  base,
275
- self._provider,
430
+ child_provider,
276
431
  self._config,
277
432
  tools=schemas,
278
433
  hooks=self._hooks,
279
434
  event_sink=self._event_sink,
280
- _depth=self._depth + 1,
435
+ registry=self._registry,
436
+ history_store=self._history_store,
437
+ # The grandchild's record hangs off the child's, so a trace
438
+ # walks down the lineage the same way the ids do.
439
+ parent_record_id=self._child_record_id(child_id),
440
+ parent_projection=lambda: (
441
+ own_loop[0].history.projection if own_loop else []
442
+ ),
443
+ budget=self._budget,
444
+ _depth=child_depth,
281
445
  _lineage=child_id,
282
446
  )
283
447
  schemas = [*schemas, *orchestration_tool_descriptors(self._config)]
284
448
  wrap_up = ChildBudgetWrapUp()
285
449
  loop = CoreLoop(
286
- self._provider,
450
+ child_provider,
287
451
  base,
288
452
  LoopConfig(
289
453
  max_rounds=self._config.child_max_rounds,
290
454
  max_tool_errors=self._config.child_max_tool_errors,
291
455
  ),
292
456
  hooks=wrap_up if self._hooks is None else ChainHooks(self._hooks, wrap_up),
457
+ history_store=self._history_store,
458
+ record_id=self._child_record_id(child_id),
293
459
  )
460
+ own_loop.append(loop)
294
461
  return loop, schemas or None
295
462
 
296
463
  # ------------------------------------------------------------------
@@ -307,7 +474,7 @@ class OrchestrationExecutor:
307
474
  if call.name == self._config.close_tool:
308
475
  return await self._close(call)
309
476
  if call.name == self._config.list_tool:
310
- return self._list(call)
477
+ return await self._list(call)
311
478
  if call.name == self._config.interrupt_tool:
312
479
  return await self._interrupt(call)
313
480
  return await self._inner.execute(call, ctx)
@@ -322,23 +489,148 @@ class OrchestrationExecutor:
322
489
  if isinstance(raw_filter, list)
323
490
  else None
324
491
  )
492
+ profile = str(call.arguments.get("subagent_type") or "").strip() or None
493
+ raw_context = str(call.arguments.get("context") or "fresh").strip().lower()
494
+ if raw_context not in ("fresh", "fork"):
495
+ return ToolResult(
496
+ success=False,
497
+ error=f'unknown context {raw_context!r}; expected "fresh" or "fork"',
498
+ needsFollowup=True,
499
+ )
500
+ # A fork may run on another model/effort; a fresh child has no
501
+ # inherited route to diverge from, so a model there is a request the
502
+ # six-tool family cannot honour (it has no profile registry).
503
+ child_model = str(call.arguments.get("model") or "").strip() or None
504
+ child_effort = (
505
+ str(call.arguments.get("reasoningEffort") or "").strip() or None
506
+ )
507
+ child_provider: LLMProvider | None = None
508
+ route: ForkRoute | None = None
509
+ if child_model or child_effort:
510
+ if raw_context != "fork":
511
+ return ToolResult(
512
+ success=False,
513
+ error=(
514
+ "changing model or reasoningEffort needs "
515
+ 'context="fork": a fresh child has no parent route to '
516
+ "diverge from. Name a subagent_type instead, or fork."
517
+ ),
518
+ needsFollowup=True,
519
+ )
520
+ if self._provider_factory is None:
521
+ return ToolResult(
522
+ success=False,
523
+ error=(
524
+ "this host cannot build a provider for another model; "
525
+ "spawn with the parent's route"
526
+ ),
527
+ needsFollowup=True,
528
+ )
529
+ parent_model, parent_effort = (
530
+ self._parent_route() if self._parent_route is not None else (None, None)
531
+ )
532
+ route = ForkRoute(
533
+ parent_model=parent_model,
534
+ child_model=child_model or parent_model,
535
+ parent_effort=parent_effort,
536
+ child_effort=child_effort or parent_effort,
537
+ )
538
+ child_provider = self._provider_factory(
539
+ child_model, child_effort
540
+ )
541
+ seed: list[LLMMessage] | None = None
542
+ seed_provenance: SeedProvenance | None = None
543
+ if raw_context == "fork":
544
+ if self._parent_projection is None:
545
+ return ToolResult(
546
+ success=False,
547
+ error=(
548
+ 'context="fork" needs the parent transcript, which this '
549
+ "host did not wire; use \"fresh\""
550
+ ),
551
+ needsFollowup=True,
552
+ )
553
+ fork_seed = completed_turn_prefix(
554
+ self._parent_projection(), max_turns=self._config.fork_turns
555
+ )
556
+ if not fork_seed:
557
+ # Nothing answered yet: a fork has nothing to inherit. Falling
558
+ # back to a fresh child is the honest outcome — the alternative
559
+ # is a child that believes it has context it was never given.
560
+ seed = None
561
+ else:
562
+ # The parent's system prompt describes the parent's job. A
563
+ # child carrying it alongside its own would be two competing
564
+ # instructions, so the seed keeps only the conversation; the
565
+ # child's own system message (below, if any) takes that slot.
566
+ seed = [m for m in fork_seed if m.role != "system"]
567
+ if self._parent_record_id is not None and seed:
568
+ # Record where this came from: the child's own record must
569
+ # answer "why does it already know that?" without reading
570
+ # the parent's transcript. The producing model is recorded
571
+ # even for a same-model fork — the question "which model
572
+ # wrote this history" has an answer either way.
573
+ parent_model = (
574
+ route.parent_model
575
+ if route is not None
576
+ else (
577
+ self._parent_route()[0]
578
+ if self._parent_route is not None
579
+ else None
580
+ )
581
+ )
582
+ seed_provenance = SeedProvenance(
583
+ source_record_id=self._parent_record_id,
584
+ source_model=parent_model,
585
+ )
325
586
  try:
326
- handle = self._pool.spawn(task, tool_filter)
587
+ # ``profile`` is recorded, not resolved: the six-tool family has no
588
+ # profile registry (``delegate_subagent`` owns that). It reaches the
589
+ # index so a resumed child is rebuilt with the same label; the tool
590
+ # domain here comes from ``toolFilter`` alone.
591
+ handle = self._pool.spawn(
592
+ task,
593
+ tool_filter,
594
+ profile=profile,
595
+ seed=seed,
596
+ seed_provenance=seed_provenance,
597
+ **(
598
+ {
599
+ "loop_factory": (
600
+ lambda child_id, filter_: self._child_loop(
601
+ child_id, filter_, provider=child_provider
602
+ )
603
+ )
604
+ }
605
+ if child_provider is not None
606
+ else {}
607
+ ),
608
+ )
327
609
  except OrchestrationBudgetExceeded as exc:
328
610
  return ToolResult(
329
611
  success=False,
330
612
  error=f"orchestration_budget_exceeded: {exc}",
331
613
  needsFollowup=True,
332
614
  )
615
+ payload: dict[str, Any] = {
616
+ "childId": handle.child_id,
617
+ "depth": self._depth + 1,
618
+ "status": "running",
619
+ }
620
+ record_id = handle.loop.record_id
621
+ if record_id:
622
+ payload["recordId"] = record_id
623
+ if route is not None and route.diverged:
624
+ # Not an error: the fork succeeded. But its first request re-reads
625
+ # the whole parent prefix on a route whose prompt cache does not
626
+ # exist, and the same transcript is now interpreted by two models.
627
+ # Saying so is what keeps that cost explainable afterwards.
628
+ payload["cacheNote"] = "cold"
629
+ payload["forkRoute"] = route.to_wire()
630
+ payload["usageNote"] = "crossModelFork"
333
631
  return ToolResult(
334
632
  success=True,
335
- message=json.dumps(
336
- {
337
- "childId": handle.child_id,
338
- "depth": self._depth + 1,
339
- "status": "running",
340
- }
341
- ),
633
+ message=json.dumps(payload),
342
634
  data={"childId": handle.child_id},
343
635
  )
344
636
 
@@ -349,6 +641,12 @@ class OrchestrationExecutor:
349
641
  return ToolResult(success=False, error="empty message")
350
642
  outcome = self._pool.send(child_id, message)
351
643
  if outcome.startswith("error:"):
644
+ # The pool only knows the children of this turn. A child from an
645
+ # earlier turn (or before a restart) has no handle here — try the
646
+ # durable index before telling the model the child is unknown.
647
+ resumed = await self._resume_from_registry(child_id, message)
648
+ if resumed is not None:
649
+ return resumed
352
650
  return ToolResult(
353
651
  success=False,
354
652
  error=outcome[len("error:"):],
@@ -360,8 +658,121 @@ class OrchestrationExecutor:
360
658
  data={"childId": child_id, "delivery": outcome},
361
659
  )
362
660
 
363
- def _list(self, call: ToolCall) -> ToolResult:
661
+ async def _resume_from_registry(
662
+ self, child_id: str, message: str
663
+ ) -> ToolResult | None:
664
+ """Re-run a child that this pool never had, from its durable row.
665
+
666
+ Returns ``None`` when there is nothing to resume (no registry, no
667
+ row, a closed child, or no record to seed from) — the caller then
668
+ reports the pool's own error, so the model sees one consistent
669
+ message either way.
670
+
671
+ The rebuilt child keeps its **recorded** profile and depth: resume
672
+ must not gain permissions the child never had, and a depth-1 child
673
+ that was not allowed to spawn must not be able to after a resume.
674
+ """
675
+ if self._registry is None or self._parent_record_id is None:
676
+ return None
677
+ view = await self._registry.find_child(self._parent_record_id, child_id)
678
+ if view is None or view.record.status == "closed":
679
+ return None
680
+ if view.live:
681
+ # It is running in a pool we cannot see (another executor sharing
682
+ # the registry). Steering it is not ours to do; say so.
683
+ return ToolResult(
684
+ success=False,
685
+ error=(
686
+ f"child_busy: {child_id!r} is running under another pool; "
687
+ "wait for it to finish before sending"
688
+ ),
689
+ needsFollowup=True,
690
+ )
691
+ record_id = view.record.record_id
692
+ seed_messages = await self._resume_seed(record_id, message)
693
+ if seed_messages is None:
694
+ return ToolResult(
695
+ success=False,
696
+ error=(
697
+ f"child_not_resumable: {child_id!r} has no readable record; "
698
+ "spawn a new child"
699
+ ),
700
+ needsFollowup=True,
701
+ )
702
+ loop, schemas = self._child_loop(child_id, None, depth=view.record.depth)
703
+ try:
704
+ self._pool.adopt(
705
+ child_id=child_id,
706
+ loop=loop,
707
+ schemas=schemas,
708
+ seed=seed_messages,
709
+ profile=view.record.profile,
710
+ engine=view.record.engine,
711
+ record_id=record_id,
712
+ )
713
+ except OrchestrationBudgetExceeded as exc:
714
+ return ToolResult(
715
+ success=False,
716
+ error=f"orchestration_budget_exceeded: {exc}",
717
+ needsFollowup=True,
718
+ )
719
+ return ToolResult(
720
+ success=True,
721
+ message=json.dumps(
722
+ {
723
+ "childId": child_id,
724
+ "delivery": "resumed",
725
+ "recordId": record_id,
726
+ "profile": view.record.profile,
727
+ "depth": view.record.depth,
728
+ }
729
+ ),
730
+ data={"childId": child_id, "delivery": "resumed"},
731
+ )
732
+
733
+ async def _resume_seed(
734
+ self, record_id: str, message: str
735
+ ) -> list[LLMMessage] | None:
736
+ """The child's recorded transcript plus the new user message."""
737
+ if self._history_store is None:
738
+ return None
739
+ try:
740
+ transcript = await load_history_transcript(self._history_store, record_id)
741
+ except Exception as exc: # noqa: BLE001 — a bad record is not a crash
742
+ logger.warning("resume seed for %s unavailable: %s", record_id, exc)
743
+ return None
744
+ if transcript is None:
745
+ return None
746
+ return [*transcript, LLMMessage.text_of("user", message)]
747
+
748
+ async def _list(self, call: ToolCall) -> ToolResult:
749
+ """Every child of this session, not just the ones running now.
750
+
751
+ The pool is per turn, so on its own it answers "what is running"; the
752
+ durable index answers "what did this session spawn". Merging both is
753
+ what makes the tool useful in a later turn, which is the whole point
754
+ of R1 — a child that finished three turns ago is still listed.
755
+ """
364
756
  entries = self._pool.list()
757
+ seen = {entry["childId"] for entry in entries}
758
+ if self._registry is not None and self._parent_record_id is not None:
759
+ for view in await self._registry.list_for_parent(self._parent_record_id):
760
+ if view.record.child_id in seen:
761
+ continue
762
+ entries.append(
763
+ {
764
+ "childId": view.record.child_id,
765
+ "status": view.status,
766
+ "recordId": view.record.record_id,
767
+ **(
768
+ {"profile": view.record.profile}
769
+ if view.record.profile
770
+ else {}
771
+ ),
772
+ "depth": view.record.depth,
773
+ **({"resumable": True} if view.resumable else {}),
774
+ }
775
+ )
365
776
  return ToolResult(
366
777
  success=True,
367
778
  message=json.dumps({"children": entries}),
@@ -386,25 +797,66 @@ class OrchestrationExecutor:
386
797
  )
387
798
 
388
799
  async def _wait(self, call: ToolCall) -> ToolResult:
389
- child_id = str(call.arguments.get("childId") or "")
800
+ """Wait for the first child to settle.
801
+
802
+ Three accepted shapes, because the useful question differs by context:
803
+
804
+ - ``childId`` — wait for that one (the original contract);
805
+ - ``childIds`` — wait for whichever of those settles first;
806
+ - neither — wait for any unfinished child of this pool, which is what
807
+ makes "spawn several, then collect" one call instead of N.
808
+ """
809
+ single = str(call.arguments.get("childId") or "").strip()
810
+ raw_ids = call.arguments.get("childIds")
811
+ if single and isinstance(raw_ids, list):
812
+ return ToolResult(
813
+ success=False,
814
+ error="pass either childId or childIds, not both",
815
+ needsFollowup=True,
816
+ )
817
+ child_ids: list[str] | None
818
+ if single:
819
+ child_ids = [single]
820
+ elif isinstance(raw_ids, list):
821
+ child_ids = [str(item) for item in raw_ids]
822
+ else:
823
+ child_ids = None
390
824
  raw_timeout = call.arguments.get("timeoutMs")
391
825
  timeout_s = (
392
826
  float(raw_timeout) / 1000.0
393
827
  if isinstance(raw_timeout, (int, float))
394
828
  else None
395
829
  )
396
- outcome = await self._pool.wait(child_id, timeout_s)
830
+ outcome, pending, unknown = await self._pool.wait_any(child_ids, timeout_s)
397
831
  if outcome is None:
832
+ if unknown and not pending:
833
+ return ToolResult(
834
+ success=False,
835
+ error=f"unknown_child: {', '.join(unknown)}",
836
+ needsFollowup=True,
837
+ )
838
+ # Nobody settled within the timeout: report who is still going so
839
+ # the model can decide to wait again rather than re-spawn.
398
840
  return ToolResult(
399
- success=False,
400
- error=f"unknown_child: {child_id!r}",
401
- needsFollowup=True,
841
+ success=True,
842
+ message=json.dumps(
843
+ {
844
+ "status": "running",
845
+ "pending": pending,
846
+ **({"unknown": unknown} if unknown else {}),
847
+ }
848
+ ),
849
+ data={"status": "running", "pending": pending},
402
850
  )
403
851
  payload: dict[str, Any] = {
404
852
  "childId": outcome.child_id,
405
853
  "status": outcome.status,
406
854
  "answer": outcome.answer,
407
855
  }
856
+ if pending:
857
+ payload["pending"] = pending
858
+ if unknown:
859
+ payload["unknown"] = unknown
408
860
  if self._pool.resumable(outcome.child_id):
409
861
  payload["resumable"] = True
410
862
  payload["hint"] = _RESUME_HINT
@@ -443,16 +895,27 @@ class OrchestrationExecutor:
443
895
  return bool(inner_exempt and inner_exempt(call))
444
896
 
445
897
  def concurrency_safe(self, call: ToolCall) -> bool:
446
- # Spawn consumes the bounded parallel-capacity pool, so batching two
447
- # spawns makes which call succeeds scheduler-dependent. Wait/list only
448
- # observe pool state and may batch.
898
+ """Whether this call may run alongside its siblings in one round.
899
+
900
+ ``agent_spawn`` is safe to batch even though it consumes the bounded
901
+ parallel pool: ``AgentPool.spawn`` is fully synchronous, so capacity is
902
+ checked and reserved without ever yielding to the event loop. Concurrent
903
+ spawns therefore reserve **in call order**, and a call beyond the cap is
904
+ the one that fails — deterministic, not scheduler-dependent. That is
905
+ what makes "emit several spawns in one message" a supported fan-out
906
+ rather than a race.
907
+
908
+ ``send`` / ``close`` / ``interrupt`` stay serial: they mutate an
909
+ existing child's lifecycle, and interleaving them with a sibling's
910
+ spawn would make the observed state depend on execution order.
911
+ """
449
912
  if call.name in {
913
+ self._config.spawn_tool,
450
914
  self._config.wait_tool,
451
915
  self._config.list_tool,
452
916
  }:
453
917
  return True
454
918
  if call.name in {
455
- self._config.spawn_tool,
456
919
  self._config.send_tool,
457
920
  self._config.close_tool,
458
921
  self._config.interrupt_tool,