@steerable/runtime-win32-x64 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/bin/steerable-egress-proxy.exe +0 -0
  2. package/bin/win-spawn-helper.exe +0 -0
  3. package/package.json +1 -1
  4. package/python-runtime/manifest.json +1 -1
  5. package/python-runtime/python/Lib/site-packages/cffi-2.1.1.dist-info/RECORD +1 -1
  6. package/python-runtime/python/Lib/site-packages/httpx-0.28.1.dist-info/RECORD +1 -1
  7. package/python-runtime/python/Lib/site-packages/idna-3.20.dist-info/RECORD +1 -1
  8. package/python-runtime/python/Lib/site-packages/jsonschema-4.26.0.dist-info/RECORD +1 -1
  9. package/python-runtime/python/Lib/site-packages/mcp-1.30.0.dist-info/RECORD +1 -1
  10. package/python-runtime/python/Lib/site-packages/python_dotenv-1.2.4.dist-info/RECORD +1 -1
  11. package/python-runtime/python/Lib/site-packages/pywin32-312.dist-info/RECORD +2 -2
  12. package/python-runtime/python/Lib/site-packages/{steerable_agent_harness-0.9.1.dist-info → steerable_agent_harness-0.10.0.dist-info}/METADATA +1 -1
  13. package/python-runtime/python/Lib/site-packages/{steerable_agent_harness-0.9.1.dist-info → steerable_agent_harness-0.10.0.dist-info}/RECORD +7 -7
  14. package/python-runtime/python/Lib/site-packages/{steerable_agent_protocol-0.9.1.dist-info → steerable_agent_protocol-0.10.0.dist-info}/METADATA +1 -1
  15. package/python-runtime/python/Lib/site-packages/steerable_agent_protocol-0.10.0.dist-info/RECORD +11 -0
  16. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/__init__.py +48 -0
  17. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/budget.py +124 -0
  18. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/child_notice.py +84 -0
  19. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/child_record.py +174 -0
  20. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/engines.py +499 -0
  21. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/history.py +114 -1
  22. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/orchestration.py +496 -33
  23. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/pool.py +662 -16
  24. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/registry.py +345 -0
  25. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/storage/__init__.py +52 -0
  26. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/storage/in_memory.py +74 -0
  27. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/storage/sqlalchemy_store.py +153 -0
  28. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/storage/sqlite_store.py +117 -1
  29. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/subagent.py +157 -6
  30. package/python-runtime/python/Lib/site-packages/{steerable_agent_runtime-0.9.1.dist-info → steerable_agent_runtime-0.10.0.dist-info}/METADATA +39 -4
  31. package/python-runtime/python/Lib/site-packages/{steerable_agent_runtime-0.9.1.dist-info → steerable_agent_runtime-0.10.0.dist-info}/RECORD +26 -16
  32. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime_native/steerable_agent_runtime_native.pyd +0 -0
  33. package/python-runtime/python/Lib/site-packages/{steerable_agent_runtime_native-1.0.7.dist-info → steerable_agent_runtime_native-1.0.8.dist-info}/METADATA +1 -1
  34. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime_native-1.0.8.dist-info/RECORD +9 -0
  35. package/python-runtime/python/Lib/site-packages/steerable_sidecar/sidecar.py +545 -6
  36. package/python-runtime/python/Lib/site-packages/steerable_sidecar-0.10.0.dist-info/METADATA +97 -0
  37. package/python-runtime/python/Lib/site-packages/{steerable_sidecar-0.9.1.dist-info → steerable_sidecar-0.10.0.dist-info}/RECORD +12 -12
  38. package/python-runtime/python/Lib/site-packages/uvicorn-0.54.0.dist-info/RECORD +1 -1
  39. package/python-runtime/python/Scripts/cffi-gen-src.exe +0 -0
  40. package/python-runtime/python/Scripts/dotenv.exe +0 -0
  41. package/python-runtime/python/Scripts/httpx.exe +0 -0
  42. package/python-runtime/python/Scripts/idna.exe +0 -0
  43. package/python-runtime/python/Scripts/jsonschema.exe +0 -0
  44. package/python-runtime/python/Scripts/mcp.exe +0 -0
  45. package/python-runtime/python/Scripts/pip.exe +0 -0
  46. package/python-runtime/python/Scripts/pip3.12.exe +0 -0
  47. package/python-runtime/python/Scripts/pip3.exe +0 -0
  48. package/python-runtime/python/Scripts/pywin32_postinstall.exe +0 -0
  49. package/python-runtime/python/Scripts/pywin32_testall.exe +0 -0
  50. package/python-runtime/python/Scripts/steerable-sidecar-acp.exe +0 -0
  51. package/python-runtime/python/Scripts/steerable-sidecar-headless.exe +0 -0
  52. package/python-runtime/python/Scripts/steerable-sidecar.exe +0 -0
  53. package/python-runtime/python/Scripts/uvicorn.exe +0 -0
  54. package/runtime.json +94 -70
  55. package/python-runtime/python/Lib/site-packages/steerable_agent_protocol-0.9.1.dist-info/RECORD +0 -11
  56. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime_native-1.0.7.dist-info/RECORD +0 -9
  57. package/python-runtime/python/Lib/site-packages/steerable_sidecar-0.9.1.dist-info/METADATA +0 -58
  58. /package/python-runtime/python/Lib/site-packages/{steerable_agent_harness-0.9.1.dist-info → steerable_agent_harness-0.10.0.dist-info}/INSTALLER +0 -0
  59. /package/python-runtime/python/Lib/site-packages/{steerable_agent_harness-0.9.1.dist-info → steerable_agent_harness-0.10.0.dist-info}/WHEEL +0 -0
  60. /package/python-runtime/python/Lib/site-packages/{steerable_agent_protocol-0.9.1.dist-info → steerable_agent_protocol-0.10.0.dist-info}/INSTALLER +0 -0
  61. /package/python-runtime/python/Lib/site-packages/{steerable_agent_protocol-0.9.1.dist-info → steerable_agent_protocol-0.10.0.dist-info}/WHEEL +0 -0
  62. /package/python-runtime/python/Lib/site-packages/{steerable_agent_runtime-0.9.1.dist-info → steerable_agent_runtime-0.10.0.dist-info}/INSTALLER +0 -0
  63. /package/python-runtime/python/Lib/site-packages/{steerable_agent_runtime-0.9.1.dist-info → steerable_agent_runtime-0.10.0.dist-info}/WHEEL +0 -0
  64. /package/python-runtime/python/Lib/site-packages/{steerable_agent_runtime_native-1.0.7.dist-info → steerable_agent_runtime_native-1.0.8.dist-info}/INSTALLER +0 -0
  65. /package/python-runtime/python/Lib/site-packages/{steerable_agent_runtime_native-1.0.7.dist-info → steerable_agent_runtime_native-1.0.8.dist-info}/WHEEL +0 -0
  66. /package/python-runtime/python/Lib/site-packages/{steerable_sidecar-0.9.1.dist-info → steerable_sidecar-0.10.0.dist-info}/INSTALLER +0 -0
  67. /package/python-runtime/python/Lib/site-packages/{steerable_sidecar-0.9.1.dist-info → steerable_sidecar-0.10.0.dist-info}/WHEEL +0 -0
  68. /package/python-runtime/python/Lib/site-packages/{steerable_sidecar-0.9.1.dist-info → steerable_sidecar-0.10.0.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
- def _subagent_provider_factory(model: str) -> LLMProvider:
2179
- overridden = {**params, "model": model}
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