Skip to content

Flag chat-only completions (zero tool calls with model output) as a diagnostic - #1025

Open
ljr145733 wants to merge 3 commits into
benchflow-ai:mainfrom
ljr145733:fix-988-chat-only-diagnostic
Open

Flag chat-only completions (zero tool calls with model output) as a diagnostic#1025
ljr145733 wants to merge 3 commits into
benchflow-ai:mainfrom
ljr145733:fix-988-chat-only-diagnostic

Conversation

@ljr145733

Copy link
Copy Markdown
Contributor

Fixes #988 — implements the design proposed in my comment there. Opening the PR now so the semantics discussion can happen on concrete code rather than in the abstract; happy to rework any of it if maintainers prefer a different call.

Problem

A rollout where the agent produced real model output but ended its turn without a single tool call was recorded as a clean scored fail — error: null, reward 0.0 from the verifier — indistinguishable in aggregates from a genuine attempt. The existing zero-signal net (suspected_api_error) only covers the zero-token half of the no-op space; the reported rollout had 2,727 output tokens ("Proceeding to add PLAN.md…") and fell through.

Semantics (the key design decision)

A chat-only completion is agent/model behavior, not infrastructure failure — so unlike suspected_api_error this diagnostic leaves reward and error untouched: the slot stays a scored fail and stays in score denominators. It only becomes visible. This matches the intent already documented in test_zero_tools_with_tokens_not_flagged ("Prompt-only answer (no tools) with real usage is a legitimate rollout") — legitimate for the error classifier, but worth surfacing for sweep forensics.

What this adds

Open question from the issue thread

The tool-telemetry guard currently reuses uses_native_subscription_auth() (option (a) from my comment) — symmetric with _maybe_classify_api_error, but over-broad for ACP adapters that do report tool_call events under subscription auth. If you'd rather gate on a per-adapter tool-telemetry capability flag (option (b)), I'm happy to do that here or as a follow-up — the detection site has a TODO marking the swap point.

Testing

  • New tests/test_no_tool_completion.py (13 tests): core semantics (flag set, reward/error untouched), one negative test per guard, precedence (zero-token case still routes to suspected_api_error), registry integrity, format_issue rendering.
  • Adjacent suites pass unchanged: test_api_error_capture.py (30), test_diagnostics.py + test_eval_single_task_summary.py (17), test_rollout_architecture.py + test_integration_check_results.py + test_rollout_import_no_side_effects.py (69). ruff check clean on all touched files.

Disclosure: Claude assisted with the implementation and tests under my direction; I reviewed and take responsibility for the design, the semantics, and all code.

…iagnostic

A rollout where the agent produced real output (tokens, agent messages)
but ended without a single tool call was recorded as a clean scored fail,
indistinguishable in aggregates from a genuine attempt (benchflow-ai#988). The
existing zero-signal net (suspected_api_error) only covers the
zero-token half of the no-op space.

Add NoToolCallCompletionDiagnostic: visibility only — reward and error
stay untouched (chat-only completion is agent behavior, not
infrastructure failure). Detection reuses the established guards
(_executed_prompts, oracle exclusion, the native-subscription
tool-telemetry exemption from PR benchflow-ai#886) and additionally requires at
least one agent_message trajectory event so trajectory-capture loss
(benchflow-ai#982) is not misflagged. Surfaces in result.json
(no_tool_call_completion_info), the per-rollout CLI line (", no-op"),
summary.json (no_tool_call_completions), and the registry-driven job
summary warning, which now counts category-less diagnostics by field
presence.

Fixes benchflow-ai#988
@Galius5136

Copy link
Copy Markdown

Thanks for putting this together! I tested the PR end to end and the per-rollout diagnostic looks good on the #988 shape: the flag is set, while reward/error stay unchanged.

I did hit one issue at the aggregate level though. On a fresh bench eval run, the chat-only rollout had no_tool_call_completion_info populated, but summary.json still reported no_tool_call_completions: 0 and no job-summary warning was shown. Re-running the same job from disk, with the same artifacts and 0 tasks re-executed, changed the count to 1 and emitted the warning. It looks like fresh results go through rollout_result_payload(), which currently doesn't carry no_tool_call_completion_info, while resumed results are read back from result.json.

I also noticed a narrower edge case in the Gemini scraped-trajectory fallback: the saved trajectory can contain tool calls while _n_tool_calls remains 0, so the new diagnostic can say “made 0 tool calls” next to trajectory_summary.tool_call_steps: 3. I only reproduced this by forcing the salvage precondition in a probe, so I can't say how common it is in real runs.

Small cleanup: ruff format --check src tests tools currently fails on the PR head (base passes) for the new formatting in evaluation.py and rollout/__init__.py.

Hope this helps, happy to re-test if you push a fix.

…rd, formatting

Review findings from PR benchflow-ai#1025 (thanks @Galius5136):

1. Fresh runs build summary rows from the in-memory RolloutResult via
   rollout_result_payload(), which carries no diagnostic payloads — so
   no_tool_call_completions read 0 on a fresh job and only became correct
   on resume, when rows are read back from result.json. Generalize the
   benchflow-ai#501 persisted-timing enrichment into
   _enrich_payload_with_persisted_fields(), which now also copies every
   DIAGNOSTIC_REGISTRY field from the persisted result.json, making fresh
   and resumed aggregation identical.

2. Salvage paths (the gemini scraped-trajectory fallback) rebuild
   tool_call events the ACP session never counted, so _n_tool_calls can
   be 0 while the trajectory shows real tool activity. The detection now
   treats the trajectory as authoritative: any tool_call event in it
   disqualifies the chat-only flag.

3. ruff format on evaluation.py and rollout/__init__.py; the full
   'ruff format --check src tests tools' sweep now passes.

New tests: scraped-trajectory negative case; enrichment copies diagnostic
fields, skips nulls, never overwrites in-memory values, and stays silent
when result.json is absent.
@ljr145733

ljr145733 commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough end-to-end test — all three findings were real. I really appreciate it. They should be fixed in 01bb6bc:

  1. Fresh-run aggregation: rollout_result_payload() is RolloutResult-driven and never carried the diagnostic payloads, so summary.json only counted them after a resume re-read result.json from disk. Rather than special-casing the new field, I generalized the existing summary.json omits aggregate tool-call and phase-timing metrics #501 persisted-timing enrichment into _enrich_payload_with_persisted_fields(), which now copies every DIAGNOSTIC_REGISTRY field from the persisted result.json into fresh-run payloads — fresh and resumed jobs now aggregate identically, for this diagnostic and any future one. Covered by three new tests (fields copied, nulls skipped, in-memory values never overwritten).

  2. Scraped-trajectory fallback: confirmed — the salvage path rebuilds tool_call events the ACP session never counted, so _n_tool_calls can be 0 next to trajectory_summary.tool_call_steps > 0. The detection now treats the trajectory as authoritative: any tool_call event in it disqualifies the chat-only flag. Negative test added with exactly that shape.

  3. Formatting: reproduced and fixed; ruff format --check src tests tools is clean on the new head.

Would appreciate a re-test if you have the time — the fresh-run repro you described should now show no_tool_call_completions: 1 and the job-summary warning on the first pass.

@Galius5136

Copy link
Copy Markdown

Re-tested on 01bb6bc and all three look fixed now. Thanks for the quick turnaround!

Fresh-run aggregation now matches resume on the same artifacts, the scraped-trajectory case no longer gets the chat-only diagnostic, and ruff format --check src tests tools is clean.

One small test-coverage note: I tried removing the new enrichment call site and the current test suite still stays green, so it may be worth adding an Evaluation-level regression test for that wiring. Nothing blocking from my side though, happy with the fixes :)

Review follow-up on PR benchflow-ai#1025: removing the
_enrich_payload_with_persisted_fields() call site left the suite green,
because the existing tests exercise the helper in isolation. This test
runs a real Evaluation.run() over a mocked rollout that persists
no_tool_call_completion_info only to its on-disk result.json — exactly
the fresh-run shape — and asserts summary.json counts it on the first
pass. Verified red with the call site removed, green with it present.
@ljr145733

Copy link
Copy Markdown
Contributor Author

Good catch on the coverage gap — you're right that the enrichment tests exercised the helper in isolation, so the call-site wiring itself was unguarded. Added in c378761: an Evaluation.run()-level regression test where a mocked rollout persists no_tool_call_completion_info only to its on-disk result.json (the exact fresh-run shape) and the test asserts summary.json counts it on the first pass. Verified it goes red with the call site removed and green with it present. Thanks again for the careful testing!

@Galius5136

Galius5136 commented Aug 17, 2026

Copy link
Copy Markdown

Confirmed the new test goes red without the call site. Thanks, looks good to me!

ljr145733 added a commit to ljr145733/benchflow that referenced this pull request Aug 17, 2026
All five findings from the review pass:

- `bench eval view --help` no longer exits 1: the help text carried
  "[/subpath]", which Rich parsed as a closing markup tag; rephrased
  without brackets.
- `ruff format --check src tests tools` passes (the new viewer test file
  was unformatted).
- The browse-mode run cap is no longer silent: the sidebar heading says
  "first N runs (capped)" when truncation happened (detected by scanning
  cap+1, not by len==cap), a `?run=` pointing at an undiscovered id shows
  an explicit load error instead of silently rendering the first run, and
  BENCHFLOW_VIEWER_MAX_RUNS overrides the 500 default.
- The two security regression tests now pin enforcement (mutation-killing):
  the script-breakout test asserts the raw `</script><script>` sequence
  appears nowhere in the emitted page (removing the escape fails it), and
  a new whitelist test resolves ids through the extracted
  `_resolve_browse_rollout` helper against a real rollout placed outside
  the served base (removing the membership check fails it).
- Diagnostic banner keys derive from `DIAGNOSTIC_REGISTRY` instead of a
  hand-copied list (static fallback kept for lenient imports), so new
  diagnostics like benchflow-ai#1025's chat-only flag appear without drift — and
  diagnostics on an otherwise-clean rollout (no error/verifier_error)
  render as neutral info banners rather than red error banners.

Co-Authored-By: Claude Code <noreply@anthropic.com>
bingran-you pushed a commit to ljr145733/benchflow that referenced this pull request Aug 27, 2026
All five findings from the review pass:

- `bench eval view --help` no longer exits 1: the help text carried
  "[/subpath]", which Rich parsed as a closing markup tag; rephrased
  without brackets.
- `ruff format --check src tests tools` passes (the new viewer test file
  was unformatted).
- The browse-mode run cap is no longer silent: the sidebar heading says
  "first N runs (capped)" when truncation happened (detected by scanning
  cap+1, not by len==cap), a `?run=` pointing at an undiscovered id shows
  an explicit load error instead of silently rendering the first run, and
  BENCHFLOW_VIEWER_MAX_RUNS overrides the 500 default.
- The two security regression tests now pin enforcement (mutation-killing):
  the script-breakout test asserts the raw `</script><script>` sequence
  appears nowhere in the emitted page (removing the escape fails it), and
  a new whitelist test resolves ids through the extracted
  `_resolve_browse_rollout` helper against a real rollout placed outside
  the served base (removing the membership check fails it).
- Diagnostic banner keys derive from `DIAGNOSTIC_REGISTRY` instead of a
  hand-copied list (static fallback kept for lenient imports), so new
  diagnostics like benchflow-ai#1025's chat-only flag appear without drift — and
  diagnostics on an otherwise-clean rollout (no error/verifier_error)
  render as neutral info banners rather than red error banners.

Co-Authored-By: Claude Code <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.

Zero-tool-call rollouts are recorded as clean completions, silently biasing baseline sweeps

2 participants