Skip to content

15 min read

Negative criteria and preconditions

On this page

A negative criterion is a sentence in a tool description saying when not to call the tool: a precondition that must hold, a class of request that belongs somewhere else, or an instruction to answer directly instead of reaching for anything. It is the one lever that addresses an option the model is always choosing among and which has no name, no description and no entry in the tool block — calling nothing.

An accounts-receivable assistant has a tool that emails a payment reminder to whoever is on an invoice. Across 400 sessions in a fortnight it fired 118 times, and 23 of those calls carried an invoice_id that had never appeared anywhere in the conversation. The ids were not random. They were shaped exactly like real ones — INV-2026-0912, INV-2026-0417 — because the model had seen the format in the tool's own description and assembled a new one from the current date and a plausible sequence number.

Twenty-three in a hundred and eighteen is close to one call in five, which is the figure to carry rather than the raw count, since the count grows with traffic and the rate is what a fix has to move. The denominator is worth noticing too. That is a rate per call and not per session: the tool fired on a little under a third of the four hundred sessions, so every session in the denominator was one where an invoice was already in play and the model had every opportunity to look one up before reaching for the reminder.

Sequence: User → Model (Chase the outstanding invoice for Acme); Model → Billing API (send_invoice_reminder(invoice_id="INV-2026-0912")); Billing API → Model (404 invoice not found); Model → Billing API (send_invoice_reminder(invoice_id="INV-2026-0913")); Billing API → Model (200 reminder sent); Model → User (I've sent the reminder.).

Nowhere in that trace does the model look the invoice up. It was never told to, and it was never told not to proceed without one.

Required is a constraint on the call, not on the world

The schema for the tool said what you would expect it to say:

json
{
  "name": "send_invoice_reminder",
  "description": "Send a payment reminder email for an unpaid invoice. Example: invoice_id=\"INV-2026-0912\".",
  "input_schema": {
    "type": "object",
    "properties": {
      "invoice_id": {
        "type": "string",
        "description": "The invoice identifier, e.g. INV-2026-0912. Must be an invoice that exists and is unpaid."
      }
    },
    "required": ["invoice_id"]
  }
}

Two of the strings in that definition are actively producing the failure. The example in the description is doing the job chapter 2 described — demonstrating the shape of a well-formed argument — and it is doing it perfectly, which is why the invented ids are so convincing. And the required array is doing something narrower than it looks. It says that a call to this tool must contain an invoice_id key holding a string. It does not say that the model must have obtained one, because there is no vocabulary in JSON Schema for the value must have come from somewhere. A model that has decided to call the tool and has no id satisfies the only constraint it can see by producing a string, and the description has already shown it what a good one looks like.

Take that second point one layer down, because this is the layer where the wrong belief lives. JSON Schema is a language for describing the shape of a document, and every predicate available in it is a predicate about the document in front of the validator: this key is present, that value is a string, this string matches that pattern. There is no vocabulary in it for where a value came from, because a validator only ever sees the finished object and a finished object carries no history of how it was assembled. So required is enforceable — your loop checks it and rejects the call when the key is missing — while the thing an engineer means by it is not expressible in the language at all. The model is meanwhile working against what it can observe. It can observe that omitting the key produces a rejection. It can observe nothing that distinguishes an id it was handed from an id it composed, because no such distinction is written down anywhere it can read. Producing a plausible string satisfies every constraint that exists.

The sentence Must be an invoice that exists and is unpaid is also in the wrong place, for the reason chapter 1 laid out: it is inside invoice_id's parameter description, and by the time the decoder reaches that slot the name send_invoice_reminder has already been emitted. The rule arrives after the decision it was written to prevent. This is the concrete answer to the question a colleague will eventually ask you — where does don't call this unless you already have a customer id belong? It belongs in the tool description, because that is the string read while the model is still deciding whether to call the tool at all.

Writing the rule so it fires at the right moment

Moving the rule up and stating it negatively is the whole intervention:

json
{
  "name": "send_invoice_reminder",
  "description": "Send a payment reminder email for a specific unpaid invoice. Requires an invoice_id that was returned by a previous list_invoices or get_invoice call in this conversation, or that the user supplied verbatim. Do not call this tool if you do not have such an id: look the invoice up first. Never construct, guess, or infer an invoice id from a date, a customer name, or the format of another id, even if the format looks obvious. If the customer has more than one unpaid invoice, ask which one before sending.",
  "input_schema": {
    "type": "object",
    "properties": {
      "invoice_id": {
        "type": "string",
        "description": "Invoice identifier as returned by list_invoices or get_invoice."
      }
    },
    "required": ["invoice_id"]
  }
}

The example is gone from the description and replaced by a statement of provenance, because in this tool the format was never the hard part — the model had no trouble producing well-formed ids, and that was the problem. The parameter description now says where a value comes from rather than what it looks like, which is the only thing worth saying about an opaque identifier.

Over the following fortnight, on 412 sessions and 121 calls, invented ids dropped from 23 to 1, and the one remaining case was a user pasting an id from an old email that had since been voided. The model also started making a list_invoices call it had rarely made before, which is the mechanism working: told it may not call the tool without an id, it went and got one. The traffic that used to be a single speculative call is now two, and the transcript of a successful reminder carries its own evidence of provenance:

json
[
  {"type": "tool_use", "id": "toolu_01C7",
   "name": "list_invoices",
   "input": {"customer": "Acme Corp", "status": "unpaid"}},

  {"type": "tool_result", "tool_use_id": "toolu_01C7",
   "content": "[{\"invoice_id\": \"INV-2026-0911\", \"amount_due_minor\": 148200, \"due\": \"2026-07-30\"}]"},

  {"type": "tool_use", "id": "toolu_01D2",
   "name": "send_invoice_reminder",
   "input": {"invoice_id": "INV-2026-0911"}}
]

Twenty-two of the twenty-three gone is the ninety-six percent quoted later in this chapter, and both halves of that fraction matter to how it should be read. It is ninety-six percent of one failure, on one tool, over one fortnight, against traffic that happened to run at a comparable volume to the fortnight before. Stating it as a rate is honest. Treating it as a property of provenance rules in general, or as the probability that the next invented id will be stopped, is the step the last section of this chapter refuses to take.

That trace also shows where the second lever is. The description is read before the model commits, which makes it the only string that can stop a call; the tool result is read after, which makes it the only string that can correct one. In the opening trace the API answered the invented id with 404 invoice not found, and the model responded by trying a neighbouring number, which is a reasonable inference from an error that says an object is absent and says nothing about where ids come from. An error that carries the same rule as the description ends the loop instead of feeding it:

json
{"type": "error", "error": {
  "code": "unknown_invoice",
  "message": "No invoice INV-2026-0912 exists for this customer. Invoice ids are not constructible: call list_invoices(customer, status=\"unpaid\") and use an invoice_id from the result."
}}

Replaying the 23 invented-id sessions against the old description with only the error text changed, second guesses after a failed first guess fell from 14 of 23 to 1 of 23, though the first speculative call still happened every time, because nothing had been said before the decision. The two strings cover different halves of the same failure and neither substitutes for the other: the description prevents the call, the error message stops the retry, and a tool whose errors say only what went wrong is training the model to keep trying.

The same shape of rule covers a different failure that looks unrelated. A documentation-search tool in the same assistant was being called for questions the model could answer outright — what does a 402 mean, is VAT included in the totals — at a rate of about 30 calls a day, each adding a round trip and a few thousand tokens of retrieved text to a conversation that did not need them. One sentence fixed it: Do not call this for general questions about billing concepts, payment methods or HTTP status codes that you can answer directly; use it only when the answer depends on this company's specific configured policies, which you do not know. Calls fell to 9 a day and answer quality on the affected questions was unchanged in a blind review of 40 samples. Negative criteria are not only about missing inputs; not calling anything is one of the options the model is choosing among, and the descriptions are the only place you get to argue for it.

A rule that is too strong fails in the other direction

The rewrite that shipped was the second draft. The first read Only call this tool when you are certain the invoice is genuinely unpaid and that the customer has already been given notice, which sounded more careful and was worse. Over the fortnight it ran, calls dropped from 118 to 63 while the underlying volume of requests was flat, and a review of 40 sampled sessions found 11 in which the model had a valid id from a lookup, the invoice was unpaid, and it declined to send, asking the user to confirm details the user had already given. Four of those sessions ended with the user repeating the instruction; two ended with the user giving up. Reminders actually sent per session fell 47%, which nobody noticed for a week because the metric everyone was watching — invented ids — had gone to zero.

The arithmetic behind that percentage is worth checking, because a fall in a raw count is not by itself a fall in a rate. Calls went from a hundred and eighteen to sixty-three, which is a little over half of what they had been, so the shortfall is a little under half — and that reads as a drop in reminders sent only because the underlying volume of requests was flat across the two fortnights. Had traffic halved as well, the same two counts would have meant nothing whatever. The comparison is available only because request volume was being recorded independently of the tool, which is the kind of measurement that looks redundant until it is the one thing holding an inference together.

The condition is what broke it. Certain and has already been given notice are not things the transcript can settle, so the model was asked to evaluate a predicate with no evidence available for either answer, and it resolved the uncertainty the safe way. A conservative resolution of an unevaluable condition is the same failure as the invented invoice id with the sign reversed: in both cases the model supplies a value for something it cannot know, and the only difference is which way the guess falls. Replacing the sentence with a provenance rule — an id returned by a previous call, which is either in the transcript or is not — gave a condition with a determinate answer, and the send rate returned to its baseline while invented ids stayed at one.

The measurement discipline that follows is to watch both directions whenever you add a prohibition. A negative criterion has a false-positive rate and a false-negative rate, and description work reliably reports only the first, since a call that should have happened and did not leaves no trace in the tool logs at all. The eval slice that catches it is the set of cases where the correct answer is a specific call and the model must not hedge, sitting alongside the slice where the correct answer is no call; chapter 6 builds that harness. Until it exists, a sampled read of sessions where the tool was available and never used is the cheap approximation.

Which negative rules work

Not every prohibition you can write is one the model can act on. The distinguishing property is whether the condition is evaluable from what is in front of the model at the moment of the decision.

RuleEvaluable at selection time?Effect
Do not call without an id from a previous lookupYes — the transcript is right thereWorks; large effect
Never construct an id from a date or another idYes — it constrains the model's own behaviourWorks; needed alongside the above
Do not call for questions you can answer directlyYes — a judgement the model can makeWorks; moderate effect
Do not call for accounts in the EU regionNo — region is unknown until something is looked upNo effect; the model guesses
Do not call while the nightly billing sync is runningNo — invisible to the model entirelyNo effect; belongs in the server
Do not call more than three times per conversationPartly — the model can count its own calls, unreliablyWeak; belongs in the harness

What separates the working rules from the failing ones is not how clearly they are written, since every rule in that table is a clear English sentence. It is whether the model has, in front of it at the moment of the decision, evidence that settles the condition. A provenance rule is settled by looking back up the transcript: either a previous tool result contained an id or it did not, and both answers are equally visible. A region rule is settled by knowing something about the account, which only arrives after a call the rule was written to gate. Handed a condition it cannot settle, a model does not stop and does not flag the gap; it produces the most likely continuation, which is a guess about the condition wearing the clothes of a judgement about it. That is the same failure as the invented invoice id and it has the same cause: asked for something it does not have, the model supplies something well-formed.

The bottom half of that table is the more useful half, because those are the rules teams write when a description stops working and they keep adding clauses anyway. A rule whose condition the model cannot observe does not degrade gracefully into caution; it degrades into the model inventing an answer to the condition, exactly as it invented an invoice id. If the boundary depends on data, the boundary belongs behind a tool that fetches the data, or in the code that executes the call — and a description that has accumulated several of these is telling you something about the surface rather than the prose, which is chapter 7's subject.

Where the description stops and the server starts

It is not a security control, and it should never be the only thing standing between a user and an object they should not touch. The description reduced invented ids by 96%, which is a good rate for a routing intervention and an unacceptable one for authorisation. The server executing send_invoice_reminder still has to verify that the invoice exists, that it is unpaid, and that it belongs to the tenant on whose behalf the agent is acting; the model's prompt is not a trust boundary, and anything a user can type into the conversation can shape what the model decides to call. Treat the description as the thing that stops the honest 96% so your logs are quiet enough to see the other 4%.

The corollary is about which tools deserve a mechanism rather than a sentence. An email to a customer who owes nothing is a phone call from that customer whatever your logs say afterwards. For that class of tool the description is one layer of several: the server checks tenancy and payment state, an idempotency key on the invoice id — a token the server records against the first call so that a repeat of it returns the original result instead of sending a second email — stops a retry from sending twice, and a confirmation turn in the harness puts the recipient and the amount in front of the user before the call executes. A read-only lookup that occasionally fires on a bad id costs a wasted round trip and warrants none of that. Deciding which side a tool falls on is worth doing when the tool is designed, because retrofitting a confirmation step onto a conversation flow after launch is considerably more work than writing the clause was.

It is also not free. Every clause here is prompt tokens, paid on every request in the conversation and not just the one where the tool is chosen, and a description that has grown a paragraph of prohibitions is a permanent line item. The send_invoice_reminder rewrite added 61 tokens.

A precondition belongs in the description because that is the only string read before the model commits, and it has to be phrased as something the transcript can settle — a value that is present or absent — rather than as a standard of care. Phrased that way it removes almost all of the invented arguments; phrased as certainty it removes almost half the legitimate calls, and only one of those two failures shows up in your logs.

Sixty-one tokens sounds like nothing, and for one tool on one agent it is nothing. The arithmetic changes when you look at what a tool block costs across a real workload, which is where the next chapter starts — and it changes the question from is this clause worth writing to is this clause worth paying for forty times a conversation.

Tool design — descriptions — read the current version at https://daviddominguez.dev/learn/en/tool-design-descriptions/negative-criteria-and-preconditions

Back to top