Skip to content

Diagnosing a reasoning cliff in an agentic model

An agent model that quietly stops reasoning mid-session produces exactly the complaints you would blame on the model itself: it asserts instead of verifying, asks answered questions, skips the tool built for the job. This guide shows how to separate “the model is weak” from “your config broke it” from “the serving side changed”, using only the session logs your harness already writes and a handful of paid API probes. The worked example is the Kimi K3 cliff of 2026-08-19/20, where a model that had reasoned on 90-100% of turns for a month dropped to 23-30% in two days.

Prerequisites: a harness that logs, per assistant turn, the model id, the provider, the reasoning token count and the total context size (pi writes all four into its session jsonl); jq; and a few dollars of API credit for the probe ladder.

Everything the method reads from a pi session file, per assistant message:

FieldMeaningUsed for
.message.modelmodel id that produced the turnsplit multi-model sessions before computing any rate
.message.providerprovider the request went throughrule out a provider switch
.message.usage.reasoningreasoning tokens reported for the turnthe dependent variable
.message.usage.totalTokensfull context size of the requestthe context-length axis
.message.content[].type == "thinking"whether a thinking block was storeddistinguishes “did not think” from “thought but misreported”
.timestampturn time (UTC)the date axis and the transition-window audit

Fixed facts about the worked example, measured from these fields unless stated:

FactValueHow it was checked
K3 think-rate Jul 20 - Aug 1887-99% of turns per daysession logs, all sessions, provider constant (OpenRouter)
K3 think-rate Aug 20-2223-30% per daysame query
Transition windowbetween 2026-08-19 00:10 and 08:00 UTCper-turn scan of Aug 19; 00:00-00:09 healthy at 230-268k context, 08:00-08:45 degraded at every size
Harness binarypi 0.84.2, installed 2026-08-18 07:19 +08, unchanged through the cliffpi --version, binary mtime
User-side config changes in the windownone touching the K3 request shapegit log of the dotfiles repo over the window

Step 1: extract the per-turn reasoning flag

Section titled “Step 1: extract the per-turn reasoning flag”
Terminal window
for f in ~/.pi/agent/sessions/*/*.jsonl; do
jq -rc 'select(.type=="message" and .message.role=="assistant"
and .message.model=="moonshotai/kimi-k3" and .message.usage != null)
| [(.timestamp[0:10]), (.message.usage.totalTokens // 0),
(if ((.message.usage.reasoning // 0) > 0) then 1 else 0 end)] | @tsv' "$f"
done

Substitute your model id. Two gotchas at this step:

  • Filter on .message.model. A session that switched models mid-run (common when one model dies mid-task) otherwise blends two behaviours into one misleading rate.
  • usage.reasoning == 0 can be a reporting artefact on some providers. Cross-check a sample of zero-reasoning turns for thinking blocks in .message.content: if the block is absent, the model genuinely did not think; if it is present with zero reported tokens, you are measuring a usage-accounting bug, not a behaviour change.

Aggregate step 1 by date. A healthy model shows a stable daily think-rate; a cliff is a day-over-day halving. In the worked example the rate ran 87-99% through Aug 18, 70% on Aug 19, 23% on Aug 20. The per-day table is also the first thing an upstream provider will ask for, so keep it.

Do not stop at the per-day table. Daily rates conflate context size, turn type and session shape, and every one of those can change on the same day your workload changes (the worked example’s cliff week was also a large migration week with 3-4x longer sessions).

Step 3: the date x context-size cross-tab separates causes

Section titled “Step 3: the date x context-size cross-tab separates causes”

Bucket each turn by context size and cross it with date:

Terminal window
... | awk -F'\t' '{
b = ($2<50000) ? "<50k" : ($2<100000) ? "50-100k" : ($2<150000) ? "100-150k" : ">150k";
key = $1 " " b; tot[key]++; th[key] += $3 }
END { for (k in tot) printf "%s %d/%d (%.0f%%)\n", k, th[k], tot[k], 100*th[k]/tot[k] }'

Read it two ways:

  • Down a date column: if one size bucket collapses while others hold, the cause is context-dependent.
  • Across dates within one size bucket: this controls for your workload changing. The worked example’s >150k bucket ran 88-100% through Aug 18 and 4-15% from Aug 20 - the same size range, opposite behaviour, so the longer-sessions confound is ruled out by the table itself.

Run the same cross-tab for your other models over the same dates. In the worked example deepseek-v4-pro held 97% at >=100k on the cliff days while K3 ran 4-15% at >150k: same harness, same extensions, same days, one model affected. That single comparison rules out the entire “my tools broke it” hypothesis class, which is usually the first suspicion and the most expensive to chase.

Step 4: audit the transition window against user-side changes

Section titled “Step 4: audit the transition window against user-side changes”

The cross-tab gives you a transition day. The per-turn data gives you a transition window (step 1 at full timestamp resolution). Now list every user-side change that landed inside the window and check each against the request path:

  1. Harness binary: version and mtime (pi --version, stat the binary). If the binary predates the window and the model was healthy after the install, the harness is exonerated. If you suspect a specific upstream commit, compare its commit timestamp to the binary mtime - a binary cannot contain commits newer than itself.
  2. Config repo: git log --since/--until over the window, then read each commit and ask whether it touches anything that reaches the request (model overrides, sampling params, system prompt, extensions that inject context). Docs-only commits about unrelated stacks do not count as candidates however well their dates line up.
  3. Session resume vs restart: a harness that reloads model config on an explicit action (pi reloads models.json when the model selector opens) will keep sending a removed param across a session resume. Any “I removed X and nothing changed” observation made without a full restart is not evidence about X.

In the worked example this step killed three hypotheses in sequence: the harness binary (installed before the cliff, healthy after install on the same binary), a sampling-param workaround (added after degradation was already visible in the logs), and the system prompt (no prompt-surface commits in the window).

Session logs tell you what happened; probes tell you what happens now under controlled variables. Send each probe at least three times - single samples are noise, and the worked example’s repetition-penalty A/B flipped direction between two single samples before a 3v3 showed mild suppression and a long-context pair showed none.

ProbeIsolatesWorked-example result
Short single-turn, effort highbaseline: can the route think at all19-173 reasoning tokens, every provider tried
Long context (~160k), single-turnraw context lengthstill thinks (43-61 tokens) on Moonshot and DeepInfra
Long context + replayed thinking history and tool callsthe harness’s multi-turn replay shapestill thinks (14-16 tokens)
Same long probe via a second gatewaygateway translation layerthinks via both
Effort max vs highprovider-side effort semanticsno difference on trivial tasks

The ladder’s payoff is the divergence: every probe shape thinks, yet live multi-turn sessions at the same sizes do not. The cause therefore lives in a request property the probes do not replicate (the full tool-definition set and system prompt of a real session) or in serving-side behaviour conditioned on session shape - either way, it is not your config, because your config was constant across the cliff and the probes carry your config’s parameters.

A diagnosis is done when every candidate cause is either falsified by a step above or promoted with a mechanism. The worked example’s final list:

CandidateVerdictFalsified by
Model is weakno87-99% think-rate for a month on the same model
Harness config / extensionsnoother models healthy in same sessions; no request-path change in the window
Provider switchno.message.provider constant across the cliff
Sampling-param workaroundnodegradation precedes the commit that added it; probes inconsistent
Serving-side change at the providerpromotedtransition window matches the vendor’s own incident reports1; length-dependent suppression; model-vendor documented sensitivity to long-context serving2

File the promoted cause where the serving side can answer it: per-generation feedback APIs accept individual bad generations3, and a support ticket or email carries the cross-tab. The date x context-size table is the artifact that makes such a report actionable; “the model feels dumber” is not.

The diagnosis is trustworthy when three controls hold:

  1. A same-harness control model is healthy over the cliff dates (step 3).
  2. The transition window contains no user-side change that reaches the request (step 4).
  3. Probes reproduce thinking under every isolated variable, leaving the divergence in the one property you cannot replicate (step 5).

If any control fails, the diagnosis is not done - the failing control is the next hypothesis.

  • Tool-call-only turns think less than prose turns even in a healthy model. A daily rate drop can therefore be partly a turn-mix shift; the cross-tab’s <50k bucket is your turn-mix control.
  • Compaction rewrites history through a summariser. If the compaction ran under a different (weaker) model, everything the session “knows” afterwards passed through that model’s summary - split rates at the compaction boundary too.
  • A model vendor’s marketing name is not a model card. The worked example’s first wrong hypothesis (“K3 is the lite sibling”) came from recalling the naming scheme instead of querying the provider’s models API. Query the API; it returns context window, modality and price.
  • Reasoning effort is a ceiling, not a floor, on some models. A probe that returns 14 reasoning tokens has not proven the model “thinks when asked” in any operational sense; it has proven the route is not hard-suppressing.
  • Keep the raw cross-tab. Every hypothesis in the worked example was killed by re-slicing the same extracted data, not by collecting new data.
  1. vLLM project, “Kimi-K3: all requests degenerate to a repeated token after long-context prefill (NaN logits; packed KDA prefill suspected),” issue 51039. https://github.com/vllm-project/vllm/issues/51039

  2. Moonshot AI, “Kimi K3: Open Frontier Intelligence,” Kimi blog. https://www.kimi.com/blog/kimi-k3

  3. OpenRouter, “Report Feedback - Submit Bug Reports for Generations,” OpenRouter Docs. https://openrouter.ai/docs/guides/overview/report-feedback