Flag chat-only completions (zero tool calls with model output) as a diagnostic - #1025
Flag chat-only completions (zero tool calls with model output) as a diagnostic#1025ljr145733 wants to merge 3 commits into
Conversation
…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
|
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 I also noticed a narrower edge case in the Gemini scraped-trajectory fallback: the saved trajectory can contain tool calls while Small cleanup: 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.
|
Thanks for the thorough end-to-end test — all three findings were real. I really appreciate it. They should be fixed in 01bb6bc:
Would appreciate a re-test if you have the time — the fresh-run repro you described should now show |
|
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.
|
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 |
|
Confirmed the new test goes red without the call site. Thanks, looks good to me! |
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>
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>
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_errorthis 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 intest_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
NoToolCallCompletionDiagnostic(diagnostics.py), fieldno_tool_call_completion_info,category = None— registered, so it lands in result.json via the existingto_result_fields()spread._maybe_flag_no_tool_completion()(rollout/__init__.py), called right after_maybe_classify_api_error(). Guards reuse the established ones: error channel already claimed → skip (zero-token cases still route tosuspected_api_error); no executed prompts → skip (Self-gen skill export failures are swallowed, allowing empty skill updates to look successful #389); oracle → skip; native-subscription telemetry exemption from Registry-driven Claude subscription gate + zero-signal heuristic exemption (Anthropic OAuth unlock) #886 → skip; and the trajectory must contain at least oneagent_messageevent, so trajectory-capture loss (Claude ACP trajectories capture no thinking text on models that defaultthinking.displayto"omitted"#982) is never misflagged as a chat-only completion., no-opmarker ([FAIL] task-x (reward=0.00, tools=0, no-op));summary.jsongains ano_tool_call_completionscount; the registry-driven job-summary warning loop now counts category-less diagnostics by field presence in result.json (no behavior change for the seven existing diagnostics, which all carry categories).RolloutResultgains a trailing optionalno_tool_completion: bool = Falseso the CLI reads the authoritative flag instead of re-deriving it.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 reporttool_callevents 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
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 tosuspected_api_error), registry integrity,format_issuerendering.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 checkclean 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.