Skip to content

DEV-1616: roll the v1 discovery sub-agent's turns/tokens/cost into the task total - #71

Merged
ZmeiGorynych merged 2 commits into
mainfrom
egor/dev-1616-roll-up-the-v1-discovery-sub-agents-turns-tokens-cost-into
Jul 1, 2026
Merged

DEV-1616: roll the v1 discovery sub-agent's turns/tokens/cost into the task total#71
ZmeiGorynych merged 2 commits into
mainfrom
egor/dev-1616-roll-up-the-v1-discovery-sub-agents-turns-tokens-cost-into

Conversation

@ZmeiGorynych

@ZmeiGorynych ZmeiGorynych commented Jul 1, 2026

Copy link
Copy Markdown
Member

Problem

The two-stage claude_sdk_*_v1 agents delegate schema/entity discovery to a warm second ClaudeSDKClient reached via the in-process ask_discovery tool (discovery_runtime.run_main_with_discovery + discovery_channel.DiscoveryChannel). The headline n_agent_turns undercounted the real cost: it was backfilled from the MAIN client's trajectory only, so discovery turns were off-book — and that backfill counted block-level AssistantMessage events (the live SDK emits one per content block), not dedup'd turns.

Tokens / cost / n_calls were verified to already include discovery: each ask builds a fresh SdkUsageTracker(accum, model) with the default scope="agent", so its usage lands in the same agent::<model> breakdown row and in agent_cost_usd.

Fix

  • SdkUsageTracker.committed_n_calls — the exact n_calls a tracker commits to its breakdown row (max(1, turns) with a terminal ResultMessage).
  • DiscoveryChannel.turns — sums each ask's committed turn count across every path (success / timeout / error), so partial asks still contribute.
  • DiscoveryRollup + run_main_with_discovery fills it in the finally (crash-safe) from the channel; docstring corrected.
  • finalize_result_rown_agent_turns is now the scope=="agent" breakdown sum (dedup'd turns, inclusive of discovery) for claude_sdk-shaped rows. Gated so agno/smolagents (which also write scope="agent" usage but emit no AssistantMessage trajectory entries) keep their trajectory-derived count. n_discovery_turns defaults to 0.
  • The 4 v1 agents create/pass DiscoveryRollup and surface usage["n_discovery_turns"] on both success and exception paths.

This makes v0/v1/v2 turn counts apples-to-apples (the block over-count is fixed for every claude_sdk agent, not just v1).

Scope note

n_discovery_turns rides in per-row usage_json (like n_ask_user_calls); it is not a TokenUsage field, so run-level total_usage aggregation does not sum it — per-task is the contract.

Tests

New tests/test_dev1616_discovery_usage_rollup.py (tracker committed_n_calls; a real-tracker run_main_with_discovery integration covering the turn/token/cost rollup, per-ask-not-cumulative parity, and the crash path) plus additions to the channel, finalize (incl. the agno/smolagents non-derivation regression), and all four v1-agent test files. Full non-integration suite: 3825 passed, 94 skipped.

Plan and tests were adversarially reviewed by Codex in two rounds; all findings folded in.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added warm-discovery turn/call rollups to task usage summaries, including a new discovery-turn total surfaced in results (including on error/timeout).
    • Discovery turn counts now accumulate across multiple discovery requests.
  • Bug Fixes

    • Agent turn counts now reflect deduplicated logical turns instead of raw assistant-message blocks.
    • Finalized usage and backfilled telemetry are more robust: discovery turns default to 0 when missing, and n_agent_turns is derived correctly even when trajectories are empty.
  • Tests

    • Added coverage for discovery rollups and turn-count derivation across success and crash paths.

…e task total

The two-stage claude_sdk_*_v1 agents delegate discovery to a warm second
ClaudeSDKClient reached via ask_discovery. Discovery tokens/cost/n_calls
already flowed into the shared accum (each ask builds a fresh
SdkUsageTracker with the default scope="agent"), but the headline
n_agent_turns was backfilled from the MAIN client's trajectory only — so
discovery turns were off-book — AND that backfill counted block-level
AssistantMessage events, not dedup'd turns.

Fix:
- SdkUsageTracker.committed_n_calls: the exact turn count a tracker commits
  to its agent::<model> breakdown row (max(1, turns) with a ResultMessage).
- DiscoveryChannel.turns: sums each ask's committed count across all paths
  (success / timeout / error), so partial asks still contribute.
- discovery_runtime.DiscoveryRollup + run_main_with_discovery fills it in the
  finally (crash-safe) from the channel; docstring corrected.
- finalize_result_row: n_agent_turns is now the scope=="agent" breakdown sum
  (dedup'd turns, inclusive of discovery) for claude_sdk-shaped rows — gated
  so agno/smolagents (which also write scope="agent" usage but emit no
  AssistantMessage entries) keep their trajectory count. n_discovery_turns
  defaults to 0.
- The 4 v1 agents create/pass DiscoveryRollup and surface
  usage["n_discovery_turns"] on both success and exception paths.

n_discovery_turns rides in per-row usage_json (like n_ask_user_calls); it is
not a TokenUsage field, so run-level total_usage aggregation does not sum it
(per-task is the contract).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@linear

linear Bot commented Jul 1, 2026

Copy link
Copy Markdown
DEV-1616 Roll up the v1 discovery sub-agent's turns / tokens / cost into the task total usage

Problem

The two-stage v1 agents (claude_sdk_v1 / claude_sdk_otf_ainteract_v1) delegate schema/entity discovery to an ask_discovery sub-agent running in a separate ClaudeSDKClient (see agents/claude_sdk/discovery_runtime.py::run_main_with_discovery). The per-task usage metrics almost certainly do not fully account for that sub-agent's work — so v1's reported n_agent_turns (and likely n_calls, and possibly token/cost fields) undercount the real cost.

Evidence

  • n_agent_turns is len(SdkUsageTracker._turn_order) (agents/claude_sdk/agent.py:240), and _turn_order is appended only in observe() on AssistantMessage.
  • In run_main_with_discovery, only the main client's stream is fed to usage_tracker.observe(msg) (the receive loop). The discovery client is wired with a separate usage_accum=accum path, not through usage_tracker.
  • So the discovery sub-agent's turns are off-book in n_agent_turns, even though the docstring claims "both clients' usage flows into the SAME accum." Tokens may be partially captured via accum; turns/calls are the clear gap, and token/cost parity needs verifying too.

Why it matters

  • Cross-version comparisons are apples-to-oranges. A v1 (two-stage) run looks artificially cheap in turns vs a v0/v2 single-agent run, because the discovery turns are hidden. We hit this directly comparing households_5 across v2 (single-agent, 28 turns — genuinely all main-loop) and v1/v3 (two-stage), where the v1 number would omit discovery turns.
  • Any turn-budget / cost analysis (e.g. patience tuning) built on v1 numbers is skewed.

Fix

Ensure the discovery client's usage is fully rolled into the task total:

  • Turns: discovery AssistantMessage turns counted into n_agent_turns (or a clearly-labelled n_discovery_turns + a combined total — decide which, but the headline total must include them).
  • Calls / tokens / cost: verify n_calls, prompt/completion/cache tokens, agent_cost_usd all include discovery. Audit the usage_accum=accum path end-to-end.
  • Consider surfacing a main-vs-discovery breakdown for observability (the existing usage.breakdown field is a natural home).
  • Add a test asserting a two-stage run's reported turns/tokens ≥ the main-loop-only counts by the discovery contribution (e.g. via a stubbed discovery that emits N known turns).

Scope

  • Affects v1 (clean claude_sdk_v1 on main) AND v3 (this branch's modified claude_sdk_v1) — so branch off main.
  • Single-agent v0/v2 (claude_sdk) are unaffected (no sub-agent).

Review in Linear

@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a6c2f74a-98a9-41cb-b94a-5dfa648824b4

📥 Commits

Reviewing files that changed from the base of the PR and between 04a5f27 and c157dc7.

📒 Files selected for processing (3)
  • src/bird_interact_agents/harness.py
  • tests/test_dev1616_discovery_usage_rollup.py
  • tests/test_finalize_result_row.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/bird_interact_agents/harness.py
  • tests/test_dev1616_discovery_usage_rollup.py
  • tests/test_finalize_result_row.py

📝 Walkthrough

Walkthrough

This PR adds committed-call tracking, cumulative discovery-turn accounting, rollup propagation into agent usage, harness backfills for turn counts, and test coverage for success and crash paths.

Changes

Discovery Turn/Usage Rollup

Layer / File(s) Summary
Committed call tracking
src/bird_interact_agents/agents/claude_sdk/agent.py
SdkUsageTracker adds committed_n_calls backed by _committed_n_calls, set during _commit(...).
Discovery turn accumulation
src/bird_interact_agents/agents/claude_sdk/discovery_channel.py, tests/test_dev1581_discovery_channel.py
DiscoveryChannel gains a turns property backed by _turns_total, incremented after each ask() from committed_n_calls; tests cover accumulation, capping, partial failures, and timeouts.
DiscoveryRollup model and runtime wiring
src/bird_interact_agents/agents/claude_sdk/discovery_runtime.py
New DiscoveryRollup model with n_discovery_turns/n_discovery_calls; run_main_with_discovery accepts an optional discovery_rollup and populates it in the finally block, including crash paths.
Agent wiring across implementations
src/bird_interact_agents/agents/claude_sdk_otf_ainteract_raw_v1/agent.py, src/bird_interact_agents/agents/claude_sdk_otf_ainteract_v1/agent.py, src/bird_interact_agents/agents/claude_sdk_otf_raw_v1/agent.py, src/bird_interact_agents/agents/claude_sdk_otf_v1/agent.py
Each agent instantiates DiscoveryRollup, passes it to run_main_with_discovery, and adds n_discovery_turns to usage on success and exception paths.
Harness backfill logic
src/bird_interact_agents/harness.py, tests/test_finalize_result_row.py
finalize_result_row overrides trajectory-derived n_agent_turns with deduplicated agent-scope breakdown counts when positive, and defaults usage.n_discovery_turns to 0.
End-to-end and per-agent test coverage
tests/test_dev1616_discovery_usage_rollup.py, tests/test_claude_sdk_otf_*_agent.py
New tests validate committed_n_calls, discovery rollup propagation on success/crash paths, and per-agent deduplicated n_agent_turns counting.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Possibly related PRs

  • MotleyAI/bird-agents#56: Both PRs modify the shared warm-discovery flow around DiscoveryChannel and run_main_with_discovery usage accounting.
  • MotleyAI/bird-agents#57: Both PRs modify SdkUsageTracker in src/bird_interact_agents/agents/claude_sdk/agent.py at the _commit/breakdown level.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.53% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: folding the v1 discovery sub-agent's turns, tokens, and cost into the task total.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (5)
tests/test_dev1616_discovery_usage_rollup.py (3)

246-250: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Minor: unused unpacked variable trajectory.

Ruff (RUF059) flags trajectory as unpacked but never used in this test.

♻️ Proposed fix
-    accum, trajectory, rollup, disc = await _drive(
+    accum, _trajectory, rollup, disc = await _drive(
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_dev1616_discovery_usage_rollup.py` around lines 246 - 250, The
test unpacks trajectory in test_rollup_includes_discovery_turns_tokens_and_calls
but never uses it, triggering Ruff RUF059. Remove the unused unpacked variable
from the _drive(...) assignment or replace it with an ignored placeholder,
keeping the rest of the assertions and the accum/rollup/disc symbols unchanged.

Source: Linters/SAST tools


105-113: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Minor: prefer next() over single-element list slice.

Static analysis (RUF015) flags this pattern.

♻️ Proposed fix
-    assert [r for r in accum.breakdown if r.scope == "agent"][0].n_calls == 1
+    assert next(r for r in accum.breakdown if r.scope == "agent").n_calls == 1
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_dev1616_discovery_usage_rollup.py` around lines 105 - 113, The
test in SdkUsageTracker should avoid building a list just to grab the first
matching breakdown entry, since static analysis flags that as inefficient.
Update the assertion in test_committed_n_calls_result_only_is_one to use next()
over the TokenUsage.breakdown iterator with the same scope filter, while still
verifying the agent n_calls value remains 1.

Source: Linters/SAST tools


306-345: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Minor: prefer next() over single-element list slice.

Same RUF015 pattern as the earlier test.

♻️ Proposed fix
-    assert [r for r in accum.breakdown if r.scope == "agent"][0].prompt_tokens == 80
+    assert next(r for r in accum.breakdown if r.scope == "agent").prompt_tokens == 80
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_dev1616_discovery_usage_rollup.py` around lines 306 - 345, The
test in `test_crash_path_still_reports_discovery_turns` uses a single-element
list comprehension and index access to get the first agent breakdown entry;
replace that pattern with `next()` to match the existing RUF015 style used
elsewhere. Keep the assertion against `accum.breakdown` and `scope == "agent"`
intact, but fetch the matching item via `next(...)` so the test is consistent
and avoids the single-element slice pattern.

Source: Linters/SAST tools

tests/test_claude_sdk_otf_v1_agent.py (1)

664-737: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

LGTM! Correctly validates that DiscoveryRollup values survive both the success and crash-after-discovery paths, with n_agent_turns reflecting main+discovery.

Optional: mirror this rollup-wiring coverage on the other three v1 agent flavors.

Only claude_sdk_otf_v1 gets this deeper success/exception rollup-surfacing test; claude_sdk_otf_raw_v1, claude_sdk_otf_ainteract_v1, and claude_sdk_otf_ainteract_raw_v1 only have the turn-counting test. Since all four wire DiscoveryRollup through nearly identical run_task shapes, the same _fake_rmwd_factory pattern could be reused for parity.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_claude_sdk_otf_v1_agent.py` around lines 664 - 737, Add the same
DiscoveryRollup surfacing coverage to the other v1 agent variants for parity.
Reuse the existing _fake_rmwd_factory pattern and extend the run_task tests in
claude_sdk_otf_raw_v1, claude_sdk_otf_ainteract_v1, and
claude_sdk_otf_ainteract_raw_v1 so they verify n_discovery_turns and
n_agent_turns on both the success path and the crash-after-discovery path,
matching the behavior already asserted in ClaudeSDKOtfAgent.
tests/test_claude_sdk_otf_ainteract_raw_v1_agent.py (1)

628-664: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Optional: dedupe _shared_turn_msgs() across the four v1 agent test files.

This helper is copy-pasted identically into test_claude_sdk_otf_ainteract_v1_agent.py, test_claude_sdk_otf_raw_v1_agent.py, and test_claude_sdk_otf_v1_agent.py. Consider hoisting it into a shared test fixture/module to avoid drift across the four flavors.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_claude_sdk_otf_ainteract_raw_v1_agent.py` around lines 628 - 664,
The `_shared_turn_msgs()` helper is duplicated across the v1 agent test files,
so consolidate it into a shared test fixture or utility module and have each
test file import/use that single helper instead of maintaining four copies. Keep
the existing behavior of the `AssistantMessage`/usage stub intact, and update
the tests that reference `_shared_turn_msgs()` to use the shared definition so
the agent test variants stay in sync.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@tests/test_claude_sdk_otf_ainteract_raw_v1_agent.py`:
- Around line 628-664: The `_shared_turn_msgs()` helper is duplicated across the
v1 agent test files, so consolidate it into a shared test fixture or utility
module and have each test file import/use that single helper instead of
maintaining four copies. Keep the existing behavior of the
`AssistantMessage`/usage stub intact, and update the tests that reference
`_shared_turn_msgs()` to use the shared definition so the agent test variants
stay in sync.

In `@tests/test_claude_sdk_otf_v1_agent.py`:
- Around line 664-737: Add the same DiscoveryRollup surfacing coverage to the
other v1 agent variants for parity. Reuse the existing _fake_rmwd_factory
pattern and extend the run_task tests in claude_sdk_otf_raw_v1,
claude_sdk_otf_ainteract_v1, and claude_sdk_otf_ainteract_raw_v1 so they verify
n_discovery_turns and n_agent_turns on both the success path and the
crash-after-discovery path, matching the behavior already asserted in
ClaudeSDKOtfAgent.

In `@tests/test_dev1616_discovery_usage_rollup.py`:
- Around line 246-250: The test unpacks trajectory in
test_rollup_includes_discovery_turns_tokens_and_calls but never uses it,
triggering Ruff RUF059. Remove the unused unpacked variable from the _drive(...)
assignment or replace it with an ignored placeholder, keeping the rest of the
assertions and the accum/rollup/disc symbols unchanged.
- Around line 105-113: The test in SdkUsageTracker should avoid building a list
just to grab the first matching breakdown entry, since static analysis flags
that as inefficient. Update the assertion in
test_committed_n_calls_result_only_is_one to use next() over the
TokenUsage.breakdown iterator with the same scope filter, while still verifying
the agent n_calls value remains 1.
- Around line 306-345: The test in
`test_crash_path_still_reports_discovery_turns` uses a single-element list
comprehension and index access to get the first agent breakdown entry; replace
that pattern with `next()` to match the existing RUF015 style used elsewhere.
Keep the assertion against `accum.breakdown` and `scope == "agent"` intact, but
fetch the matching item via `next(...)` so the test is consistent and avoids the
single-element slice pattern.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 16e9b6d6-5dce-4fe6-83c1-afb835e40123

📥 Commits

Reviewing files that changed from the base of the PR and between 9f8031d and 04a5f27.

📒 Files selected for processing (15)
  • src/bird_interact_agents/agents/claude_sdk/agent.py
  • src/bird_interact_agents/agents/claude_sdk/discovery_channel.py
  • src/bird_interact_agents/agents/claude_sdk/discovery_runtime.py
  • src/bird_interact_agents/agents/claude_sdk_otf_ainteract_raw_v1/agent.py
  • src/bird_interact_agents/agents/claude_sdk_otf_ainteract_v1/agent.py
  • src/bird_interact_agents/agents/claude_sdk_otf_raw_v1/agent.py
  • src/bird_interact_agents/agents/claude_sdk_otf_v1/agent.py
  • src/bird_interact_agents/harness.py
  • tests/test_claude_sdk_otf_ainteract_raw_v1_agent.py
  • tests/test_claude_sdk_otf_ainteract_v1_agent.py
  • tests/test_claude_sdk_otf_raw_v1_agent.py
  • tests/test_claude_sdk_otf_v1_agent.py
  • tests/test_dev1581_discovery_channel.py
  • tests/test_dev1616_discovery_usage_rollup.py
  • tests/test_finalize_result_row.py

…eview)

Codex review: a crash after some ask_discovery calls but before any MAIN
AssistantMessage was recorded leaves the main trajectory empty, so the
traj_turns>0 gate skipped the breakdown derivation and n_agent_turns stayed
0 — despite discovery turns being in the agent-scope breakdown and a positive
usage.n_discovery_turns. Also derive from the breakdown when n_discovery_turns
> 0 (a claude_sdk-only signal), keeping the headline inclusive of discovery.
Regression test added.

CodeRabbit nitpick: rename the unused unpacked `trajectory` to `_trajectory`
in the rollup test (RUF059).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ZmeiGorynych
ZmeiGorynych merged commit 9892f76 into main Jul 1, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant