Skip to content

Timeline: require a tool-calling loop for utility-agent classification#4516

Open
dragonstyle wants to merge 5 commits into
mainfrom
fix/utility-agent-classification
Open

Timeline: require a tool-calling loop for utility-agent classification#4516
dragonstyle wants to merge 5 commits into
mainfrom
fix/utility-agent-classification

Conversation

@dragonstyle

Copy link
Copy Markdown
Collaborator

This PR contains:

  • New features
  • Changes to dev-tools e.g. CI config / github tooling
  • Docs
  • Bug fixes
  • Code refactor

What is the current behavior? (You can also link to an open issue here)

Transcript timelines classify model calls as hidden "utility agents" using a weak heuristic: the first system prompt seen defines the "main trajectory", and any no-tool-call model event with a different prompt is wrapped as a utility span (hidden by default in the viewer). In a plain workflow of generate() calls with mixed system prompts — monitors, judges, graders — this silently hides every call after the first prompt group (reported on Slack: 5 checker calls, only 2 rendered). There is also no indication anywhere in the UI that events were hidden.

Additionally, _wrap_utility_events recursed into the synthetic wrapper spans it creates, producing infinite recursion (RecursionError) on any transcript containing a cache-warmup call (max_tokens=1, single-word user message).

What is the new behavior?

  • Utility classification requires an agentic (tool-calling) loop: _wrap_utility_events derives the primary prompt solely from the first tool-calling ModelEvent, and _classify_utility_agents only demotes single-turn sub-spans whose parent runs a tool loop. Workflows of plain generate() calls render every call.
  • The viewer's timeline header shows a small "N utility agent(s) hidden" indicator next to the options button whenever utility spans are elided; clicking it reveals them (ships via the ts-mono submodule, PR below).
  • The warmup-recursion crash is fixed (recursion visits only original child spans), with a regression fixture.
  • Python/TS port-parity fixes in the functions this PR touches: span-level prompt comparison now normalizes the x-anthropic-billing-header line; prompts that normalize to empty are treated as absent; branch direct content is processed like any span's; uuid-less wrapped events get unique deterministic wrapper ids.
  • Utility-classification tests are now defined once as JSON fixtures in tests/timeline/fixtures/events/ ({description, events, expected}) and validated by both timeline_build() (new tests/timeline/test_timeline_fixtures.py) and ts-mono's buildTimeline() (its fixture suite discovers this directory). 8 fixtures cover the gate, warmup wrapping, billing-header normalization, empty prompts, and branch content.
  • CI: new viewer-tests job in log_viewer.yml runs the inspect-components vitest suite in this embedding — the fixture-driven TS tests are skipped in ts-mono's standalone CI, so previously they would have run nowhere.

Does this PR introduce a breaking change? (What changes might users need to make in their application due to this PR?)

No API changes. Behavior change: transcripts whose "main loop" never calls tools (e.g. chat-only bridge sessions with interleaved extraction calls) no longer hide those extraction calls — with no tool loop the heuristic has no basis for declaring a primary trajectory, and showing everything is the safe default.

Other information:

Companion PRs:

The submodule pointer and regenerated dist are intentionally not included; they follow after the ts-mono PR merges. Verified all 50 of inspect_scout's fixtures pass against the new TS logic, and scout's full Python timeline suite (84 tests) passes against this branch.

🤖 Generated with Claude Code

dragonstyle and others added 5 commits July 15, 2026 14:08
The utility heuristics in timeline_build (foreign-prompt event wrapping and
single-turn span classification) assumed a "primary trajectory" defined by
the first system prompt seen. In plain workflows of generate() calls — e.g.
a monitor making several checker calls with different prompts and no tools —
this hid every call after the first prompt group as a utility agent.

Both passes now only apply when the span actually contains an agentic
(tool-calling) loop: _wrap_utility_events derives the primary prompt solely
from the first tool-calling ModelEvent (no fallback to the first event),
and _classify_utility_agents only demotes single-turn sub-spans whose
parent runs a tool loop. Warmup-call wrapping is unchanged.

Adds workflow_generate (mixed-prompt calls, no tools → nothing hidden) and
bridge_utility (foreign-prompt call within a tool loop → still utility)
scenarios. Mirrors the same fix in ts-mono's core.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Move the utility-classification test cases into JSON fixtures under
tests/timeline/fixtures/events/ ({description, events, expected}) so a
single definition drives both timeline implementations: the new
test_timeline_fixtures.py validates timeline_build() against them, and
ts-mono's fixtureTimeline.test.ts discovers the same directory to validate
buildTimeline(). The scenario-based duplicates (workflow_generate,
bridge_utility) are removed from generate.py/test_timeline.py, and two
additional cases cover single-turn sub-agent classification with and
without a parent tool-calling loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two fixes from code review:

- _wrap_utility_events recursed into the synthetic wrapper spans it had
  just created; a wrapper's warmup call was wrapped again inside it,
  forever (RecursionError on any transcript with a cache-priming call).
  Recursion now visits only the span's original children. Also skips
  system-prompt extraction when there is no primary prompt to compare
  against. Adds a utility_warmup fixture covering warmup wrapping (which
  is prompt-independent and applies without an agentic loop).

- log_viewer.yml gains a viewer-tests job running the inspect-components
  vitest suite: the fixture-driven timeline tests consume this repo's
  fixtures and are skipped in ts-mono's standalone CI, so without this
  job they ran nowhere.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four parity fixes between timeline_build and ts-mono's buildTimeline, each
now covered by a shared JSON fixture (or unit test where the fixture
format can't express the case):

- _get_system_prompt (classification) now delegates to
  _get_system_prompt_for_event, so billing-header normalization applies to
  span-level prompt comparison (previously raw prompts were compared,
  classifying Claude Code sub-agents differently than the viewer).

- Prompt extraction returns None instead of "" when a prompt is empty
  after normalization (e.g. only the billing header line), keeping empty
  prompts out of comparisons and prompt inheritance.

- _wrap_utility_events processes a branch's own direct events (branches
  are trajectories like any span), matching the TS port.

- Wrapper spans for uuid-less (legacy) events get unique deterministic
  position-derived ids (utility-<span>-<index>) instead of id(item).

Fixtures: utility_billing_header, utility_header_only_prompt,
utility_branch_warmup (the fixture harness gains branch assertions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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