@steerable/runtime-win32-x64 0.9.2 → 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.
- package/package.json +1 -1
- package/python-runtime/manifest.json +1 -1
- package/python-runtime/python/Lib/site-packages/cffi-2.1.1.dist-info/RECORD +1 -1
- package/python-runtime/python/Lib/site-packages/httpx-0.28.1.dist-info/RECORD +1 -1
- package/python-runtime/python/Lib/site-packages/idna-3.20.dist-info/RECORD +1 -1
- package/python-runtime/python/Lib/site-packages/jsonschema-4.26.0.dist-info/RECORD +1 -1
- package/python-runtime/python/Lib/site-packages/mcp-1.30.0.dist-info/RECORD +1 -1
- package/python-runtime/python/Lib/site-packages/python_dotenv-1.2.4.dist-info/RECORD +1 -1
- package/python-runtime/python/Lib/site-packages/pywin32-312.dist-info/RECORD +2 -2
- package/python-runtime/python/Lib/site-packages/{steerable_agent_harness-0.9.2.dist-info → steerable_agent_harness-0.10.0.dist-info}/METADATA +1 -1
- package/python-runtime/python/Lib/site-packages/{steerable_agent_harness-0.9.2.dist-info → steerable_agent_harness-0.10.0.dist-info}/RECORD +7 -7
- package/python-runtime/python/Lib/site-packages/{steerable_agent_protocol-0.9.2.dist-info → steerable_agent_protocol-0.10.0.dist-info}/METADATA +1 -1
- package/python-runtime/python/Lib/site-packages/steerable_agent_protocol-0.10.0.dist-info/RECORD +11 -0
- package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/__init__.py +48 -0
- package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/budget.py +124 -0
- package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/child_notice.py +84 -0
- package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/child_record.py +174 -0
- package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/engines.py +499 -0
- package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/history.py +114 -1
- package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/orchestration.py +496 -33
- package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/pool.py +662 -16
- package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/registry.py +345 -0
- package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/storage/__init__.py +52 -0
- package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/storage/in_memory.py +74 -0
- package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/storage/sqlalchemy_store.py +153 -0
- package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/storage/sqlite_store.py +117 -1
- package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/subagent.py +157 -6
- package/python-runtime/python/Lib/site-packages/{steerable_agent_runtime-0.9.2.dist-info → steerable_agent_runtime-0.10.0.dist-info}/METADATA +38 -3
- package/python-runtime/python/Lib/site-packages/{steerable_agent_runtime-0.9.2.dist-info → steerable_agent_runtime-0.10.0.dist-info}/RECORD +26 -16
- package/python-runtime/python/Lib/site-packages/steerable_sidecar/sidecar.py +545 -6
- package/python-runtime/python/Lib/site-packages/steerable_sidecar-0.10.0.dist-info/METADATA +97 -0
- package/python-runtime/python/Lib/site-packages/{steerable_sidecar-0.9.2.dist-info → steerable_sidecar-0.10.0.dist-info}/RECORD +12 -12
- package/python-runtime/python/Lib/site-packages/uvicorn-0.54.0.dist-info/RECORD +1 -1
- package/python-runtime/python/Scripts/cffi-gen-src.exe +0 -0
- package/python-runtime/python/Scripts/dotenv.exe +0 -0
- package/python-runtime/python/Scripts/httpx.exe +0 -0
- package/python-runtime/python/Scripts/idna.exe +0 -0
- package/python-runtime/python/Scripts/jsonschema.exe +0 -0
- package/python-runtime/python/Scripts/mcp.exe +0 -0
- package/python-runtime/python/Scripts/pip.exe +0 -0
- package/python-runtime/python/Scripts/pip3.12.exe +0 -0
- package/python-runtime/python/Scripts/pip3.exe +0 -0
- package/python-runtime/python/Scripts/pywin32_postinstall.exe +0 -0
- package/python-runtime/python/Scripts/pywin32_testall.exe +0 -0
- package/python-runtime/python/Scripts/steerable-sidecar-acp.exe +0 -0
- package/python-runtime/python/Scripts/steerable-sidecar-headless.exe +0 -0
- package/python-runtime/python/Scripts/steerable-sidecar.exe +0 -0
- package/python-runtime/python/Scripts/uvicorn.exe +0 -0
- package/runtime.json +80 -60
- package/python-runtime/python/Lib/site-packages/steerable_agent_protocol-0.9.2.dist-info/RECORD +0 -11
- package/python-runtime/python/Lib/site-packages/steerable_sidecar-0.9.2.dist-info/METADATA +0 -58
- /package/python-runtime/python/Lib/site-packages/{steerable_agent_harness-0.9.2.dist-info → steerable_agent_harness-0.10.0.dist-info}/INSTALLER +0 -0
- /package/python-runtime/python/Lib/site-packages/{steerable_agent_harness-0.9.2.dist-info → steerable_agent_harness-0.10.0.dist-info}/WHEEL +0 -0
- /package/python-runtime/python/Lib/site-packages/{steerable_agent_protocol-0.9.2.dist-info → steerable_agent_protocol-0.10.0.dist-info}/INSTALLER +0 -0
- /package/python-runtime/python/Lib/site-packages/{steerable_agent_protocol-0.9.2.dist-info → steerable_agent_protocol-0.10.0.dist-info}/WHEEL +0 -0
- /package/python-runtime/python/Lib/site-packages/{steerable_agent_runtime-0.9.2.dist-info → steerable_agent_runtime-0.10.0.dist-info}/INSTALLER +0 -0
- /package/python-runtime/python/Lib/site-packages/{steerable_agent_runtime-0.9.2.dist-info → steerable_agent_runtime-0.10.0.dist-info}/WHEEL +0 -0
- /package/python-runtime/python/Lib/site-packages/{steerable_sidecar-0.9.2.dist-info → steerable_sidecar-0.10.0.dist-info}/INSTALLER +0 -0
- /package/python-runtime/python/Lib/site-packages/{steerable_sidecar-0.9.2.dist-info → steerable_sidecar-0.10.0.dist-info}/WHEEL +0 -0
- /package/python-runtime/python/Lib/site-packages/{steerable_sidecar-0.9.2.dist-info → steerable_sidecar-0.10.0.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 .
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
208
|
-
handle.task = asyncio.ensure_future(self._run_child(handle,
|
|
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":
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1075
|
+
self._cancel_handle(handle)
|
|
430
1076
|
for handle in live:
|
|
431
1077
|
assert handle.task is not None
|
|
432
1078
|
try:
|