@steerable/runtime-win32-x64 0.9.2 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/steerable-egress-proxy.exe +0 -0
- package/bin/win-spawn-helper.exe +0 -0
- 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.1.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.1.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.1.dist-info}/METADATA +1 -1
- package/python-runtime/python/Lib/site-packages/steerable_agent_protocol-0.10.1.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.1.dist-info}/METADATA +39 -4
- package/python-runtime/python/Lib/site-packages/{steerable_agent_runtime-0.9.2.dist-info → steerable_agent_runtime-0.10.1.dist-info}/RECORD +26 -16
- package/python-runtime/python/Lib/site-packages/steerable_agent_runtime_native/steerable_agent_runtime_native.pyd +0 -0
- package/python-runtime/python/Lib/site-packages/{steerable_agent_runtime_native-1.0.8.dist-info → steerable_agent_runtime_native-1.0.9.dist-info}/METADATA +1 -1
- package/python-runtime/python/Lib/site-packages/steerable_agent_runtime_native-1.0.9.dist-info/RECORD +9 -0
- package/python-runtime/python/Lib/site-packages/steerable_sidecar/sidecar.py +545 -6
- package/python-runtime/python/Lib/site-packages/steerable_sidecar-0.10.1.dist-info/METADATA +97 -0
- package/python-runtime/python/Lib/site-packages/{steerable_sidecar-0.9.2.dist-info → steerable_sidecar-0.10.1.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 +90 -70
- 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_agent_runtime_native-1.0.8.dist-info/RECORD +0 -9
- 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.1.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.1.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.1.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.1.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.1.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.1.dist-info}/WHEEL +0 -0
- /package/python-runtime/python/Lib/site-packages/{steerable_agent_runtime_native-1.0.8.dist-info → steerable_agent_runtime_native-1.0.9.dist-info}/INSTALLER +0 -0
- /package/python-runtime/python/Lib/site-packages/{steerable_agent_runtime_native-1.0.8.dist-info → steerable_agent_runtime_native-1.0.9.dist-info}/WHEEL +0 -0
- /package/python-runtime/python/Lib/site-packages/{steerable_sidecar-0.9.2.dist-info → steerable_sidecar-0.10.1.dist-info}/INSTALLER +0 -0
- /package/python-runtime/python/Lib/site-packages/{steerable_sidecar-0.9.2.dist-info → steerable_sidecar-0.10.1.dist-info}/WHEEL +0 -0
- /package/python-runtime/python/Lib/site-packages/{steerable_sidecar-0.9.2.dist-info → steerable_sidecar-0.10.1.dist-info}/entry_points.txt +0 -0
|
@@ -45,6 +45,7 @@ from collections import OrderedDict
|
|
|
45
45
|
from dataclasses import dataclass, field
|
|
46
46
|
from functools import lru_cache
|
|
47
47
|
from pathlib import Path
|
|
48
|
+
from collections.abc import Callable, Mapping
|
|
48
49
|
from typing import TYPE_CHECKING, Any, Literal
|
|
49
50
|
|
|
50
51
|
if TYPE_CHECKING:
|
|
@@ -73,8 +74,14 @@ from steerable_agent_runtime import (
|
|
|
73
74
|
LoopHooks,
|
|
74
75
|
McpClient,
|
|
75
76
|
McpError,
|
|
77
|
+
ChildNoticeHooks,
|
|
78
|
+
AcpEngineSpec,
|
|
79
|
+
AcpPermissionPolicy,
|
|
80
|
+
AcpToolBridge,
|
|
76
81
|
OrchestrationConfig,
|
|
77
82
|
OrchestrationExecutor,
|
|
83
|
+
SessionBudgetExceeded,
|
|
84
|
+
TreeBudget,
|
|
78
85
|
PluginLoadError,
|
|
79
86
|
PluginStateError,
|
|
80
87
|
PolicyDeniedError,
|
|
@@ -248,7 +255,17 @@ class Sidecar:
|
|
|
248
255
|
# request params, returns a LoopHooks (e.g. ChainHooks of retry +
|
|
249
256
|
# compaction + spill). Defaults to RetryHooks alone.
|
|
250
257
|
self._loop_hooks_factory = loop_hooks_factory
|
|
258
|
+
#: One child index per sidecar, over the same storage the records use.
|
|
259
|
+
#: Per-process (not per-stream) so a child spawned in one turn is
|
|
260
|
+
#: visible to the next turn's pool and to ``agent_list``.
|
|
261
|
+
from steerable_agent_runtime.registry import ChildRegistry
|
|
262
|
+
|
|
263
|
+
self._children_registry = ChildRegistry(self.storage)
|
|
251
264
|
self._streams: dict[str, asyncio.Task[Any]] = {}
|
|
265
|
+
#: Live orchestration pools by chat id. The panel's 「停止」 needs to
|
|
266
|
+
#: reach a child that is running right now, and the per-turn executor
|
|
267
|
+
#: that owns the pool is otherwise only reachable through its stream.
|
|
268
|
+
self._pools: dict[str, Any] = {}
|
|
252
269
|
#: Active CoreLoop instances by stream id — the steer RPC targets
|
|
253
270
|
#: these to inject user messages into a running turn.
|
|
254
271
|
self._coreloops: dict[str, CoreLoop] = {}
|
|
@@ -331,6 +348,8 @@ class Sidecar:
|
|
|
331
348
|
register("agent.session.branches", self._handle_session_branches)
|
|
332
349
|
register("agent.session.tree", self._handle_session_tree)
|
|
333
350
|
register("agent.session.messages", self._handle_session_messages)
|
|
351
|
+
register("agent.children.list", self._handle_children_list)
|
|
352
|
+
register("agent.children.interrupt", self._handle_children_interrupt)
|
|
334
353
|
|
|
335
354
|
# ------------------------------------------------------------------
|
|
336
355
|
# Entrypoint
|
|
@@ -1641,6 +1660,105 @@ class Sidecar:
|
|
|
1641
1660
|
)
|
|
1642
1661
|
return {"recordId": str(record_id), "messages": messages}
|
|
1643
1662
|
|
|
1663
|
+
async def _handle_children_interrupt(
|
|
1664
|
+
self, params: dict[str, Any] | None
|
|
1665
|
+
) -> dict[str, Any]:
|
|
1666
|
+
"""Cooperatively interrupt one running child of a chat's live turn.
|
|
1667
|
+
|
|
1668
|
+
The panel's 「停止」 target. Not an `agent_interrupt` tool call: the
|
|
1669
|
+
model is not the one asking, and routing it through a turn would make
|
|
1670
|
+
the user wait for their own click. Returns `{interrupted: false}` when
|
|
1671
|
+
the child is not running here, which is honest — the panel then just
|
|
1672
|
+
refreshes its rows.
|
|
1673
|
+
"""
|
|
1674
|
+
params = params or {}
|
|
1675
|
+
chat_id = str(params.get("chatId") or "").strip()
|
|
1676
|
+
child_id = str(params.get("childId") or "").strip()
|
|
1677
|
+
if not chat_id or not child_id:
|
|
1678
|
+
raise JsonRpcError(
|
|
1679
|
+
"chatId and childId are required",
|
|
1680
|
+
code=-32602,
|
|
1681
|
+
kind="invalid_params",
|
|
1682
|
+
)
|
|
1683
|
+
pool = self._pools.get(chat_id)
|
|
1684
|
+
if pool is None:
|
|
1685
|
+
return {"interrupted": False, "reason": "no live turn for this chat"}
|
|
1686
|
+
interrupted = bool(pool.interrupt(child_id))
|
|
1687
|
+
return {
|
|
1688
|
+
"interrupted": interrupted,
|
|
1689
|
+
**({"reason": "not running"} if not interrupted else {}),
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
async def _handle_children_list(
|
|
1693
|
+
self, params: dict[str, Any] | None
|
|
1694
|
+
) -> dict[str, Any]:
|
|
1695
|
+
"""Every child agent this sidecar has recorded for one parent.
|
|
1696
|
+
|
|
1697
|
+
``{"parentRecordId", "children": [ChildStatusView.to_wire(), ...]}``
|
|
1698
|
+
— the durable answer to "what did this session spawn", which the pool
|
|
1699
|
+
cannot give once the turn that spawned them has ended.
|
|
1700
|
+
|
|
1701
|
+
``includeFinished: false`` (default true) narrows to children that can
|
|
1702
|
+
still run, which is what a "resume these" panel wants. A missing
|
|
1703
|
+
``parentRecordId`` lists the whole store under ``prefix`` (or
|
|
1704
|
+
everything), for a host that wants a global view.
|
|
1705
|
+
"""
|
|
1706
|
+
params = params or {}
|
|
1707
|
+
include_finished = params.get("includeFinished", True) is not False
|
|
1708
|
+
parent = params.get("parentRecordId") or params.get("recordId")
|
|
1709
|
+
preview_chars = params.get("answerPreviewChars")
|
|
1710
|
+
preview_len = int(preview_chars) if isinstance(preview_chars, int) else 0
|
|
1711
|
+
prefix = params.get("prefix")
|
|
1712
|
+
|
|
1713
|
+
if parent:
|
|
1714
|
+
views = await self._children_registry.list_for_parent(
|
|
1715
|
+
str(parent), include_finished=include_finished
|
|
1716
|
+
)
|
|
1717
|
+
else:
|
|
1718
|
+
views = await self._children_registry.list_all(
|
|
1719
|
+
prefix=str(prefix) if prefix else None
|
|
1720
|
+
)
|
|
1721
|
+
if not include_finished:
|
|
1722
|
+
views = [view for view in views if not view.record.terminal]
|
|
1723
|
+
|
|
1724
|
+
children: list[dict[str, Any]] = []
|
|
1725
|
+
for view in views:
|
|
1726
|
+
preview = None
|
|
1727
|
+
if preview_len > 0:
|
|
1728
|
+
preview = await self._child_answer_preview(
|
|
1729
|
+
view.record.record_id, preview_len
|
|
1730
|
+
)
|
|
1731
|
+
children.append(view.to_wire(answer_preview=preview))
|
|
1732
|
+
return {
|
|
1733
|
+
"parentRecordId": str(parent) if parent else None,
|
|
1734
|
+
"children": children,
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
async def _child_answer_preview(
|
|
1738
|
+
self, record_id: str, limit: int
|
|
1739
|
+
) -> str | None:
|
|
1740
|
+
"""The child's last assistant text, truncated.
|
|
1741
|
+
|
|
1742
|
+
Read from the child's own record rather than stored on the index row,
|
|
1743
|
+
so the transcript keeps one authoritative copy (see ``child_record``).
|
|
1744
|
+
"""
|
|
1745
|
+
from steerable_agent_runtime.resume import load_history_items
|
|
1746
|
+
|
|
1747
|
+
try:
|
|
1748
|
+
items = await load_history_items(self.storage, record_id)
|
|
1749
|
+
except Exception: # noqa: BLE001 — a preview is never worth a failure
|
|
1750
|
+
return None
|
|
1751
|
+
if not items:
|
|
1752
|
+
return None
|
|
1753
|
+
for item in reversed(items):
|
|
1754
|
+
if item.message.role != "assistant":
|
|
1755
|
+
continue
|
|
1756
|
+
text = item.message.content_text.strip()
|
|
1757
|
+
if not text:
|
|
1758
|
+
continue
|
|
1759
|
+
return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
|
|
1760
|
+
return None
|
|
1761
|
+
|
|
1644
1762
|
async def _handle_chat_steer(self, params: dict[str, Any] | None) -> dict[str, Any]:
|
|
1645
1763
|
"""Inject a user message into a running CoreLoop turn.
|
|
1646
1764
|
|
|
@@ -2114,6 +2232,48 @@ class Sidecar:
|
|
|
2114
2232
|
# agent.child notification stream, and a concurrent profile's
|
|
2115
2233
|
# same-round delegations execute in parallel under the pool budget.
|
|
2116
2234
|
loop_config = _build_loop_config(params)
|
|
2235
|
+
# The tree budget is a second scope over the same number: `loop_config`
|
|
2236
|
+
# budgets the parent run, this one budgets the parent plus every child
|
|
2237
|
+
# it spawns. Both are created from `budgetTokens` so a host that asked
|
|
2238
|
+
# for a limit gets one that the whole tree shares. Absent it, neither
|
|
2239
|
+
# exists and behaviour is exactly as before.
|
|
2240
|
+
tree_budget = (
|
|
2241
|
+
_tree_budget_from(params) if params.get("budgetTokens") is not None else None
|
|
2242
|
+
)
|
|
2243
|
+
# The parent loop is created after both executors (the loop needs the
|
|
2244
|
+
# executors, and a fork needs the loop's transcript). Both multi-agent
|
|
2245
|
+
# surfaces read the transcript through this holder, filled in once the
|
|
2246
|
+
# loop exists, so `fresh`/`fork` mean the same thing on either tool.
|
|
2247
|
+
parent_loop_holder: list[CoreLoop] = []
|
|
2248
|
+
parent_projection = lambda: ( # noqa: E731 — one expression, used twice
|
|
2249
|
+
parent_loop_holder[0].history.projection if parent_loop_holder else []
|
|
2250
|
+
)
|
|
2251
|
+
# The parent's model route, for a cross-model fork to compare against.
|
|
2252
|
+
# Same holder reasoning as the projection: the loop exists later.
|
|
2253
|
+
parent_route = lambda: ( # noqa: E731 — one expression
|
|
2254
|
+
(
|
|
2255
|
+
str(getattr(parent_loop_holder[0]._provider, "model", "") or "") or None,
|
|
2256
|
+
str(params.get("reasoningEffort") or "") or None,
|
|
2257
|
+
)
|
|
2258
|
+
if parent_loop_holder
|
|
2259
|
+
else (None, None)
|
|
2260
|
+
)
|
|
2261
|
+
|
|
2262
|
+
def _orchestration_provider_factory(
|
|
2263
|
+
model: str | None, reasoning_effort: str | None
|
|
2264
|
+
) -> LLMProvider:
|
|
2265
|
+
"""Rebuild the provider at another route for a cross-model fork.
|
|
2266
|
+
|
|
2267
|
+
Same rule as the delegation profiles: `None` inherits, so a value
|
|
2268
|
+
the caller did not ask for is not blanked. Validation happens in
|
|
2269
|
+
the host factory, which fails a model or effort it cannot serve.
|
|
2270
|
+
"""
|
|
2271
|
+
overridden = {**params}
|
|
2272
|
+
if model is not None:
|
|
2273
|
+
overridden["model"] = model
|
|
2274
|
+
if reasoning_effort is not None:
|
|
2275
|
+
overridden["reasoningEffort"] = reasoning_effort
|
|
2276
|
+
return self._llm_provider_factory(overridden)
|
|
2117
2277
|
subagent_param = params.get("subagent", True)
|
|
2118
2278
|
subagent_executor: SubagentExecutor | None = None
|
|
2119
2279
|
if subagent_param:
|
|
@@ -2123,8 +2283,7 @@ class Sidecar:
|
|
|
2123
2283
|
if isinstance(subagent_opts.get("toolFilter"), list)
|
|
2124
2284
|
else None
|
|
2125
2285
|
)
|
|
2126
|
-
# Named profiles (CC ``subagent_type`` parity): the host passes
|
|
2127
|
-
# ``{"profiles": {"researcher": {"toolFilter": [...], "model":
|
|
2286
|
+
# Named profiles (CC ``subagent_type`` parity): the host passes # ``{"profiles": {"researcher": {"toolFilter": [...], "model":
|
|
2128
2287
|
# "...", "maxRounds": N, "concurrent": bool}}}`` and the tool
|
|
2129
2288
|
# schema advertises the names as a ``subagent_type`` enum. An
|
|
2130
2289
|
# unknown name fails closed listing the registered ones.
|
|
@@ -2137,6 +2296,9 @@ class Sidecar:
|
|
|
2137
2296
|
registry = SubagentRegistry()
|
|
2138
2297
|
for name, profile_opts in profiles.items():
|
|
2139
2298
|
p = profile_opts if isinstance(profile_opts, dict) else {}
|
|
2299
|
+
engine_spec, engine_permission = _acp_engine_from_profile(
|
|
2300
|
+
str(name), p.get("engine")
|
|
2301
|
+
)
|
|
2140
2302
|
p_filter = (
|
|
2141
2303
|
frozenset(str(t) for t in p.get("toolFilter"))
|
|
2142
2304
|
if isinstance(p.get("toolFilter"), list)
|
|
@@ -2157,7 +2319,13 @@ class Sidecar:
|
|
|
2157
2319
|
model=(
|
|
2158
2320
|
str(p["model"]) if p.get("model") is not None else None
|
|
2159
2321
|
),
|
|
2322
|
+
reasoning_effort=(
|
|
2323
|
+
str(p["reasoningEffort"])
|
|
2324
|
+
if p.get("reasoningEffort") is not None
|
|
2325
|
+
else None
|
|
2326
|
+
),
|
|
2160
2327
|
concurrent=bool(p.get("concurrent", False)),
|
|
2328
|
+
fork_context=bool(p.get("forkContext", False)),
|
|
2161
2329
|
system_prompt=(
|
|
2162
2330
|
str(p["systemPrompt"])
|
|
2163
2331
|
if p.get("systemPrompt") is not None
|
|
@@ -2168,15 +2336,33 @@ class Sidecar:
|
|
|
2168
2336
|
if p.get("description") is not None
|
|
2169
2337
|
else SubagentConfig().description
|
|
2170
2338
|
),
|
|
2339
|
+
acp_engine=engine_spec,
|
|
2340
|
+
acp_permission=engine_permission,
|
|
2341
|
+
# The bridge is supplied once on the executor
|
|
2342
|
+
# (below): every external child's file and command
|
|
2343
|
+
# requests must pass the same fence the parent's
|
|
2344
|
+
# own tools do.
|
|
2345
|
+
acp_tool_bridge=None,
|
|
2171
2346
|
),
|
|
2172
2347
|
)
|
|
2173
2348
|
|
|
2174
2349
|
# Per-profile models resolve through a provider factory that
|
|
2175
2350
|
# re-enters the host's own factory with the model overridden —
|
|
2176
2351
|
# a profile naming a model the host cannot serve fails closed
|
|
2177
|
-
# rather than silently running the parent's model.
|
|
2178
|
-
|
|
2179
|
-
|
|
2352
|
+
# rather than silently running the parent's model. Reasoning
|
|
2353
|
+
# effort travels the same way: a profile pinning it rebuilds the
|
|
2354
|
+
# provider at that level, and an unsupportable level fails at
|
|
2355
|
+
# construction (the factory validates strict).
|
|
2356
|
+
def _subagent_provider_factory(
|
|
2357
|
+
model: str | None, reasoning_effort: str | None
|
|
2358
|
+
) -> LLMProvider:
|
|
2359
|
+
overridden = {**params}
|
|
2360
|
+
# `None` means "inherit the parent" for that knob, so the
|
|
2361
|
+
# parent's own value must survive rather than be blanked.
|
|
2362
|
+
if model is not None:
|
|
2363
|
+
overridden["model"] = model
|
|
2364
|
+
if reasoning_effort is not None:
|
|
2365
|
+
overridden["reasoningEffort"] = reasoning_effort
|
|
2180
2366
|
return self._llm_provider_factory(overridden)
|
|
2181
2367
|
|
|
2182
2368
|
subagent_executor = SubagentExecutor(
|
|
@@ -2211,11 +2397,18 @@ class Sidecar:
|
|
|
2211
2397
|
event_sink=lambda kind, data: self._emit_child_event(
|
|
2212
2398
|
transport, stream_id, kind, data
|
|
2213
2399
|
),
|
|
2400
|
+
parent_projection=parent_projection,
|
|
2401
|
+
# External-engine children reach files and commands through
|
|
2402
|
+
# this bridge, which re-enters the same executor the parent
|
|
2403
|
+
# uses — so the project fence and egress allow-list apply to
|
|
2404
|
+
# them identically. Built once per turn; a bridge that cannot
|
|
2405
|
+
# reach a capability refuses that request.
|
|
2406
|
+
acp_tool_bridge=_acp_bridge_for(executor),
|
|
2214
2407
|
)
|
|
2215
2408
|
executor = subagent_executor
|
|
2216
2409
|
tools = [
|
|
2217
2410
|
*(tools or []),
|
|
2218
|
-
subagent_tool_descriptor(registry=registry),
|
|
2411
|
+
subagent_tool_descriptor(registry=registry, fork_available=True),
|
|
2219
2412
|
]
|
|
2220
2413
|
# requiredProfiles: the host named these sub-agents for the turn
|
|
2221
2414
|
# (desktop ``@`` mentions), so finishing without delegating to
|
|
@@ -2353,7 +2546,14 @@ class Sidecar:
|
|
|
2353
2546
|
"childMaxToolErrors", loop_config.max_tool_errors
|
|
2354
2547
|
)
|
|
2355
2548
|
),
|
|
2549
|
+
fork_turns=(
|
|
2550
|
+
int(orchestration_param["forkTurns"])
|
|
2551
|
+
if isinstance(orchestration_param.get("forkTurns"), int)
|
|
2552
|
+
else None
|
|
2553
|
+
),
|
|
2356
2554
|
)
|
|
2555
|
+
# The parent loop is created after this executor (the loop needs
|
|
2556
|
+
# the executor), so a fork reads the transcript through a holder.
|
|
2357
2557
|
orchestration = OrchestrationExecutor(
|
|
2358
2558
|
executor,
|
|
2359
2559
|
provider,
|
|
@@ -2362,6 +2562,21 @@ class Sidecar:
|
|
|
2362
2562
|
event_sink=lambda kind, data: self._emit_child_event(
|
|
2363
2563
|
transport, stream_id, kind, data
|
|
2364
2564
|
),
|
|
2565
|
+
# The durable child index: rows are what let a later turn
|
|
2566
|
+
# (or a restarted process) list and resume this turn's
|
|
2567
|
+
# children, and they only work if the pool writes them.
|
|
2568
|
+
registry=self._children_registry,
|
|
2569
|
+
# Children get their own record under the parent's, so the
|
|
2570
|
+
# orchestration family matches delegate_subagent: both write
|
|
2571
|
+
# a readable process log for every child.
|
|
2572
|
+
history_store=self.storage,
|
|
2573
|
+
parent_record_id=(
|
|
2574
|
+
params.get("recordId") or params.get("chatId") or None
|
|
2575
|
+
),
|
|
2576
|
+
parent_projection=parent_projection,
|
|
2577
|
+
parent_route=parent_route,
|
|
2578
|
+
provider_factory=_orchestration_provider_factory,
|
|
2579
|
+
budget=tree_budget,
|
|
2365
2580
|
)
|
|
2366
2581
|
if subagent_executor is not None:
|
|
2367
2582
|
subagent_executor.attach_pool(orchestration.pool)
|
|
@@ -2455,6 +2670,11 @@ class Sidecar:
|
|
|
2455
2670
|
# toolCallDelta arrives whole — no incremental argument fragments.)
|
|
2456
2671
|
if params.get("streamRawChunks"):
|
|
2457
2672
|
hooks = ChainHooks(hooks, RawChunkBridgeHooks(transport, stream_id))
|
|
2673
|
+
if orchestration is not None:
|
|
2674
|
+
# Tell the parent when a child settles instead of making it poll
|
|
2675
|
+
# `agent_wait`. Appended last so a notice still lands even when an
|
|
2676
|
+
# earlier hook rewrites the transcript this step.
|
|
2677
|
+
hooks = ChainHooks(hooks, ChildNoticeHooks(orchestration.pool))
|
|
2458
2678
|
loop = CoreLoop(
|
|
2459
2679
|
provider,
|
|
2460
2680
|
executor,
|
|
@@ -2466,6 +2686,15 @@ class Sidecar:
|
|
|
2466
2686
|
history_store=self.storage,
|
|
2467
2687
|
record_id=params.get("recordId") or params.get("chatId"),
|
|
2468
2688
|
)
|
|
2689
|
+
# Hand the loop to the orchestration executor's fork path (see the
|
|
2690
|
+
# holder's declaration above).
|
|
2691
|
+
if orchestration is not None:
|
|
2692
|
+
parent_loop_holder.append(loop)
|
|
2693
|
+
# Expose the pool to the panel's live controls (interrupt a running
|
|
2694
|
+
# child). Keyed by chat: one turn at a time per chat, so a later
|
|
2695
|
+
# turn simply replaces it.
|
|
2696
|
+
if params.get("chatId"):
|
|
2697
|
+
self._pools[str(params["chatId"])] = orchestration.pool
|
|
2469
2698
|
# Persist the run as it streams so the host can inspect it afterwards
|
|
2470
2699
|
# via trace.fetch (and so a future resume projection has the events).
|
|
2471
2700
|
recorder = TraceRecorder(self.storage, chat_id=params.get("chatId"))
|
|
@@ -2473,6 +2702,12 @@ class Sidecar:
|
|
|
2473
2702
|
self._exec_controls[stream_id] = exec_control
|
|
2474
2703
|
final_content_only = params.get("contentMode") == "final"
|
|
2475
2704
|
pending_content: list[str] = []
|
|
2705
|
+
#: Whether the parent's own spend has been charged to the tree. The
|
|
2706
|
+
#: terminal completion carries the run's accumulated usage, and it is
|
|
2707
|
+
#: emitted *before* the turn's `finally`, so charging there is what
|
|
2708
|
+
#: makes `treeUsage` include the parent rather than reporting zero;
|
|
2709
|
+
#: `finally` then only covers runs that produced no completion.
|
|
2710
|
+
parent_charged = False
|
|
2476
2711
|
from .egress_ask import bind_turn_chat_id, reset_turn_chat_id
|
|
2477
2712
|
|
|
2478
2713
|
raw_chat_id = params.get("chatId")
|
|
@@ -2507,6 +2742,22 @@ class Sidecar:
|
|
|
2507
2742
|
},
|
|
2508
2743
|
)
|
|
2509
2744
|
pending_content.clear()
|
|
2745
|
+
if (
|
|
2746
|
+
not parent_charged
|
|
2747
|
+
and tree_budget is not None
|
|
2748
|
+
and event.kind == "completion"
|
|
2749
|
+
and event.data.get("status") != "executing"
|
|
2750
|
+
):
|
|
2751
|
+
# Charge the parent **before** the terminal event goes
|
|
2752
|
+
# out: `stream.done` reports the tree's spend, and the
|
|
2753
|
+
# parent is the largest part of it. Charging afterwards
|
|
2754
|
+
# (in `finally`) reported a tree that had spent nothing
|
|
2755
|
+
# while the child's numbers were already in.
|
|
2756
|
+
parent_charged = True
|
|
2757
|
+
try:
|
|
2758
|
+
tree_budget.charge(loop.last_run_usage, agent="parent")
|
|
2759
|
+
except SessionBudgetExceeded as exc:
|
|
2760
|
+
logger.warning("parent crossed the tree budget: %s", exc)
|
|
2510
2761
|
await self._emit_loop_event(
|
|
2511
2762
|
transport,
|
|
2512
2763
|
stream_id,
|
|
@@ -2514,6 +2765,20 @@ class Sidecar:
|
|
|
2514
2765
|
recorder.trace_id,
|
|
2515
2766
|
model=params.get("model"),
|
|
2516
2767
|
hooks=hooks,
|
|
2768
|
+
tree_report=(
|
|
2769
|
+
(lambda: _tree_report_of(tree_budget))
|
|
2770
|
+
if tree_budget is not None
|
|
2771
|
+
else None
|
|
2772
|
+
),
|
|
2773
|
+
child_usage=(
|
|
2774
|
+
orchestration.pool.children_usage
|
|
2775
|
+
if orchestration is not None
|
|
2776
|
+
else (
|
|
2777
|
+
subagent_executor.pool.children_usage
|
|
2778
|
+
if subagent_executor is not None
|
|
2779
|
+
else None
|
|
2780
|
+
)
|
|
2781
|
+
),
|
|
2517
2782
|
)
|
|
2518
2783
|
except asyncio.CancelledError:
|
|
2519
2784
|
await transport.emit_notification(
|
|
@@ -2539,6 +2804,16 @@ class Sidecar:
|
|
|
2539
2804
|
},
|
|
2540
2805
|
)
|
|
2541
2806
|
finally:
|
|
2807
|
+
# Backstop for the paths that never reach a terminal completion
|
|
2808
|
+
# (cancel, exception): the tree limit covers the parent too, and
|
|
2809
|
+
# after this point the pool is gone and nobody can charge it. The
|
|
2810
|
+
# normal path already charged at the terminal event, so this does
|
|
2811
|
+
# not double-count.
|
|
2812
|
+
if tree_budget is not None and not parent_charged:
|
|
2813
|
+
try:
|
|
2814
|
+
tree_budget.charge(loop.last_run_usage, agent="parent")
|
|
2815
|
+
except SessionBudgetExceeded as exc:
|
|
2816
|
+
logger.warning("parent crossed the tree budget: %s", exc)
|
|
2542
2817
|
reset_turn_chat_id(chat_token)
|
|
2543
2818
|
self._coreloops.pop(stream_id, None)
|
|
2544
2819
|
self._exec_controls.pop(stream_id, None)
|
|
@@ -2555,6 +2830,11 @@ class Sidecar:
|
|
|
2555
2830
|
elif subagent_executor is not None:
|
|
2556
2831
|
# Delegation-only turn: wind down the delegate pool.
|
|
2557
2832
|
await subagent_executor.shutdown()
|
|
2833
|
+
if orchestration is not None and params.get("chatId"):
|
|
2834
|
+
# Only drop the index entry if it is still this turn's pool:
|
|
2835
|
+
# a later turn may already have replaced it.
|
|
2836
|
+
if self._pools.get(str(params["chatId"])) is orchestration.pool:
|
|
2837
|
+
self._pools.pop(str(params["chatId"]), None)
|
|
2558
2838
|
await recorder.finalize()
|
|
2559
2839
|
self._streams.pop(stream_id, None)
|
|
2560
2840
|
|
|
@@ -2593,6 +2873,8 @@ class Sidecar:
|
|
|
2593
2873
|
trace_id: str | None = None,
|
|
2594
2874
|
model: str | None = None,
|
|
2595
2875
|
hooks: Any | None = None,
|
|
2876
|
+
tree_report: Callable[[], Any] | None = None,
|
|
2877
|
+
child_usage: Callable[[], tuple[list[dict[str, Any]], int]] | None = None,
|
|
2596
2878
|
) -> None:
|
|
2597
2879
|
kind = event.kind
|
|
2598
2880
|
data = event.data
|
|
@@ -2764,6 +3046,40 @@ class Sidecar:
|
|
|
2764
3046
|
)
|
|
2765
3047
|
if cost is not None:
|
|
2766
3048
|
done["usage"] = {**usage, "costUsd": cost}
|
|
3049
|
+
if child_usage is not None:
|
|
3050
|
+
# The parent's own bill is `usage` above; these are the
|
|
3051
|
+
# children's, split per childId so a host can show the turn's
|
|
3052
|
+
# real cost without walking the registry. `unknown` counts
|
|
3053
|
+
# children whose engine reports no tokens at all — adding
|
|
3054
|
+
# zeros silently would present a partial total as complete.
|
|
3055
|
+
entries, unknown = child_usage()
|
|
3056
|
+
if entries:
|
|
3057
|
+
totals = {
|
|
3058
|
+
key: sum(int(entry[key]) for entry in entries)
|
|
3059
|
+
for key in ("promptTokens", "completionTokens", "totalTokens")
|
|
3060
|
+
}
|
|
3061
|
+
done["usage"] = {
|
|
3062
|
+
**(done.get("usage") or {}),
|
|
3063
|
+
"childPromptTokens": totals["promptTokens"],
|
|
3064
|
+
"childCompletionTokens": totals["completionTokens"],
|
|
3065
|
+
"childTotalTokens": totals["totalTokens"],
|
|
3066
|
+
"children": entries,
|
|
3067
|
+
**({"unknownUsageChildren": unknown} if unknown else {}),
|
|
3068
|
+
}
|
|
3069
|
+
elif unknown:
|
|
3070
|
+
done["usage"] = {
|
|
3071
|
+
**(done.get("usage") or {}),
|
|
3072
|
+
"unknownUsageChildren": unknown,
|
|
3073
|
+
}
|
|
3074
|
+
if tree_report is not None:
|
|
3075
|
+
tree = tree_report()
|
|
3076
|
+
if tree is not None:
|
|
3077
|
+
# The parent's own bill is the `usage` above; this adds
|
|
3078
|
+
# what its children spent and the per-child split, so a
|
|
3079
|
+
# host can show the turn's real cost without re-reading
|
|
3080
|
+
# the registry for every child. `_tree_report_of` already
|
|
3081
|
+
# produced the wire shape, so this is a pass-through.
|
|
3082
|
+
done["treeUsage"] = tree
|
|
2767
3083
|
await transport.emit_notification("stream.done", done)
|
|
2768
3084
|
|
|
2769
3085
|
# ------------------------------------------------------------------
|
|
@@ -3409,6 +3725,229 @@ def _spill_directory() -> str:
|
|
|
3409
3725
|
return os.path.join(base, "steerable-spill")
|
|
3410
3726
|
|
|
3411
3727
|
|
|
3728
|
+
def _acp_bridge_for(executor: Any) -> AcpToolBridge:
|
|
3729
|
+
"""A file/terminal bridge that runs ACP requests through the parent's tools.
|
|
3730
|
+
|
|
3731
|
+
Two decisions here:
|
|
3732
|
+
|
|
3733
|
+
- **Same domain, same fence.** The bridge calls the parent's executor, so
|
|
3734
|
+
an external agent's write hits the project fence and its command hits the
|
|
3735
|
+
egress allow-list exactly as the parent's own would. A second,
|
|
3736
|
+
independent path would be a second confinement story to keep correct.
|
|
3737
|
+
- **Approval stays with the policy.** The bridge does not ask the user: the
|
|
3738
|
+
engine's ``AcpPermissionPolicy`` already decided whether the call may
|
|
3739
|
+
happen. Fence *after* approval is what makes "allowed but still confined"
|
|
3740
|
+
a meaningful pair.
|
|
3741
|
+
|
|
3742
|
+
Args:
|
|
3743
|
+
executor: The parent's tool executor (already wrapped by approval,
|
|
3744
|
+
sandbox and filtering in the caller).
|
|
3745
|
+
|
|
3746
|
+
Returns:
|
|
3747
|
+
A bridge wired to ``local_read_file`` / ``local_write_file`` /
|
|
3748
|
+
``local_exec_shell`` when the parent has them, and refusing otherwise.
|
|
3749
|
+
"""
|
|
3750
|
+
|
|
3751
|
+
async def invoke(name: str, arguments: dict[str, Any]) -> Any:
|
|
3752
|
+
from steerable_agent_protocol.generated import ToolCall
|
|
3753
|
+
from steerable_agent_runtime.loop import LoopContext
|
|
3754
|
+
|
|
3755
|
+
result = await executor.execute(
|
|
3756
|
+
ToolCall(id=f"acp-{name}", name=name, arguments=arguments),
|
|
3757
|
+
LoopContext(),
|
|
3758
|
+
)
|
|
3759
|
+
if not getattr(result, "success", False):
|
|
3760
|
+
raise AcpUnavailable(
|
|
3761
|
+
f"{name} was refused: {getattr(result, 'error', None) or 'no reason reported'}"
|
|
3762
|
+
)
|
|
3763
|
+
return result
|
|
3764
|
+
|
|
3765
|
+
async def read_text_file(
|
|
3766
|
+
path: str, line: int | None, limit: int | None
|
|
3767
|
+
) -> str:
|
|
3768
|
+
result = await invoke(
|
|
3769
|
+
"local_read_file",
|
|
3770
|
+
{
|
|
3771
|
+
"path": path,
|
|
3772
|
+
**({"offset": line} if isinstance(line, int) else {}),
|
|
3773
|
+
**({"limit": limit} if isinstance(limit, int) else {}),
|
|
3774
|
+
},
|
|
3775
|
+
)
|
|
3776
|
+
return str(getattr(result, "message", "") or "")
|
|
3777
|
+
|
|
3778
|
+
async def write_text_file(path: str, content: str) -> None:
|
|
3779
|
+
await invoke("local_write_file", {"path": path, "content": content})
|
|
3780
|
+
|
|
3781
|
+
async def create_terminal(
|
|
3782
|
+
command: str, args: list[str], env: Mapping[str, str] | None
|
|
3783
|
+
) -> Any:
|
|
3784
|
+
argv = " ".join([command, *args]).strip()
|
|
3785
|
+
result = await invoke("local_exec_shell", {"command": argv})
|
|
3786
|
+
return {
|
|
3787
|
+
"terminalId": f"acp-{abs(hash(argv)) % 100000}",
|
|
3788
|
+
"output": str(getattr(result, "message", "") or ""),
|
|
3789
|
+
}
|
|
3790
|
+
|
|
3791
|
+
return AcpToolBridge(
|
|
3792
|
+
read_text_file=read_text_file,
|
|
3793
|
+
write_text_file=write_text_file,
|
|
3794
|
+
create_terminal=create_terminal,
|
|
3795
|
+
)
|
|
3796
|
+
|
|
3797
|
+
|
|
3798
|
+
def _acp_engine_from_profile(
|
|
3799
|
+
name: str, raw: Any
|
|
3800
|
+
) -> tuple[AcpEngineSpec | None, AcpPermissionPolicy | None]:
|
|
3801
|
+
"""One profile's `engine` block → the spec and policy that drive it.
|
|
3802
|
+
|
|
3803
|
+
Only `kind: "acp"` is supported. Anything else fails closed at load: a
|
|
3804
|
+
profile naming an engine this build cannot run must not silently execute
|
|
3805
|
+
locally, because the user asked for a *different* agent to do the work.
|
|
3806
|
+
|
|
3807
|
+
Args:
|
|
3808
|
+
name: The profile name, for the error message.
|
|
3809
|
+
raw: The profile's `engine` value (`None` = local loop).
|
|
3810
|
+
|
|
3811
|
+
Returns:
|
|
3812
|
+
The engine spec and its permission policy, or `(None, None)` for a
|
|
3813
|
+
local profile.
|
|
3814
|
+
|
|
3815
|
+
Raises:
|
|
3816
|
+
JsonRpcError: on an unsupported kind or a missing command.
|
|
3817
|
+
"""
|
|
3818
|
+
if raw is None:
|
|
3819
|
+
return None, None
|
|
3820
|
+
if not isinstance(raw, dict):
|
|
3821
|
+
raise JsonRpcError(
|
|
3822
|
+
f"profile {name!r}: engine must be an object",
|
|
3823
|
+
code=-32602,
|
|
3824
|
+
kind="invalid_params",
|
|
3825
|
+
)
|
|
3826
|
+
kind = raw.get("kind")
|
|
3827
|
+
if kind != "acp":
|
|
3828
|
+
raise JsonRpcError(
|
|
3829
|
+
f"unsupported_engine: only \"acp\" is supported; got {kind!r} "
|
|
3830
|
+
f"(profile {name!r})",
|
|
3831
|
+
code=-32602,
|
|
3832
|
+
kind="unsupported_engine",
|
|
3833
|
+
)
|
|
3834
|
+
command = str(raw.get("command") or "").strip()
|
|
3835
|
+
if not command:
|
|
3836
|
+
raise JsonRpcError(
|
|
3837
|
+
f"profile {name!r}: engine.command is required for an acp engine",
|
|
3838
|
+
code=-32602,
|
|
3839
|
+
kind="invalid_params",
|
|
3840
|
+
)
|
|
3841
|
+
args = raw.get("args")
|
|
3842
|
+
env = raw.get("env")
|
|
3843
|
+
timeout = raw.get("timeoutSeconds")
|
|
3844
|
+
spec = AcpEngineSpec(
|
|
3845
|
+
command=command,
|
|
3846
|
+
args=tuple(str(a) for a in args) if isinstance(args, list) else (),
|
|
3847
|
+
env=(
|
|
3848
|
+
{str(k): str(v) for k, v in env.items()}
|
|
3849
|
+
if isinstance(env, dict)
|
|
3850
|
+
else {}
|
|
3851
|
+
),
|
|
3852
|
+
cwd=str(raw["cwd"]) if raw.get("cwd") else None,
|
|
3853
|
+
timeout_s=float(timeout) if isinstance(timeout, (int, float)) else 1800.0,
|
|
3854
|
+
)
|
|
3855
|
+
return spec, _acp_permission_from_profile(name, raw.get("permission"))
|
|
3856
|
+
|
|
3857
|
+
|
|
3858
|
+
def _acp_permission_from_profile(
|
|
3859
|
+
name: str, raw: Any
|
|
3860
|
+
) -> AcpPermissionPolicy:
|
|
3861
|
+
"""One profile's `engine.permission` → the fixed policy for that engine.
|
|
3862
|
+
|
|
3863
|
+
Accepts a bare string (`"allow"` / `"reject"`) or an object
|
|
3864
|
+
`{default?, byKind?}`. Unknown decisions fail closed at load rather than
|
|
3865
|
+
becoming an accidental grant.
|
|
3866
|
+
|
|
3867
|
+
Args:
|
|
3868
|
+
name: The profile name, for the error message.
|
|
3869
|
+
raw: The `permission` value (`None` = the policy default, reject).
|
|
3870
|
+
|
|
3871
|
+
Returns:
|
|
3872
|
+
A permission policy over the ACP tool kinds.
|
|
3873
|
+
"""
|
|
3874
|
+
if raw is None:
|
|
3875
|
+
return AcpPermissionPolicy()
|
|
3876
|
+
decisions = ("allow", "reject")
|
|
3877
|
+
if isinstance(raw, str):
|
|
3878
|
+
if raw not in decisions:
|
|
3879
|
+
raise JsonRpcError(
|
|
3880
|
+
f"profile {name!r}: engine.permission must be \"allow\" or "
|
|
3881
|
+
f"\"reject\"; got {raw!r}",
|
|
3882
|
+
code=-32602,
|
|
3883
|
+
kind="invalid_params",
|
|
3884
|
+
)
|
|
3885
|
+
return AcpPermissionPolicy(default=raw) # type: ignore[arg-type]
|
|
3886
|
+
if not isinstance(raw, dict):
|
|
3887
|
+
raise JsonRpcError(
|
|
3888
|
+
f"profile {name!r}: engine.permission must be a string or an object",
|
|
3889
|
+
code=-32602,
|
|
3890
|
+
kind="invalid_params",
|
|
3891
|
+
)
|
|
3892
|
+
default = raw.get("default", "reject")
|
|
3893
|
+
if default not in decisions:
|
|
3894
|
+
raise JsonRpcError(
|
|
3895
|
+
f"profile {name!r}: engine.permission.default must be \"allow\" or "
|
|
3896
|
+
f"\"reject\"; got {default!r}",
|
|
3897
|
+
code=-32602,
|
|
3898
|
+
kind="invalid_params",
|
|
3899
|
+
)
|
|
3900
|
+
by_kind_raw = raw.get("byKind")
|
|
3901
|
+
by_kind: dict[str, str] = {}
|
|
3902
|
+
if isinstance(by_kind_raw, dict):
|
|
3903
|
+
for kind, decision in by_kind_raw.items():
|
|
3904
|
+
if decision not in decisions:
|
|
3905
|
+
raise JsonRpcError(
|
|
3906
|
+
f"profile {name!r}: engine.permission.byKind[{kind!r}] must "
|
|
3907
|
+
f"be \"allow\" or \"reject\"; got {decision!r}",
|
|
3908
|
+
code=-32602,
|
|
3909
|
+
kind="invalid_params",
|
|
3910
|
+
)
|
|
3911
|
+
by_kind[str(kind)] = decision
|
|
3912
|
+
return AcpPermissionPolicy(default=default, by_kind=by_kind) # type: ignore[arg-type]
|
|
3913
|
+
|
|
3914
|
+
|
|
3915
|
+
def _tree_report_of(budget: TreeBudget) -> dict[str, Any]:
|
|
3916
|
+
"""The whole tree's spend as the wire object ``stream.done`` carries.
|
|
3917
|
+
|
|
3918
|
+
``byAgent`` keys are the caller's agent labels (``"parent"``, ``"0.1"``),
|
|
3919
|
+
which is what lets a host attribute a turn's bill without reading the
|
|
3920
|
+
registry for every child.
|
|
3921
|
+
"""
|
|
3922
|
+
report = budget.report()
|
|
3923
|
+
return {"limit": report.limit, "spent": report.spent, "byAgent": dict(report.by_agent)}
|
|
3924
|
+
|
|
3925
|
+
|
|
3926
|
+
def _tree_budget_from(params: dict[str, Any]) -> TreeBudget:
|
|
3927
|
+
"""Build the turn tree's shared token limit from the request.
|
|
3928
|
+
|
|
3929
|
+
Same ``budgetTokens`` the parent run uses, because a host that asks for a
|
|
3930
|
+
limit means the turn — not "each loop separately". A value that is not a
|
|
3931
|
+
positive integer is a request error, not a silent zero budget.
|
|
3932
|
+
"""
|
|
3933
|
+
raw = params.get("budgetTokens")
|
|
3934
|
+
try:
|
|
3935
|
+
limit = int(raw)
|
|
3936
|
+
except (TypeError, ValueError) as exc:
|
|
3937
|
+
raise JsonRpcError(
|
|
3938
|
+
"budgetTokens must be a positive integer",
|
|
3939
|
+
code=-32602,
|
|
3940
|
+
kind="invalid_params",
|
|
3941
|
+
) from exc
|
|
3942
|
+
if limit <= 0:
|
|
3943
|
+
raise JsonRpcError(
|
|
3944
|
+
"budgetTokens must be a positive integer",
|
|
3945
|
+
code=-32602,
|
|
3946
|
+
kind="invalid_params",
|
|
3947
|
+
)
|
|
3948
|
+
return TreeBudget(limit=limit)
|
|
3949
|
+
|
|
3950
|
+
|
|
3412
3951
|
def _build_loop_config(params: dict[str, Any]) -> LoopConfig:
|
|
3413
3952
|
# Loop-limit precedence (W3.4.2.4): explicit request param > the bundled
|
|
3414
3953
|
# default spec's `loop:` section > the baseline. `resolve_loop_limits` is
|