DEV Community

Cover image for Turn thirty-one, and the agent asked for the account number the caller gave on turn four
Marcus Chen
Marcus Chen

Posted on

Turn thirty-one, and the agent asked for the account number the caller gave on turn four

Look: the call was going fine. That is what made it annoying.

A caller with a billing dispute. Long call, the kind we are actually proud of, because a year ago the agent would have dumped her to a queue in ninety seconds. She gave her account number on turn four. She explained the charge, we pulled the invoice, she disputed two lines, we walked the two lines, she asked about the credit timing, we answered, she asked whether it would hit before her next statement.

Turn thirty-one, the agent said: "Sure, can I get your account number?"

She hung up.

Week one: the wrong suspect

My first guess was the ASR. It usually is. I pulled the audio, the transcript was clean, turn four had the digits, and the digits were right.

Second guess was the tool layer. Also wrong. The account number had been passed to three separate tools during the call and all three had it.

The number was in the transcript and in the tool arguments. It was not in the prompt.

What our context policy actually was

Nobody wrote it down, which is the first thing I would tell past me. It had accreted. Read out of the code, it was:

  • keep the last 6 turns verbatim
  • everything older gets folded into a running summary
  • every 5 turns the summarizer is handed the previous summary plus whatever just fell out of the window, and writes a new one. It never sees the original transcript again. That last clause is the whole post.
  • prepend the summary, then the 6 verbatim turns

That is a completely ordinary policy. I have seen close variants in four codebases. It is also the thing that ate the account number, and the reason took me embarrassingly long to see, because I kept thinking about it as one summarization step instead of a chain of them.

The arithmetic nobody had done

A fact stated on turn 4 and needed on turn 31 is 27 turns old. It is out of the 6-turn verbatim window, so it lives in the summary. And the summary does not get summarized once. It gets regenerated every 5 turns, and each regeneration is a fresh chance to drop the fact.

So how many times does it get rewritten? I reached for a formula, ceil((d - K) / S), which gives five. Then I enumerated the actual schedule and it is four: our summarizer regenerates on multiples of five, turn 4 falls out of the verbatim window at turn 10, and it gets rewritten at 15, 20, 25 and 30.

The formula is an upper bound over where the fact happens to land. Holding the distance at 27 turns and sliding the statement turn, the true count is four in eight of the ten possible alignments and five in two. So the question is not "does my summarizer keep account numbers." It is "does my summarizer keep account numbers four times in a row," and the four is a small integer nobody on the team could have named.

We measured the per-pass number, which turned out to be the only measurement worth taking. Take a sample of real calls, take the facts you care about, and for each regeneration check whether the fact went into the new summary. Ours came out at about 0.90 for structured identifiers. It was worse for constraints stated in prose ("she said she is travelling until the 14th"), which I will come back to.

Then it is just powers:

1 pass: 90.0 percent survival
2 passes: 81.0 percent
3 passes: 72.9 percent
4 passes: 65.6 percent
5 passes: 59.0 percent
7 passes: 47.8 percent

Our case is four, so 65.6 percent. A summarizer I would have described to you as good, because 90 percent sounds good, is closer to two-in-three a half hour into a call, and it is one boundary alignment away from 59.

I have written about compounding before, a couple of weeks ago, about per-turn error rates across a call, and I read that as a fact about the model. This is a different exponent. This one counts the number of times we rewrote the fact ourselves, and that count is not in any config file.

And a real call does not carry one fact. Ours carry about five that matter: the identifier, the specific charge in dispute, the resolution the caller asked for, one constraint on timing, and whatever the caller said they had already tried. Treat those five as independent and the picture at turn 31 is:

  • per-fact survival 65.6 percent
  • expected facts still present: 3.28 of 5
  • all five present: 12.2 percent of calls, under independence

I said something close to the opposite of what follows a couple of weeks ago, so let me correct myself. Positive association between the drops makes the all-clean number BETTER than the independent estimate, not worse, so 12.2 percent is a floor rather than an estimate. What association makes worse is how it feels, because the calls that lose one fact are the same calls that lose three.

How far above 12.2 percent the truth sits depends on how much the drops share a cause, and I cannot measure that from a few hundred calls. The number I trust is the expected count, because expectation does not care about correlation at all: 3.28 facts of 5 still present at turn 31, however the drops are coupled.

The failure you can compute, and the one you cannot

The comparison that changed how I think about this. Same policy family, same K, and the difference is what happens past the window edge.

inside the 6-turn window: verbatim window keeps it, rolling summary keeps it
1 rewrite later: window 0 percent, summary at r=0.90 90.0 percent
2 rewrites: window 0 percent, summary 81.0 percent
4 rewrites, which is where our turn-4 fact lands at turn 31: window 0 percent, summary 65.6 percent
7 rewrites: window 0 percent, summary 47.8 percent

I am counting rewrites rather than turns on purpose. Turns are what you can see and rewrites are what actually happens to the fact, and the map between them depends on where the fact falls relative to your regeneration boundary. On our policy, roughly one rewrite per five turns once the fact is out of the window.

The plain truncation window is honest. It fails completely and it fails at a turn number you can compute in your head. You lose everything past turn K and you know it, so you build around it.

The rolling summary is the one that hurts, because it works. It works at 10 turns, it mostly works at 20, and it degrades smoothly enough that nothing in your testing is going to catch the edge. There is no turn number where it breaks. There is a probability that slides, and what a slide produces is not a bug report. It is a slow drip of calls that went weird late.

Raising r does not save you either, it just moves the wall. At r=0.95, four rewrites leaves you at 81.5 percent. At r=0.98, which is better than anything I have measured, four rewrites is 92.2 percent and seven is 86.8. Nothing you can do to r buys you a long call.

Week two: what we changed

Three things, in the order they mattered.

First, and this is most of the fix, we stopped putting facts that must not be lost into a lossy channel. This is the same shape as the handoff card we built for the human escalation path, and I did not notice that for a week. That card was for a seam between two systems. This one is for a seam inside one system, between the agent and its own past, and nobody had drawn that seam on any diagram. There is now a slots object that lives outside the summary entirely:

from dataclasses import dataclass, field, fields

@dataclass
class CallSlots:
    account_id: str | None = None
    disputed_items: list[str] = field(default_factory=list)
    requested_resolution: str | None = None
    timing_constraint: str | None = None
    already_attempted: list[str] = field(default_factory=list)

    def render(self) -> str:
        lines = [f"{f.name}: {v}" for f in fields(self)
                 if (v := getattr(self, f.name))]
        return "KNOWN FACTS (verbatim, do not paraphrase):\n" + "\n".join(lines)
Enter fullscreen mode Exit fullscreen mode

It is prepended whole on every turn, it is never summarized, and it is populated by the tool layer rather than by the model, because the tool layer is where the account number was already correct. Cost is under a hundred tokens on a call that was already spending thousands. Survival is 100 percent by construction, and for the account number that is the only rate I am willing to design around.

Yes, this is a slot-filling dialogue manager, the thing everyone spent 2023 declaring obsolete. I was one of the people declaring it. What I had wrong was the reason it existed. I assumed it was a workaround for models that could not follow a conversation. The models follow the conversation fine. What they cannot do is recover a string that four rewrites ago decided was not important enough to carry.

Second, we made the summarizer's job smaller and told it so. It no longer has to remember the account number, because the account number is not its problem any more. Its prompt now says which categories are handled elsewhere. Retention on what remains went up, which is unsurprising, and I am not going to publish a number for it because I only have a few hundred calls and the confidence interval is wider than the effect.

Third, we log the passes. Every regeneration writes a span attribute with the fact keys going in and the fact keys coming out. The diff is the drop. That took an afternoon and it converted an invisible failure into a line on a dashboard, and I would do that one first if I were starting over, because we spent a week on suspects we could have eliminated in an hour.

The prose facts are worse and I have no clean fix

The identifiers survive because they are short, structured, and obviously important. The constraint stated in prose is the one that dies quietly.

"She is travelling until the 14th" is the fact that makes the whole call correct, and it has no field to live in. Our slots object has a timing_constraint string, which helps when the extractor notices it, and the extractor notices it maybe most of the time. That is a smaller unsolved problem rather than a solved one. Anyone selling you a summarizer that keeps every soft constraint through a forty-turn call is selling you something they have not measured.

What I do now is cheap and blunt: before the agent takes any action with a date in it, it re-reads the full transcript for date-shaped constraints, once, at that moment. One extra call on the small number of turns where being wrong is expensive. It is not elegant. It has caught three of these since we shipped it.

What shipped, and what I would tell past me

Shipped: a slots object outside the summary, a smaller summarizer with an explicit list of what it does not own, per-regeneration drop logging, and a targeted re-read before date-bearing actions.

What I would tell past me is not any of that. It is: write down your context policy, then compute how many times a fact from turn 4 gets rewritten before turn 31. The answer was five, and five was the whole bug. Nobody on the team could have told you that number, and it was three lines of arithmetic away the entire time.

The caller who hung up was right to. From where she sat, she had told us her account number, and then a machine that had been doing quite well suddenly had not been listening. That is exactly what happened. We just had it filed under "summarization quality" instead of under "we ask the model to remember something twenty-seven turns after we threw it away."

Top comments (0)