What you need to know

There is a class of agent bug that survives every review you run, because nothing about it looks wrong. The trace reads well. The model picks create_refund, which is exactly the right tool, emits valid JSON that passes the schema, and the customer gets a polite confirmation. The only defect is that the amount field says 12 and the customer, in Pune, said twelve lakh. Or the appointment lands on 4 September when a user in Leeds typed 9/4 meaning 9 April. Or the order ID is well-formed, plausible and does not exist, because the model composed it from the pattern of the last one it saw.

This is argument extraction: given that the agent chose the right tool, did it fill in the right values? It is a different problem from tool selection, which is mostly won or lost in the descriptions and which we cover in writing descriptions that stop wrong tool calls, and from the retry-and-repair machinery that rescues small models from malformed output, covered in making small models reliable at tool-calling. This one is about calls that are correctly aimed, structurally fine, and still wrong.

The observability vendors have started naming it. Chanl's August 2026 write-up on tool argument correctness puts it plainly: wrong arguments survive review because "everything around them looks correct", and "schema validation confirms an argument is well-formed. It does not confirm the argument is correct." Openlayer's July 2026 failure-modes analysis reports tool-calling failing "3 to 15% of the time in production" across the systems it examined, with a lead example of a function expecting {"user_id": int, "date_range": ISO8601} and receiving {"userId": "last_week"}. Treat the range as their sample's figure; the shape of the example is what you will find in your own logs.

Everything below is vendor-agnostic on purpose: the failures come from the gap between how humans phrase things and how APIs want them, and that gap does not close when you change providers. There are four levers: design the schema so the wrong value is hard to produce; make ambiguity something the agent can return rather than guess through; validate and canonicalise at the boundary, not in the prompt; and build an eval that scores each argument field.

A taxonomy of argument failures

Before fixing anything, name the failure. Most teams lump all of these under "the model got it wrong", which is true and useless, because the twelve classes below have different root causes and different fixes. Build the detection-signal column first: it tells you which classes you actually suffer from, which is rarely the one you assumed.

Twelve argument-extraction failure classes. Structural classes (rows 8 to 10) are eliminated by strict schema modes; semantic classes need the other three levers.
Failure class Example Root cause Detection signal
Bare timestamp "2026-09-18T09:00" for a Manchester customer; server assumes UTC, meeting lands at 10:00 BST Schema says format: date-time but has no timezone field, so the model omits the offset Validator flags any date-time without offset or without a companion timezone
Relative date, wrong anchor "next Friday" resolved from the model's notion of today, not the conversation's No now in context, or no locale for what "next" means Resolved date differs from a golden resolution against the conversation timestamp
Day and month swapped User in Leeds or Chennai types 9/4; call carries 4 September Day-first users, month-first defaults; dateparser's DATE_ORDER defaults to MDY Golden set mismatch on DMY cases; day value greater than 12 in the month slot on parse
Enum case or spelling "priority": "High" where the API wants "high"; "GBP " with trailing space Enum documented in description but not enforced in schema Enum validation rejects; with strict modes this class disappears
Hallucinated identifier "order_id": "ORD-2026-0917" composed from the pattern of earlier IDs, never returned by any lookup Mutating tool accepts a free-text ID; no lookup step in the loop ID absent from every prior tool result in the trace; foreign-key check fails
Scale and unit confusion "12 lakh" becomes 12; "£12k" becomes 12; "2.5 crore" becomes 2.5 Number field carries no unit; scale words are regional Range check (minimum/maximum) or unit-aware parser at the boundary
Currency assumed "amount": 5000 with no currency on a service that handles INR and GBP Currency optional, or defaulted server-side to whichever market shipped first Currency field required and constrained to ISO 4217 codes
Null versus empty string "notes": "" stored as a real note; "end_date": "" fails a date parse Optional fields with no stated null convention Type union ["string", "null"] rejects the empty string, or a normaliser maps it
Nested object flattened "line1", "postcode" at top level instead of inside "address" Deep schema with no example; model guesses a flatter shape additionalProperties: false rejects the unknown top-level keys
Number as string "passengers": "2" or "two" where an integer is required Non-strict sampling; type is a suggestion, not a constraint Strict type validation; Anthropic's own strict-mode docs use exactly this case
Phone number without region "07700 900123" (UK) or "98450 12345" (India) with no country code Field is a bare string; users never say their own country code E.164 canonicaliser needs a region hint; fails without one
Stale value from an earlier turn User corrected the date two turns ago; call still carries the first date Correction buried in a long context; no explicit confirmation step Multi-turn golden cases with a mid-conversation correction

Notice the split. Rows 8 to 10 are structural: the value is the wrong type or in the wrong place. Both major API vendors now offer grammar-constrained modes that remove those classes outright, and you should turn them on. The other nine rows are semantic: the JSON is exactly what the schema allows and the value inside is still wrong. No sampling constraint can fix that, because a schema cannot express "this ID must exist" or "this user meant April".

Design the schema so the model cannot get it wrong

The cheapest fix for an argument error is to make the wrong value unrepresentable. JSON Schema gives you more than most tool definitions use, and the vendors are explicit about it. Anthropic's tool definition guidance calls detailed descriptions "by far the most important factor in tool performance" and recommends "at least 3–4 sentences for each tool description". OpenAI's function-calling guide says to "use enums and object structure to prevent invalid states". Both are saying the same thing: the contract lives in the schema and its descriptions, not in the system prompt.

Six patterns do most of the work:

Enums for anything closed. A description that says "one of high, medium, low" is a hint; an enum is a constraint, and under strict modes it is a guarantee.

A timezone field next to every date-time. format: "date-time" alone invites a bare timestamp. Pair it with a required timezone field constrained to an IANA name such as Asia/Kolkata or Europe/London. This single change removes the IST-versus-BST class, because India's fixed +05:30 and the UK's seasonal GMT/BST switch stop being the model's problem.

minimum and maximum on every amount. A refund with minimum: 1 and a maximum derived from the order value catches "12 lakh became 12" before any business logic runs. Where the bound is dynamic, put it in the boundary validator instead, but put it somewhere.

Required versus nullable, decided explicitly. Strict modes change the rules. As of September 2026, OpenAI's strict function calling requires that "all fields in properties must be marked as required" and additionalProperties be false, with optional values expressed "by adding null as a type option". Anthropic's strict tool use likewise guarantees that "tool input strictly follows the input_schema" via grammar-constrained sampling. Either way, decide for every optional field whether absence means null or empty, and say so in the description. For schemas that must stay portable, see structured outputs that survive a provider swap.

Resolve, then act. The hallucinated-identifier class exists because a mutating tool accepted a free-text ID. Split it: a find_orders tool that takes what the user actually knows and returns real IDs, and a create_refund whose order_id description says it must come from a find_orders result. Anthropic's advice to "return semantic, stable identifiers (for example, slugs or UUIDs)" from tool responses is the other half: the lookup returns something the model can copy verbatim.

Descriptions that carry the contract, and examples where the shape is subtle. Anthropic's input_examples field (on user-defined tools as of September 2026, at a stated cost of roughly 20 to 50 tokens per simple example) lets you show a correctly nested address or a properly offset timestamp rather than describe one.

Here is the same refund tool before and after. The before version is the one most codebases actually contain.

# BEFORE: the schema most teams ship. Every semantic failure class fits through it.
create_refund_v1 = {
    "name": "create_refund",
    "description": "Refund an order.",
    "input_schema": {
        "type": "object",
        "properties": {
            "order_id": {"type": "string"},
            "amount": {"type": "number"},
            "reason": {"type": "string"},
            "notify_at": {"type": "string"},
        },
        "required": ["order_id", "amount"],
    },
}

# AFTER: the contract is in the schema. Strict mode makes the structural
# parts a guarantee; the descriptions carry what strict mode cannot express.
create_refund_v2 = {
    "name": "create_refund",
    "strict": True,   # Anthropic; OpenAI uses the equivalent strict flag
    "description": (
        "Issue a refund against a single existing order. Use only after "
        "find_orders has returned the order and the customer has confirmed "
        "the amount. Refunds are irreversible. Do not construct order_id "
        "from a pattern; copy it exactly from a find_orders result. "
        "Returns the refund_id and the settlement date."
    ),
    "input_schema": {
        "type": "object",
        "additionalProperties": False,
        "properties": {
            "order_id": {
                "type": "string",
                "pattern": "^ord_[a-z0-9]{12}$",
                "description": "Opaque order identifier exactly as returned by "
                               "find_orders. Never inferred or reconstructed.",
            },
            "amount_minor": {
                "type": "integer",
                "minimum": 1,
                "description": "Refund amount in MINOR units of `currency`: "
                               "pence for GBP, paise for INR. 12 lakh rupees "
                               "is 120000000. GBP 12,000 is 1200000. Must not "
                               "exceed the order's refundable_minor.",
            },
            "currency": {
                "type": "string",
                "enum": ["GBP", "INR"],
                "description": "ISO 4217 code. Must equal the order's currency "
                               "as returned by find_orders.",
            },
            "reason": {
                "type": "string",
                "enum": ["damaged", "not_received", "changed_mind",
                         "duplicate_charge", "other"],
                "description": "Lower-case reason code. Use 'other' and put "
                               "detail in notes rather than inventing a code.",
            },
            "notes": {
                "type": ["string", "null"],
                "description": "Free text for the finance team. null when the "
                               "customer gave no detail; never an empty string.",
            },
            "notify_at": {
                "type": ["string", "null"],
                "format": "date-time",
                "description": "RFC 3339 timestamp WITH offset, e.g. "
                               "2026-09-18T09:00:00+01:00. Interpret the "
                               "customer's words in `timezone`. null to notify "
                               "immediately.",
            },
            "timezone": {
                "type": "string",
                "enum": ["Europe/London", "Asia/Kolkata"],
                "description": "IANA zone the customer is in. Required even when "
                               "notify_at is null so the receipt is dated correctly.",
            },
        },
        "required": ["order_id", "amount_minor", "currency", "reason",
                     "notes", "notify_at", "timezone"],
    },
    "input_examples": [
        {
            "order_id": "ord_7k2m9p4q1w8e",
            "amount_minor": 1250000,
            "currency": "INR",
            "reason": "not_received",
            "notes": None,
            "notify_at": "2026-09-18T10:30:00+05:30",
            "timezone": "Asia/Kolkata",
        }
    ],
}

Three changes matter more than the rest. The amount moved to minor units with the currency beside it, which collapses the scale, unit and currency classes into one field pair. The ID acquired a pattern and a provenance rule. And every date-time acquired a zone. If your tool surface is large, our guide to designing agent tools with schemas, errors and retries covers rolling a change like this across all of them.

Watch out

Strict mode is not correctness. Anthropic's docs promise the model will emit passengers: 2 rather than "two"; they cannot promise it will emit the number of passengers the user meant. Turn strict modes on to delete the structural rows of the taxonomy, then treat every remaining failure as semantic.

Make ambiguity a first-class return

The hardest semantic failures are the ones where there was no right answer to extract. "Refund my order" from a customer with two open orders. "Book it for the 9th" in a thread where the month was never stated. "Send five thousand" on a platform that handles rupees and pounds. Today the model resolves these by guessing, confidently, because nothing in the tool surface gives it any other move.

Give it one. Two patterns, used together, turn ambiguity from a silent failure into a visible, measurable event.

Lookup tools return candidates, not answers. A find_orders that returns a single best match has already made the decision. Return a list, with the fields the model needs to disambiguate and a confidence it can read. One entry above threshold, proceed; two, and the model has evidence that it must ask.

An ask_user tool with a structured question. The alternative to guessing has to be a tool call, not a free-text plea, so that it appears in the trace and can be counted. The question carries the field it is about, the candidates, and what the model would do by default. A customer-facing question that offers a default gets answered; one that opens with "I need more information" gets abandoned.

{
  "name": "find_orders",
  "description": "Search a customer's orders by any combination of email, postcode, approximate date and free text. ALWAYS returns a candidates list, never a single order. If more than one candidate has confidence above 0.6, call ask_user before any mutating tool. Never fabricate an order_id.",
  "strict": true,
  "input_schema": {
    "type": "object",
    "additionalProperties": false,
    "properties": {
      "email":      {"type": ["string", "null"], "format": "email"},
      "postcode":   {"type": ["string", "null"], "description": "UK postcode or Indian PIN code, as typed."},
      "around":     {"type": ["string", "null"], "format": "date", "description": "Approximate order date, ISO 8601."},
      "query":      {"type": ["string", "null"], "description": "Free text such as 'the blue kettle'."}
    },
    "required": ["email", "postcode", "around", "query"]
  }
}

/* Return shape. The model reads `confidence` and `ambiguous`. */
{
  "candidates": [
    {"order_id": "ord_7k2m9p4q1w8e", "placed_at": "2026-09-02T14:12:00+01:00",
     "total_minor": 1250000, "currency": "GBP", "summary": "Kettle, blue", "confidence": 0.71},
    {"order_id": "ord_3f8h1j6n2v5c", "placed_at": "2026-09-09T09:40:00+01:00",
     "total_minor": 1250000, "currency": "GBP", "summary": "Kettle, blue (replacement)", "confidence": 0.69}
  ],
  "ambiguous": true,
  "hint": "Two orders match with similar confidence. Ask which one before refunding."
}

{
  "name": "ask_user",
  "description": "Ask the customer ONE question needed to fill a specific tool argument. Use when a lookup returns ambiguous=true, when a date has no month or year, or when an amount has no currency. Offer the candidates and a sensible default. Do not use for general conversation.",
  "strict": true,
  "input_schema": {
    "type": "object",
    "additionalProperties": false,
    "properties": {
      "about_tool":  {"type": "string", "description": "The tool whose argument is blocked, e.g. create_refund."},
      "about_field": {"type": "string", "description": "The argument name, e.g. order_id."},
      "question":    {"type": "string", "description": "Plain-language question, one sentence."},
      "options": {
        "type": "array",
        "items": {"type": "object", "additionalProperties": false,
                  "properties": {"label": {"type": "string"}, "value": {"type": "string"}},
                  "required": ["label", "value"]},
        "description": "Zero or more concrete choices, each mapping to a value the tool will accept."
      },
      "default_value": {"type": ["string", "null"], "description": "What you will use if the customer just says yes."}
    },
    "required": ["about_tool", "about_field", "question", "options", "default_value"]
  }
}

The payoff is not only fewer wrong actions. Because the question is a tool call with a field name attached, you can compute a clarification rate per argument. If create_refund.order_id triggers a question in a fifth of conversations, your lookup is returning too many near-ties and the fix is in retrieval. If book_slot.starts_at never triggers one and you serve both Bengaluru and Birmingham, the model is guessing months. When the question needs a human on your side rather than the customer, the same shape is what a review queue should ingest; see designing the queue your agent escalates into.

Pro tip

Give the model the current time and the customer's zone as a structured line at the top of context, in the same RFC 3339 form you expect back: now: 2026-09-14T11:05:00+05:30 (Asia/Kolkata). Most "next Friday" failures are not reasoning failures. They are the model doing arithmetic from an anchor it was never given, and then being marked wrong for it.

Every article here is written by a Verified Builder. Want your name on the next one?

AI Tech Connect lists AI engineers, founders and researchers across India and the UK — and the people hiring browse it to find them. Adding your profile is free.

Become a Verified Builder →

Validate at the boundary, not in the prompt

A pattern shows up in almost every agent codebase I have been asked to look at: a system prompt that grows a new paragraph every time an argument bug ships. "Always use DD/MM/YYYY." "Never pass an empty string." "Amounts must be in paise." Each line is a fossil of an incident, none is enforced, and together they push the tool definitions further down the context. Prompts are for behaviour. Contracts are for code.

The boundary layer sits between the model's emitted arguments and your actual function and does three jobs in order. It canonicalises: turns whatever came in into the one representation your system uses, with an explicit locale so that day-first dates, lakh and crore, and local phone formats are handled deliberately. It validates: types, ranges, referential integrity, cross-field rules such as "currency must match the order". And on failure it rejects with a structured error that names the field and what would have been acceptable, so the model can correct itself rather than retry the same guess.

In Python that layer is pydantic v2; in TypeScript it is zod. pydantic's validator model gives you BeforeValidator for canonicalisation (it runs "before Pydantic's internal parsing and validation") and AfterValidator and model_validator for checks that need the parsed value. Two details from the strict-mode docs matter. In default lax mode pydantic coerces '123' to 123, which hides the number-as-string class you may want to count; ConfigDict(strict=True) makes it an error. And "strict mode is looser when validating from JSON", so a date string passes validate_json even when validate_python would reject it. Pick one entry point and use it everywhere.

from datetime import datetime
from typing import Annotated, Literal
from zoneinfo import ZoneInfo

import dateparser
import phonenumbers
from pydantic import (AfterValidator, BaseModel, BeforeValidator, ConfigDict,
                      Field, ValidationError, model_validator)

# --- canonicalisers: turn what the model emitted into what the system uses ---

_SCALE = {"k": 1_000, "thousand": 1_000, "lakh": 100_000, "lac": 100_000,
          "crore": 10_000_000, "cr": 10_000_000, "m": 1_000_000, "million": 1_000_000}

def to_minor_units(v):
    """'12 lakh' -> 120_000_000 ; '£12k' -> 1_200_000 ; 1250000 -> 1250000.
    Accepts ints/strings; multiplies by 100 for a major-unit string."""
    if isinstance(v, int):
        return v                      # already minor units — trust the schema
    s = str(v).lower().replace(",", "").replace("£", "").replace("₹", "").strip()
    num, _, word = s.partition(" ")
    if not word and num[-1:] in _SCALE:  # '12k'
        num, word = num[:-1], num[-1]
    major = float(num) * _SCALE.get(word, 1)
    return int(round(major * 100))

def parse_when(v, zone: str):
    """Day-first, zone-explicit. dateparser defaults to DATE_ORDER='MDY' and
    local time; both are wrong for an India/UK product, so set them."""
    if v is None:
        return None
    dt = dateparser.parse(str(v), settings={
        "DATE_ORDER": "DMY",
        "TIMEZONE": zone,
        "RETURN_AS_TIMEZONE_AWARE": True,
        "PREFER_DATES_FROM": "future",
        "REQUIRE_PARTS": ["day", "month"],
    })
    if dt is None:
        raise ValueError(f"could not parse {v!r} as a day-first date in {zone}")
    return dt

def to_e164(v, region: str):
    n = phonenumbers.parse(str(v), region)        # region: 'GB' or 'IN'
    if not phonenumbers.is_valid_number(n):
        raise ValueError(f"{v!r} is not a valid number for region {region}")
    return phonenumbers.format_number(n, phonenumbers.PhoneNumberFormat.E164)

Currency = Literal["GBP", "INR"]
Zone = Literal["Europe/London", "Asia/Kolkata"]
REGION_FOR_ZONE = {"Europe/London": "GB", "Asia/Kolkata": "IN"}


class CreateRefundArgs(BaseModel):
    model_config = ConfigDict(strict=True, extra="forbid")

    order_id: Annotated[str, Field(pattern=r"^ord_[a-z0-9]{12}$")]
    amount_minor: Annotated[int, BeforeValidator(to_minor_units), Field(ge=1)]
    currency: Currency
    reason: Literal["damaged", "not_received", "changed_mind",
                    "duplicate_charge", "other"]
    notes: str | None
    notify_at: datetime | str | None
    timezone: Zone

    @model_validator(mode="after")
    def _cross_field(self):
        if self.notes == "":
            raise ValueError("notes must be null, not an empty string")
        if isinstance(self.notify_at, str):
            self.notify_at = parse_when(self.notify_at, self.timezone)
        if self.notify_at is not None and self.notify_at.utcoffset() is None:
            raise ValueError("notify_at must carry a UTC offset")
        return self


def guard(args: dict, order_index: dict) -> dict:
    """Boundary: canonicalise, validate, check provenance, and on failure
    return a tool_result the model can act on instead of raising."""
    try:
        a = CreateRefundArgs.model_validate(args)
    except ValidationError as e:
        return {"ok": False, "error_type": "argument_validation",
                "fields": [{"path": ".".join(map(str, err["loc"])),
                            "problem": err["msg"], "code": err["type"]}
                           for err in e.errors()],
                "hint": "Fix only the listed fields and call create_refund again."}

    order = order_index.get(a.order_id)
    if order is None:
        return {"ok": False, "error_type": "unknown_order_id",
                "fields": [{"path": "order_id", "problem":
                            "not returned by any find_orders call in this conversation",
                            "code": "provenance"}],
                "hint": "Call find_orders and copy order_id from its result."}
    if order["currency"] != a.currency:
        return {"ok": False, "error_type": "currency_mismatch",
                "fields": [{"path": "currency", "problem":
                            f"order is in {order['currency']}", "code": "cross_field"}]}
    if a.amount_minor > order["refundable_minor"]:
        return {"ok": False, "error_type": "amount_exceeds_refundable",
                "fields": [{"path": "amount_minor", "problem":
                            f"maximum is {order['refundable_minor']}", "code": "range"}]}
    return {"ok": True, "args": a.model_dump(mode="json")}

Two choices in that code are deliberate. The error return is a list of field problems, not prose: the model is far better at fixing amount_minor: maximum is 1250000 than at re-reading "your refund seems too large", and pydantic's errors() already gives you loc, msg and type. And the provenance check sits outside pydantic because it needs conversation state: the set of IDs any lookup tool actually returned in this trace.

Build the argument-extraction eval

You cannot manage what you do not score per call. An agent eval that reports "task success" will pass a conversation where the refund went to the right customer for the wrong amount. The argument eval scores the call itself, field by field, against a golden answer, and it is the only way to find out which of the twelve classes you actually have.

The Berkeley Function Calling Leaderboard makes the case for this structure. It scores calls two ways: executable evaluation, where the call is run and its result checked, and abstract syntax tree evaluation, where the call is parsed and its function name, parameter names, types and values are checked against a set of acceptable answers. The BFCL team explains why both exist: "Ideally one should use execution evaluation, but when we evaluate the answers, not all the results are easily executable ... So we use the AST as a complement." For your own tools, run against a sandbox where you can and score the parsed arguments field by field everywhere. The AST framing lets you accept 2026-09-18T09:00:00+01:00 and 2026-09-18T08:00:00Z as the same answer while rejecting 2026-09-18T09:00:00.

Golden cases per tool. For each tool, write ten to thirty conversations that should end in a call to it, each with the exact expected argument object. Include at least one case per taxonomy row that applies, cases where the correct answer is a clarification (with the expected about_field), and multi-turn cases where the user corrects a value mid-conversation. If you already run the trajectory evals in how to evaluate AI agents across trajectory, tools and outcomes, these plug straight into the tool-call layer of that harness.

Mutation testing of the phrasing. Take each golden case and generate paraphrases that change surface form but not meaning: 18/09/2026, 18 Sept, the 18th, this Friday, 9/4 and 4/9; 12 lakh, ₹12,00,000, 1.2 million rupees; £12k, 12,000 pounds, twelve grand; a time given as 10.30am IST and the same instant as 6am BST; a UK mobile with and without the leading zero, an Indian number with and without +91. Every mutation shares the golden answer. A model that passes the canonical phrasing and fails the mutation has a bug your users will find before you do.

Argument-extraction metrics. Report per tool and per field; the aggregate hides exactly the fields that are failing.
Metric Definition What it tells you
Exact match, per field Emitted value equals golden value byte-for-byte Is the model following the contract as written?
Canonicalised match, per field Equal after the boundary layer normalises both sides Is the meaning right, even if the form is not?
Argument-set accuracy Every field of the call canonicalised-matches The number that predicts a correct action; usually far below the per-field average
Clarification recall Share of genuinely ambiguous cases where the model called ask_user Is the model asking when it should? Want this high
False-clarification rate Share of unambiguous cases where it asked anyway Friction cost of the ask tool. Want this low but not zero
Provenance rate Share of identifier arguments traceable to a prior lookup result Direct measure of hallucinated IDs
Boundary-rejection rate Share of calls the validator refused, by error code Which taxonomy rows are live; the same number you monitor in production
Mutation robustness Argument-set accuracy on mutated phrasings divided by accuracy on canonical ones How much of your accuracy is real versus phrasing luck

The harness itself is small. What matters is that it scores through the same boundary layer production uses, so the numbers mean the same thing in both places, and that it records model and schema versions on every row, because argument accuracy moves when either changes.

import json
from collections import defaultdict
from dataclasses import dataclass, field


@dataclass
class GoldenCase:
    case_id: str
    tool: str
    messages: list                      # the conversation up to the call
    expected_args: dict | None          # None when the right move is to ask
    expected_ask_field: str | None = None
    mutation_of: str | None = None      # parent case_id if this is a paraphrase
    tags: list = field(default_factory=list)   # e.g. ["dmy", "ist", "lakh"]


def run_argument_eval(cases, call_model, guards, canon, model_version, schema_version):
    """call_model(messages) -> (tool_name, args) ; guards[tool](args) -> guard result ;
    canon[tool](args) -> canonical dict for comparison."""
    per_field = defaultdict(lambda: {"exact": 0, "canon": 0, "n": 0})
    rows = []
    for c in cases:
        tool, args = call_model(c.messages)
        row = {"case_id": c.case_id, "tool": c.tool, "tags": c.tags,
               "mutation_of": c.mutation_of, "model": model_version,
               "schema": schema_version, "called": tool}

        if c.expected_args is None:                      # should have asked
            row["clarified"] = (tool == "ask_user")
            row["ask_field_ok"] = (tool == "ask_user"
                                   and args.get("about_field") == c.expected_ask_field)
            rows.append(row); continue

        row["false_clarification"] = (tool == "ask_user")
        if tool != c.tool:
            row["set_ok"] = False; rows.append(row); continue   # selection bug, not ours

        g = guards[tool](args)
        row["rejected"] = not g["ok"]
        row["reject_codes"] = [f["code"] for f in g.get("fields", [])]

        got = canon[tool](args) if g["ok"] else {}
        want = canon[tool](c.expected_args)
        field_ok = {}
        for k, v in want.items():
            pf = per_field[(tool, k)]; pf["n"] += 1
            exact = args.get(k) == c.expected_args.get(k)
            cn = got.get(k) == v
            pf["exact"] += exact; pf["canon"] += cn
            field_ok[k] = cn
        row["field_ok"] = field_ok
        row["set_ok"] = all(field_ok.values()) and g["ok"]
        rows.append(row)

    summary = {
        "per_field": {f"{t}.{k}": {"exact": v["exact"] / v["n"], "canon": v["canon"] / v["n"]}
                      for (t, k), v in per_field.items()},
        "argument_set_accuracy": _rate(rows, "set_ok"),
        "clarification_recall": _rate([r for r in rows if "clarified" in r], "clarified"),
        "false_clarification_rate": _rate([r for r in rows if "false_clarification" in r],
                                          "false_clarification"),
        "boundary_rejection_rate": _rate([r for r in rows if "rejected" in r], "rejected"),
        "mutation_robustness": (_rate([r for r in rows if r["mutation_of"]], "set_ok")
                                / max(_rate([r for r in rows if not r["mutation_of"]
                                             and "set_ok" in r], "set_ok"), 1e-9)),
    }
    return summary, rows


def _rate(rows, key):
    rows = [r for r in rows if key in r]
    return sum(bool(r[key]) for r in rows) / len(rows) if rows else 0.0

Run it on every model, schema and prompt change, in the same pipeline as the rest of your regression suite; we described the wiring in putting your evals in CI. And do not let the model that produced the calls also judge them. Argument evals are one of the few places where a deterministic judge is entirely adequate, and, as we argued in building evals your agent cannot game, a deterministic judge is one the agent cannot learn to flatter.

Production monitoring

The boundary layer is also your telemetry. Every rejection it produces is a labelled argument failure with a tool name, a field path and an error code. Emit it as a metric, not just a log line.

Argument-validation-failure rate per tool, per field, per model version. FutureAGI's agent tool glossary lists "versioning tool code without versioning the schema" as a common mistake, because "a changed argument contract can silently break previously passing prompts". The inverse is just as common: a provider updates a model under a stable name and it starts emitting "High" where it used to emit "high". Tag every rejection with both versions and the regression is a one-line query.

Provenance failures as their own series. Unknown-ID rejections are a hallucination counter. They should sit near zero, and any rise deserves its own alert, because it means the model has stopped using the lookup tool and started composing identifiers.

Clarification rate per argument. Track ask_user calls by field. A rising rate is retrieval ambiguity or a less confident model; a rate that drops to zero after a change is the model having stopped asking, which is worse.

Thresholds. There is no universal number and we will not invent one. A practical start is to alert when a tool's rejection rate exceeds twice its trailing seven-day baseline, and separately on any provenance failure at all for a mutating tool. Tune from there against the cost of a wrong action in your domain.

Close the loop into the golden set. Every rejection is a candidate golden case. Capture the shape rather than the payload (the field, the error code, the pattern of the offending value, never the customer record), rewrite it as a synthetic conversation with fictional entities, and add it to the eval tagged with the model and schema versions that produced it. The discipline is the one we set out in building LLM evals from production logs: production tells you which failures are real, and the eval makes sure they stay fixed.

Common pitfalls and what to do this week

Avoid

Fixing argument bugs by adding rules to the system prompt. Each rule is unenforced, costs context on every call, and drifts out of date the moment the schema changes. If a rule is worth stating, encode it as an enum, a pattern, a range or a validator, where it cannot be ignored. The same goes for mutating tools that accept identifiers as free strings: if a tool can charge, refund, send, delete or book, its IDs must come from a lookup in the same conversation, and the boundary layer must check that they did.

Watch out

Scoring the eval with a different canonicaliser from production, or with none. If production accepts 12 lakh through a parser and the eval demands the integer, you will report failures that never reach users; if it is the other way round, you will report successes that do. One boundary layer, imported by both.

Starting from a typical agent codebase, this is a week's work, and it pays back on the first incident it prevents:

Day one. Pull a hundred recent tool calls from your traces and classify each argument against the taxonomy by hand. Two or three rows will dominate. Those are your priorities.

Day two. Rewrite the schemas for your mutating tools only: enums, minor units with a currency field, a timezone beside every date-time, patterns on IDs, additionalProperties: false, strict mode on, and one input_examples entry for anything nested.

Day three. Build the boundary layer for those tools with the pydantic pattern above, including the provenance index and the structured error return. Wire rejections to a metric labelled by tool, field, code, model version and schema version.

Day four. Write golden cases for the mutating tools, one per applicable taxonomy row, plus mutations for day-first dates, IST and BST offsets, lakh and crore, pound shorthand and local phone formats, plus the ambiguous cases whose right answer is a question.

Day five. Run the harness, fix what the per-field numbers point at, put it in CI, and put the rejection-rate alert live.

Argument extraction is not a model-quality problem you wait for the next release to solve. It is an interface-design problem, and interface design is your job. Models will keep getting better at reading what people mean; the gap between what a person in Chennai or Cardiff says and what your API needs will still be there, and every pattern above will still be the way to close it.