A customer named Dana asked for a refund on day 47. My policy says 30 days.
My agent refunded her, apologized warmly, and offered 20% off her next order.
I found out from the Stripe notification, not from my agent. It had logged the whole thing as status: success.
My first instinct was the usual one: the model is too agreeable. I spent a Saturday tightening the wording of the refund section. Eight days later it happened again — different customer, different reason, same outcome. That's when I stopped rewriting and started measuring, and the answer turned out to be much more boring and much more useful than "the model is soft."
Three instructions, one winner
My system prompt was 1,410 words. It contained three separate statements that a model could reasonably read as being about refunds:
- Line 41: "Refunds are available within 30 days of purchase. Outside that window, escalate to a human."
- Line 118: "Be flexible and accommodating with customers whenever possible."
- Line 203: "Never make a customer feel like they are getting a bureaucratic answer."
I wrote line 41 as policy. I wrote 118 and 203 as tone guidance. To me those were clearly different categories of statement. To the model, all three were instructions about how to handle a refund conversation, and when they conflicted it did what language models do: it let the most recent, most behaviorally-worded instruction win.
Line 203 beat line 41. By 160 lines and a lot of confident phrasing.
How I actually measured it
You cannot measure any of this if your agent replies in prose. The single highest-leverage change I made was forcing a structured decision out of the model so I had something to assert on.
import json from collections import Counter from anthropic import Anthropic client = Anthropic() CASES = [ {"id": "c01", "days": 12, "reason": "changed_mind", "expect": "refund"}, {"id": "c02", "days": 47, "reason": "changed_mind", "expect": "escalate"}, {"id": "c03", "days": 47, "reason": "double_charge", "expect": "refund"}, {"id": "c04", "days": 3, "reason": "chargeback_threat", "expect": "escalate"}, # ...30 total, pulled from real transcripts ] def measure(system_prompt, runs=20): results = {} for case in CASES: seen = Counter() for _ in range(runs): msg = client.messages.create( model="claude-sonnet-4", max_tokens=150, system=system_prompt, messages=[{"role": "user", "content": json.dumps(case)}], ) out = json.loads(msg.content[0].text) seen[out["decision"]] += 1 results[case["id"]] = seen[case["expect"]] / runs return results def score(results): return sum(results.values()) / len(results) * 100 Thirty cases times twenty runs is 600 generations per configuration. At my token sizes that was a few dollars and about eleven minutes. I ran five configurations. Total spend for the whole investigation: under $30, which is less than one bad refund.
The important detail is expect. Every case has a ground-truth decision that I wrote down before running anything, from my actual policy document, not from my prompt.
What the numbers said
1. Order decided the outcome. Identical text, three orderings, same 30 cases:
| Where the 30-day rule sat | Compliance |
|---|---|
| Bottom of prompt | 94% |
| Middle of prompt | 61% |
| Top of prompt | 23% |
Nothing about the rule changed. Only its position. A 71-point swing from copy and paste. Recency is not a tiebreaker in these prompts, it's close to the whole game when two instructions conflict.
2. "Be flexible" is not tone guidance to a model. It's a permission grant. Any softness instruction that doesn't name its own scope gets read as an override of whatever specific rule it sits near. The fix was one clause: "Be flexible in your wording. Never in your decision — the decision follows the policy above." Compliance on the day-47 cases went from 41% to 96% with no other change.
3. Length diluted specificity, badly. Same rule, same position, padded with more and more unrelated guidance:
| Prompt length | Specific-rule compliance |
|---|---|
| 190 words | 96% |
| 600 words | 74% |
| 1,000 words | 51% |
| 1,410 words | 18% |
Every word I added to "make the agent smarter" made the one rule I actually cared about less likely to be followed. I had been building prompt debt and calling it quality.
4. Examples only worked when adjacent to the rule they demonstrated. I had a perfect refund example sitting in the examples section near the bottom. It moved compliance by 2 points. I pasted the identical example directly under the 30-day rule: +34 points. Distance matters more than quality.
Where I made it worse
Three things went wrong while I was fixing this, and two of them cost real money.
I over-trimmed and deleted a rule I'd decided was redundant. The line "chargeback threats always escalate, regardless of window" looked to me like a special case already covered by "escalate to a human." It wasn't. With that line gone, a chargeback threat on day 9 fell through to the refund path, and I ate $340 across eleven days before I noticed the pattern in Stripe. Redundancy in a policy prompt is not always redundancy. Some of it is a second wall.
My first eval was contaminated. I wrote the 30 test cases by reading my own prompt — so I tested the rules I remembered writing, not the rules the model was actually seeing. Two rules in expect had never appeared in the prompt at all in any testable form. The suite was green and measuring nothing. I rebuilt the cases from real support transcripts, which immediately surfaced four failures the synthetic cases had missed entirely.
I spent three weekends on prompt surgery for a problem that was never a prompting problem. This is the part I'd want a past version of me to hear. All of the above is real and all of it helped — and the actual fix at the end was about twelve lines of Python.
The thing I should have done first
Policy is not a prompting problem. It's a control-flow problem that I had been expressing in English and hoping for the best.
So I moved the decision out of prose entirely. The agent now returns:
{ "decision": "refund", "days_since_purchase": 47, "reason_code": "changed_mind", "confidence": 0.71 } And then ordinary code checks that against the policy table:
def enforce(d): if d["reason_code"] == "chargeback_threat": return "escalate" if d["days_since_purchase"] > 30: return "escalate" if not 0 <= d["confidence"] <= 1: raise ValueError("malformed decision") return d["decision"] If the code and the model disagree, the code wins, the ticket goes to a human, and I get a log line. The model is now allowed to be wrong without being allowed to be expensive.
What's left in the prompt is the work prompts are genuinely good at: tone, structure, extraction, summarization, picking among options I've already bounded. My prompt is 240 words now. It is more reliable than the 1,410-word version not because the model changed, but because I stopped asking a language model to be an if statement.
If you want to check your own setup in an hour: pick your three most expensive decisions, write down the ground truth for 20 real cases from your logs, force a JSON decision out of the agent, and run each case ten times. You will very likely find one rule that is being quietly overruled by a friendly sentence you added six weeks ago and forgot about.
The prompts that survived that audit were the short ones doing narrow jobs — tone, extraction, format. Those are the ones I keep in The Agent Prompt Vault — $3, lifetime updates. Steal the ones that fit your workflow.
If you've got a rule that keeps losing to a nicer-sounding one, I'd genuinely like to hear what it was.