diff --git a/src/amplifier_agent_lib/bundle/hook_streaming.py b/src/amplifier_agent_lib/bundle/hook_streaming.py index a5a3dba..eac9db6 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 c24f741..811c40c 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()