@steerable/runtime-darwin-arm64 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.
- package/bin/steerable-egress-proxy +0 -0
- package/package.json +1 -1
- package/python-runtime/manifest.json +1 -1
- package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_harness-0.9.2.dist-info → steerable_agent_harness-0.10.1.dist-info}/METADATA +1 -1
- package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_harness-0.9.2.dist-info → steerable_agent_harness-0.10.1.dist-info}/RECORD +7 -7
- package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_protocol-0.9.2.dist-info → steerable_agent_protocol-0.10.1.dist-info}/METADATA +1 -1
- package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_protocol-0.10.1.dist-info/RECORD +11 -0
- package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/__init__.py +48 -0
- package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/budget.py +124 -0
- package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/child_notice.py +84 -0
- package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/child_record.py +174 -0
- package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/engines.py +499 -0
- package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/history.py +114 -1
- package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/orchestration.py +496 -33
- package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/pool.py +662 -16
- package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/registry.py +345 -0
- package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/storage/__init__.py +52 -0
- package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/storage/in_memory.py +74 -0
- package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/storage/sqlalchemy_store.py +153 -0
- package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/storage/sqlite_store.py +117 -1
- package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/subagent.py +157 -6
- package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_runtime-0.9.2.dist-info → steerable_agent_runtime-0.10.1.dist-info}/METADATA +39 -4
- package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_runtime-0.9.2.dist-info → steerable_agent_runtime-0.10.1.dist-info}/RECORD +26 -16
- package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime_native/steerable_agent_runtime_native.abi3.so +0 -0
- package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_runtime_native-1.0.8.dist-info → steerable_agent_runtime_native-1.0.9.dist-info}/METADATA +1 -1
- package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime_native-1.0.9.dist-info/RECORD +9 -0
- package/python-runtime/python/lib/python3.12/site-packages/steerable_sidecar/sidecar.py +545 -6
- package/python-runtime/python/lib/python3.12/site-packages/steerable_sidecar-0.10.1.dist-info/METADATA +97 -0
- package/python-runtime/python/lib/python3.12/site-packages/{steerable_sidecar-0.9.2.dist-info → steerable_sidecar-0.10.1.dist-info}/RECORD +9 -9
- package/runtime.json +66 -46
- package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_protocol-0.9.2.dist-info/RECORD +0 -11
- package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime_native-1.0.8.dist-info/RECORD +0 -9
- package/python-runtime/python/lib/python3.12/site-packages/steerable_sidecar-0.9.2.dist-info/METADATA +0 -58
- /package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_harness-0.9.2.dist-info → steerable_agent_harness-0.10.1.dist-info}/INSTALLER +0 -0
- /package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_harness-0.9.2.dist-info → steerable_agent_harness-0.10.1.dist-info}/WHEEL +0 -0
- /package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_protocol-0.9.2.dist-info → steerable_agent_protocol-0.10.1.dist-info}/INSTALLER +0 -0
- /package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_protocol-0.9.2.dist-info → steerable_agent_protocol-0.10.1.dist-info}/WHEEL +0 -0
- /package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_runtime-0.9.2.dist-info → steerable_agent_runtime-0.10.1.dist-info}/INSTALLER +0 -0
- /package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_runtime-0.9.2.dist-info → steerable_agent_runtime-0.10.1.dist-info}/WHEEL +0 -0
- /package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_runtime_native-1.0.8.dist-info → steerable_agent_runtime_native-1.0.9.dist-info}/INSTALLER +0 -0
- /package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_runtime_native-1.0.8.dist-info → steerable_agent_runtime_native-1.0.9.dist-info}/WHEEL +0 -0
- /package/python-runtime/python/lib/python3.12/site-packages/{steerable_sidecar-0.9.2.dist-info → steerable_sidecar-0.10.1.dist-info}/INSTALLER +0 -0
- /package/python-runtime/python/lib/python3.12/site-packages/{steerable_sidecar-0.9.2.dist-info → steerable_sidecar-0.10.1.dist-info}/WHEEL +0 -0
- /package/python-runtime/python/lib/python3.12/site-packages/{steerable_sidecar-0.9.2.dist-info → steerable_sidecar-0.10.1.dist-info}/entry_points.txt +0 -0
package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/orchestration.py
CHANGED
|
@@ -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 .
|
|
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
|
|
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
|
|
172
|
-
"
|
|
173
|
-
"
|
|
174
|
-
"
|
|
175
|
-
"
|
|
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
|
-
[
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
430
|
+
child_provider,
|
|
276
431
|
self._config,
|
|
277
432
|
tools=schemas,
|
|
278
433
|
hooks=self._hooks,
|
|
279
434
|
event_sink=self._event_sink,
|
|
280
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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=
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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,
|