From e28475b2b8c6a871a75a64d139da95f810ab5483 Mon Sep 17 00:00:00 2001 From: sadlilas Date: Wed, 26 Aug 2026 13:16:27 -0700 Subject: [PATCH] feat(spawn): bridge delegated sub-agent cost to the parent session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR wires bridge_child_cost into spawn_sub_session so delegated sub-agent spend reaches the parent's session.cost channel. Without it, collect_contributions("session.cost") — and the sessionCostTotal the streaming hook emits from it — reported only the parent's own LLM calls, so a turn that delegated most of its work looked nearly free. A self-review found two problems with the original commit, both now fixed: 1. The original put the bridge_child_cost call in the finally block. amplifier_foundation's own spawn() (amplifier_foundation/bundle/_prepared.py:926-938) bridges inside the try after execute, keeping finally teardown-only. spawn_sub_session is the second implementation of that same capability, so the divergence meant the same delegating turn would report a different session total depending on which engine ran it. The call was moved inside the try, matching foundation. Capturing spend from failed delegations is deliberately dropped — no present need, and it was the only thing the finally placement bought. 2. hook_streaming.py's on_orchestrator_complete docstring claimed sub-agent totals differ "due to how the kernel accumulates contributions across the coordinator hierarchy." That is false — amplifier-core's collect_contributions (crates/amplifier-core/src/coordinator.rs:386) reads only the channels registered on that one coordinator; there is no hierarchy walk. The docstring now states the accurate mechanism: a parent's total includes delegated spend solely because spawn_sub_session calls bridge_child_cost. The commit also drops two stale "OUT OF SCOPE for this MVP — Cost bridging" notes from the module and function docstrings in spawn.py. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- .../bundle/hook_streaming.py | 10 ++++----- src/amplifier_agent_lib/spawn.py | 21 +++++++++++++++---- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/amplifier_agent_lib/bundle/hook_streaming.py b/src/amplifier_agent_lib/bundle/hook_streaming.py index a5a3dbaa..eac9db6b 100644 --- a/src/amplifier_agent_lib/bundle/hook_streaming.py +++ b/src/amplifier_agent_lib/bundle/hook_streaming.py @@ -427,11 +427,11 @@ async def on_orchestrator_complete(self, event: str, data: dict[str, Any]) -> Ho satisfied; the meaningful payload is the cost total). Note: ``sessionCostTotal`` reflects what ``collect_contributions`` - returns, which may differ from summing per-call ``cost`` fields. - Sub-agent sessions can report higher totals due to how the kernel - accumulates contributions across the coordinator hierarchy. This is - a kernel concern (`bridge_child_cost` semantics in foundation), not a - bug in this hook. + returns, which may differ from summing per-call ``cost`` fields. The + kernel does not accumulate across a coordinator hierarchy -- it reads + only the channels registered on this coordinator. A parent's total + includes delegated spend solely because ``spawn_sub_session`` calls + ``bridge_child_cost`` to re-register the child's frozen total here. """ collect = getattr(self._coordinator, "collect_contributions", None) if collect is None: diff --git a/src/amplifier_agent_lib/spawn.py b/src/amplifier_agent_lib/spawn.py index c24f7416..811c40c4 100644 --- a/src/amplifier_agent_lib/spawn.py +++ b/src/amplifier_agent_lib/spawn.py @@ -21,7 +21,6 @@ Explicitly OUT OF SCOPE for this MVP (noted with comments): - Recursive session.spawn on child coordinator (grandchild delegation will fail with the delegate tool's own clear error message; that is acceptable) - - Cost bridging (bridge_child_cost) - Display nesting (push_nesting / pop_nesting) - Provider preference plumbing beyond simple config inclusion - session.resume capability (only spawn, not resume) @@ -346,7 +345,6 @@ async def spawn_sub_session(**kwargs: Any) -> dict[str, Any]: MVP out-of-scope items (marked with # MVP-SKIP comments): - Recursive session.spawn on child coordinator (grandchild delegation fails with delegate tool's own error message — acceptable for MVP) - - Cost bridging (bridge_child_cost) - Display nesting (push_nesting / pop_nesting) - Provider preference plumbing beyond config inclusion @@ -362,7 +360,7 @@ async def spawn_sub_session(**kwargs: Any) -> dict[str, Any]: ``"self"``. """ from amplifier_core import AmplifierSession - from amplifier_foundation import generate_sub_session_id + from amplifier_foundation import bridge_child_cost, generate_sub_session_id # -- Unpack kwargs (matches tool-delegate calling convention) -------- agent_name: str = kwargs["agent_name"] @@ -524,9 +522,24 @@ async def _system_factory() -> str: child_session.coordinator.register_capability("display.emit", parent_display_emit) await mount_streaming_hook(child_session.coordinator, {}) - # -- Execute the task and clean up --------------------------------- + # -- Execute the task, bridge its cost, and clean up ---------------- + # bridge_child_cost reads the child's `session.cost` contributions and + # re-registers the frozen total on the PARENT coordinator, so a delegated + # turn's spend is visible to whoever asks the parent for its cost. It must + # run BEFORE cleanup(), which tears the child coordinator down and takes + # its contributions with it. + # + # Placement matches amplifier_foundation's own spawn(): bridge inside the + # try, `finally` stays teardown-only. A failed delegation's spend is not + # bridged, and that is deliberate -- the two spawn implementations report + # the same number for the same delegation. try: response = await child_session.execute(instruction) + await bridge_child_cost( + child_session.coordinator, + parent_session.coordinator, + sub_session_id, + ) finally: await child_session.cleanup()