Chapter 24 — Why Did the Agent Behave Differently?
Friday: O-1001 escalates — Outreach refuses to send outside the send window. Monday: a similar stalled-shipment case proposes send_email. The team says “the model changed.” That is speculation until you diff the artifacts.
run A artifacts ↔ run B artifacts → ranked candidates (policy → prompt → model → tools → memory → input)
Text-only comparison hides what moved — prompt version, policy pack, send window flag in input state. You fix the wrong layer.
This chapter adds run diff: compare two closed executions of O-1001 across versioned inputs and produce a ranked candidate list before anyone opens a model rollback.
First principles
Compare two closed runs across:
| Dimension | Why it matters |
|---|---|
model_version | weights / router target |
prompt_version | assembler templates |
policy_version | permit/deny rules |
tool_schemas | args the model can emit |
memory_snapshot | hidden writes |
input_state | send window, balances |
observations | tool world differed |
actions / stop_reason | what actually diverged |
Prioritize candidates: policy → prompt → model → tools → memory → input → observations. If actions diverge with no artifact diff, inspect sampling and recording gaps—that is still an engineering finding, not a guess.
Example: same order, model, and policy; prompt v2 vs v3. Outreach flips from escalation to send proposal → leading candidate is prompt_version, not “model rolled.”
Concrete example
Prompt v2 vs v3 send-window regression fixture in run_diff.py: same model, same policy, same outside_send_window=True input; actions flip from escalate to send_email. Diff report’s top candidate: prompt_version changed. That is enough to stop the “model rolled” thread and open a prompt PR revert.
Hidden memory write exercise: two runs identical except memory_snapshot.preferred_channel; preferred channel flips — find it in the report.
Diagram
flowchart LR
A[Run A artifact] --> D[RunDiff]
B[Run B artifact] --> D
D --> C[candidate_causes]
C --> P[policy/prompt/model/...]
Dimensions: model prompt policy tools memory state obs actions
Changed: . ≠ . . . . . ≠
Candidates: prompt_version changed
Implementation
Reference: shopops/run_diff.py.
from shopops.run_diff import diff_runs, send_window_regression_fixture
v2, v3 = send_window_regression_fixture()
report = diff_runs(v2, v3)
print("\n".join(report.summary_lines()))
assert any(c.startswith("prompt_version") for c in report.candidate_causes)
action_dim = next(d for d in report.dims if d.dimension == "actions")
assert action_dim.changed
Wire traces into run artifacts: TraceEmitter.to_dict() plus input_state and memory_snapshot at run start. Without those snapshots, memory diffs are invisible — which is itself a design bug.
Failure modes
- “Model rolled” as default explanation.
- Diffing prose only — missing versions.
- Unsnapshot memory — ghost behaviour.
- Claiming causality from a candidate list (wait for Ch 44).
- Comparing live unsaved runs — no artifact, no science.
Production considerations
- Store run artifacts immutably keyed by
run_id. - Auto-diff on eval regressions in CI.
- Alert when
policy_versiondiffers across canary vs control. - Experimentation practices (config diffs, feature flags) apply; agents are not exempt. [VERIFY SOURCE for any specific experimentation guide.]
- Redact PII inside artifacts the same as traces.
Reading a diff report without fooling yourself
Example summary_lines():
RunDiff run-v2 → run-v3
≠ prompt_version: 'outreach-v2' → 'outreach-v3'
≠ actions: [{'type': 'escalate', ...}] → [{'type': 'tool_call', 'tool': 'send_email'}]
Candidate causes (engineering, not proven causality):
- prompt_version changed
What you may say in the incident channel: “Actions diverged; prompt_version is the leading config diff; rolling back outreach-v3 while we inspect assembler pins.”
What you may not say: “Prompt v3 caused the send” as a settled causal claim — unless you also show the input state, policy version, and a controlled replay (Ch 44). Candidate ≠ verdict. Still: candidates beat “model rolled.”
Hidden memory drill: clone run-v2, alter only memory_snapshot={"outside_send_window": false} while wall-clock send window are true. If Outreach trusts memory over clock, actions flip and the diff’s top line is memory_snapshot changed. That is why snapshots are mandatory at run start.
Artifact schema (minimum)
Persist per run_id:
{
"run_id": "...",
"meta": {"model_version": "...", "prompt_version": "...", "policy_version": "..."},
"input_state": {"outside_send_window": true, "case_id": "O-1001"},
"memory_snapshot": {},
"tool_schemas": ["get_order", "draft_email", "send_email"],
"actions": [],
"stop_reason": "escalate",
"spans": []
}
If memory_snapshot is missing, your diff tool must say so loudly — absence is itself a finding.
Sampling residual
When every config dimension matches and actions still differ, the honest candidate is sampling / nondeterminism. Mitigations: lower temperature on tool choice, structured outputs, seed if the vendor supports it, and golden scripted models in CI so your code paths stay deterministic even when prod samples.
Chapter summary
- Behaviour change → diff versions and state first.
- Candidate causes are ranked engineering hypotheses.
- Prompt/policy often beat “model rolled.”
- Memory snapshots are mandatory for fair diffs.
- Fixtures encode known regressions.
- Do not overclaim causality.
- CI can auto-diff failing evals.
- Artifacts beat Slack memory.
Exercises
- Build two run dicts that differ only in
memory_snapshot; assert candidate list leads with memory. - Add
tool_schemaschange that introducessend_whatsapp; show actions diff. - Integrate
diff_runsinto the eval harness whenpassedflips vs last golden. - Sealed puzzle: given two artifacts (create them), find the hidden memory write.
References
- Experimentation / config diff practices. [VERIFY SOURCE]
- Ch 23 traces; Ch 35 cost when model version changes.
- Ch 44 causal reasoning — upgrade path from candidates to interventions.