From 03bdda697f8a9f2209421ad5f6c14d371d37d6c5 Mon Sep 17 00:00:00 2001 From: workprentice <257153108+workprentice@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:14:17 +0000 Subject: [PATCH] fix(weekly-digest): recover from adaptive-thinking max_tokens exhaustion The Synthesize step calls claude-sonnet-5 with thinking: adaptive and no explicit effort, which defaults to Anthropic's most reasoning-heavy tier. Thinking tokens count against max_tokens, and the digest this formats grows every week, so a fixed 16000-token budget that worked at launch eventually stops leaving room for the response text: - 2026-08-17 (run 32037994970): the model was cut off mid-JSON -> invalid JSON -> 'Synthesis output was not valid JSON'. - 2026-08-24 (run 32737641009): thinking alone consumed the whole budget, leaving zero text blocks -> 'Anthropic API returned no text'. The diagnostic head -c 2000 dump was entirely swallowed by the thinking block's base64 signature, hiding stop_reason/usage from the log. Both are the documented stop_reason: max_tokens failure mode (see https://platform.claude.com/docs/en/build-with-claude/thinking-troubleshooting#the-response-stops-with-stop_reason-max_tokens). - Set output_config.effort: low. This call formats data the COLLECT step already gathered; it is not the complex-reasoning workload effort defaults to, and low effort is Anthropic's own guidance for 'chat and non-coding use cases'. - Raise the initial budget to 24000 and add one bounded retry at 32000 if the first attempt reports stop_reason: max_tokens, so one unlucky week does not silently skip the digest. - Replace the raw response-body dump with a jq summary of stop_reason, usage, error, content block types, and a text preview -- fields that can no longer be pushed out by an opaque thinking signature. Verified with a local harness (curl stubbed) against six fixtures: clean success, max-tokens-then-retry-succeeds, curl failure, fenced JSON, missing keys, and max-tokens-persists-after-retry (the diagnosability regression guard, confirming stop_reason/usage stay visible past a 3000-char signature). Left the structurally similar .content[0].text extraction in claude-triage.yml and auto-label-issues.yml alone: neither enables thinking and both use haiku with small max_tokens, so they are not exposed to this failure mode today, and claude-triage.yml already has an open PR (#21095) touching it. Fixes #21086 --- .github/workflows/weekly-digest.yml | 92 +++++++++++++++++++++++------ 1 file changed, 73 insertions(+), 19 deletions(-) diff --git a/.github/workflows/weekly-digest.yml b/.github/workflows/weekly-digest.yml index 2ec014bf4018..f1518d6c2f3b 100644 --- a/.github/workflows/weekly-digest.yml +++ b/.github/workflows/weekly-digest.yml @@ -62,6 +62,16 @@ jobs: # claude-triage.yml). Pure JSON-in / prose-out; no agency, so no Claude # Code action. Returns {"pr_digest":...,"backlog_digest":...}; we strip # any ```json fences defensively before parsing. + # + # Thinking tokens count against max_tokens (see + # https://platform.claude.com/docs/en/build-with-claude/thinking-troubleshooting#the-response-stops-with-stop_reason-max_tokens), + # and the backlog this formats only grows week over week, so a fixed + # budget that was fine at launch can quietly stop leaving room for the + # response text. `output_config.effort: "low"` keeps thinking spend + # down for what is a formatting task, not a reasoning one — Anthropic's + # own guidance for "chat and non-coding use cases" — and a single + # bounded retry at a larger budget covers the case where the model + # still exhausts it on the first pass. - name: Synthesize digests id: synth env: @@ -70,29 +80,73 @@ jobs: set -euo pipefail PROMPT=$(cat scripts/weekly-digest/synthesis-prompt.md) DIGEST=$(cat /tmp/digest.json) - REQUEST=$(jq -n \ - --arg prompt "$PROMPT" \ - --arg digest "$DIGEST" \ - '{ - model: "claude-sonnet-5", - max_tokens: 16000, - thinking: { type: "adaptive" }, - messages: [{ - role: "user", - content: ($prompt + "\n\n=== DATA (JSON) ===\n\n" + $digest) - }] - }') - RESPONSE=$(curl -sS https://api.anthropic.com/v1/messages \ - -H "x-api-key: $ANTHROPIC_API_KEY" \ - -H "anthropic-version: 2023-06-01" \ - -H "content-type: application/json" \ - -d "$REQUEST" || echo '{"error":"curl_failed"}') + + call_anthropic() { + local budget="$1" + jq -n \ + --arg prompt "$PROMPT" \ + --arg digest "$DIGEST" \ + --argjson max_tokens "$budget" \ + '{ + model: "claude-sonnet-5", + max_tokens: $max_tokens, + thinking: { type: "adaptive" }, + output_config: { effort: "low" }, + messages: [{ + role: "user", + content: ($prompt + "\n\n=== DATA (JSON) ===\n\n" + $digest) + }] + }' \ + | curl -sS https://api.anthropic.com/v1/messages \ + -H "x-api-key: $ANTHROPIC_API_KEY" \ + -H "anthropic-version: 2023-06-01" \ + -H "content-type: application/json" \ + -d @- \ + || echo '{"error":"curl_failed"}' + } + + # A diagnostic summary that survives what the plain response body + # doesn't: a thinking block's base64 `signature` field alone can + # run past a couple thousand characters, which previously pushed + # stop_reason and usage — the two fields that actually explain a + # failure — out of any head -c dump of the raw body. + summarize_response() { + echo "$1" | jq -c '{ + stop_reason, + usage, + error, + content_block_types: [.content[]?.type], + text_preview: ([.content[]? | select(.type=="text") | .text] | join("") | .[0:500]) + }' 2>/dev/null || echo "$1" | head -c 500 + } + + RESPONSE=$(call_anthropic 24000) + STOP_REASON=$(echo "$RESPONSE" | jq -r '.stop_reason // empty') + + # Thinking that ate the whole budget stops with stop_reason + # "max_tokens" and no text block, or a text block truncated + # mid-JSON — both observed in the wild (2026-08-24 and 2026-08-17 + # runs respectively). Retry once with more headroom before giving + # up, so one unlucky week doesn't silently skip the digest. + if [[ "$STOP_REASON" == "max_tokens" ]]; then + echo "::warning::Synthesis hit max_tokens on the first attempt; retrying with a larger budget." + summarize_response "$RESPONSE" >&2 + RESPONSE=$(call_anthropic 32000) + STOP_REASON=$(echo "$RESPONSE" | jq -r '.stop_reason // empty') + fi + + if [[ "$STOP_REASON" == "max_tokens" ]]; then + echo "::error::Anthropic response hit max_tokens even after retrying with a larger budget — thinking consumed the response before it produced digest text. Raise the max_tokens values in this step further." + summarize_response "$RESPONSE" >&2 + exit 1 + fi + # Sonnet 5 runs adaptive thinking, so a thinking block precedes the text # block — select the text block(s) rather than content[0]. - TEXT=$(echo "$RESPONSE" | jq -r '[.content[] | select(.type=="text") | .text] | join("")') + TEXT=$(echo "$RESPONSE" | jq -r '[.content[]? | select(.type=="text") | .text] | join("")') if [[ -z "$TEXT" ]]; then echo "::error::Anthropic API returned no text" - echo "$RESPONSE" | head -c 2000 >&2 + summarize_response "$RESPONSE" >&2 exit 1 fi CLEAN=$(echo "$TEXT" \