I spent a Tuesday writing a guard so that one guard would stop contradicting another guard.
What they were guarding is a chat agent that interviews people looking for work. It asks about your trade, your languages, where you will travel, whether you need housing — eleven fields in all — then proposes vacancies that fit. Nine tools behind it, and one conversation that has to feel like a person.
About twenty-five deterministic backstops sat downstream of the model, correcting its prose before any candidate saw it. Eleven were text-mutating passes with names like stripLeakedToolCalls and enforceOneQuestion, and two of them had begun disagreeing over whether a sentence had already been rewritten. My fix was an auditor: a second, cheaper model reading the first one’s draft and flagging claims it could not support. Within a day it needed guards of its own.
What I believed for four months
An agent, I thought, is a castrated ChatGPT. Pin a general model to a role, hand it a fixed toolset, and you have an employee who never sleeps. The first version was exactly that, and it worked beautifully right up until real people started answering it. The case page has the shipped shape of it; this is what the case leaves out.
[past-max]Real people give a birth year when you ask their age. They send two phone numbers, or one with a digit missing. Some have names that do not reveal gender, so the bot must ask without making it strange. Some ask a question without a question mark, and the code downstream cannot tell whether to answer or move on.
Each of those became a line in the system prompt, until the prompt grew long enough that the model began ignoring parts of it. So the architecture became a slogan: the AI reacts, the code checks. Before anything reached the candidate, code inspected the draft, recorded what the model had failed to record, and nudged it into phrasing the next question.
For a few weeks this felt like power. Any whim could become another guard.
Then the deviations kept arriving. Someone gives a phone number only at the end, once a vacancy looks worth it. Someone changes country mid-conversation. Someone is job-hunting for themselves and their husband, in one thread. A fragment of a tool call leaks into the prose. The bot addresses a candidate whose name it does not know using its own.
The auditor ended the approach, and not by failing. A wider auditor, given more rules, performed worse: it began correcting replies that were already right. A false correction costs more than a missed one, because it wrecks the voice of a thing people are talking to. So I narrowed it until it asked exactly one question — is the agent promising to do something itself, later? — and noticed I was maintaining a model whose entire job was to distrust a model.
The sentence that reorganised the project
We never wanted an agent that fulfils the user’s wishes subject to constraints. This one talks to the candidate and serves the company: it gives feedback in natural language and moves a script along. Solving anybody’s problem cleverly was never in the brief.
I had been building the wrong thing very carefully.
[devil's-advocate]- every rule lives in the system prompt, which the model partly ignores once it is long enough
- the model writes the whole final message
- the model decides which field is still missing
- ~25 prose backstops rewrite the draft before it ships
- a second model audits the first, and needs guards of its own
- a cheap NLU pass reads the message before any generation runs
- the model returns parts; code assembles the string
- a pure function picks the next field
- a closed set of 11 intents, each checked against the turn's tool trace
- no auditor: a false claim is a typed field, not a sentence to parse
Three principles, one week
The model no longer writes the final message. It returns parts: what it meant, how it reacted, the answer to the candidate’s question if there was one, how the next form question should be put. Code assembles the string from those parts. The model used to be scriptwriter and editor at once; now it is only the scriptwriter. Leaked code, two questions in one breath, a sentence cut off halfway: that whole class of failure stopped being possible instead of being caught.
The model no longer decides which field is missing. A pure function does, so nobody is asked the same thing twice.
Every claim is checked against evidence. The reply carries an intent label from a closed set of eleven: showed vacancies, handed to a recruiter, withdrew the application. Code folds the turn’s trace — which tools ran, what they returned — and compares. Said “I found some roles” with no search in the trace, and code either runs the search or downgrades the claim to something honest. Said “I have passed you to a recruiter” with nothing written down: same treatment.
- ● 01reconcilecheap NLU pass, before any generation
- ● 02planplanIntakeMove(): code picks the field
- ● 03generateflat reply: intent, reaction, answer, question
- ● 04verifyintent checked against the tool trace
- ● 05composecomposeReply(): the only place the string is built
Changing all this was frightening: the old thing did work. So, tests — 804 the evening the rewrite landed, 1,190 now. Ninety-seven simulated candidates, sixteen turns each, another model playing the human deliberately badly: answering only what was asked, one fact at a time, the way people do and fixtures never do. One spends four turns trying to break the agent, fails, then settles down and looks for a job like everybody else. One rule in that harness exists purely so a simulated candidate will sometimes skip the introduction and open with a question instead — the case that used to leave the agent addressing a stranger by the only name in the room. Its own.
[max]One thing I will not tidy up: the system prompt is longer than before the rewrite, not shorter. I had assumed moving policy into code would shrink it. What moved was the load-bearing part, into somewhere a test can reach — a different win from the one I went looking for.
I now have convincing hope that the agent will behave more predictably and be easier to extend. I do not think this is a story about infinite extensibility. Sooner or later we will overload it again with the variability and mutual contradiction of our own requirements, which we do not track all that well ourselves.