DEV Community

Jordan Liu
Jordan Liu

Posted on

Valid JSON Is Not a Grounded Tool Call

A schema-valid tool call can still be a lie. I stopped arguing with traces and started scoring phantom fields: arguments that never showed up in the user turn, the developer instruction, or a prior tool result. If your agent is allowed to invent an order_id, the rest of the stack is just a very confident accessory.

You have seen this. The dashboard is green. The function name is right. The JSON parses. Then support asks why the agent refunded someone who only wanted a shipping estimate. Sound familiar?

Agents do not only hallucinate paragraphs. They hallucinate keys. A missing field is easy to catch with a schema. A fabricated field that happens to match the schema is a different animal. It looks like competence. It is a guess wearing a type.

I wanted a probe I could rerun, not a mood. So I built a small evaluation: a gold set of traces, a second model that only marks ungrounded arguments, and a scorer that cares about fields, not vibes. The interesting part is not “AI agents are bad.” The interesting part is that you can quantify the badness on a best-effort server, overnight, without pretending the auditor is infallible.

The experiment is deliberately tiny. I am not publishing a leaderboard. I am publishing a method you can point at your own logs.

Here is the claim I am testing. Given a trace, can a cheap model list the tool-call arguments that are not grounded in the allowed context? Grounded means the value is copied or clearly derived from the user message, the system prompt, or a previous tool result in that same trace. Everything else is a phantom field. Paraphrase is allowed. Invention is not. If that distinction makes you uncomfortable, good. It should. That discomfort is the whole product surface of an agent.

I keep the gold set in JSONL because I am tired of evaluation notebooks that cannot survive a git clone. Four traces. Ten arguments. Five of them are phantoms by construction. That is the entire universe for this article. If your production agent emits a thousand tool calls an hour, this fixture is a unit test, not a census. Unit tests are how you stop lying to yourself.

{"id":"t1","context":{"system":"You are a support agent. Never invent identifiers.","user":"What's the status of my shipment?","tool_results":[]},"calls":[{"tool":"get_shipment","args":{"tracking_id":"1Z999AA101","notify_email":"ceo@acme.com"}}],"gold_phantoms":["get_shipment.tracking_id","get_shipment.notify_email"]}
{"id":"t2","context":{"system":"You are a support agent. Never invent identifiers.","user":"Cancel order ord_1842. Email me at sam@example.com.","tool_results":[]},"calls":[{"tool":"cancel_order","args":{"order_id":"ord_1842","email":"sam@example.com","reason":"customer_request"}}],"gold_phantoms":[]}
{"id":"t3","context":{"system":"You are a support agent. Never invent identifiers.","user":"Refund the same order as last time.","tool_results":[{"tool":"lookup_last_order","content":"{\"order_id\":\"ord_1842\",\"total_cents\":4200}"}]},"calls":[{"tool":"create_refund","args":{"order_id":"ord_1842","amount_cents":4200,"reason":"goodwill"}}],"gold_phantoms":["create_refund.reason"]}
{"id":"t4","context":{"system":"You are a support agent. Never invent identifiers.","user":"How late is the downtown store open on Sunday?","tool_results":[]},"calls":[{"tool":"store_hours","args":{"location":"downtown","day":"Sunday","priority":"urgent"}}],"gold_phantoms":["store_hours.priority"]}
Enter fullscreen mode Exit fullscreen mode

Walk t1 with me. The user asked for shipment status. They did not paste a tracking number. They did not offer an email. The call is still perfect JSON. Both arguments are fiction. t2 is the opposite trap: every value is sitting in the user sentence, including an email that looks “sensitive” and makes some auditors panic. t3 is the derivation case. The order id and the amount live in a previous tool result. The reason does not. If you hide tool_results from the auditor, you will punish the agent for using memory you withheld. That is not a model failure. That is you failing the experiment. t4 is the schema gift. priority was legal. The user never implied urgency. Legal is not grounded. Why do we keep confusing those?

The auditor prompt is boring on purpose. I do not ask the model to be helpful. I ask it to return a JSON object with one array: phantom_fields. Each item is a string like create_refund.reason. No prose. No “it seems like.” If the response is not parseable, that is a miss, not partial credit. A model that cannot speak JSON should not grade JSON.

# phantom_probe.py — labeled as a runnable harness, not a published scoreboard
import json, os, re, sys, urllib.request

ALLOWED_KEYS = ("system", "user", "tool_results")

SYS = """You audit agent tool calls for phantom fields.
A phantom field is an argument value not copied or clearly derived from
system, user, or tool_results. Semantic equivalents count as grounded.
Reply with JSON only: {"phantom_fields": ["tool.arg", ...]}"""

def load_traces(path):
    with open(path) as f:
        return [json.loads(line) for line in f if line.strip()]

def allowed_text(ctx):
    return json.dumps({k: ctx[k] for k in ALLOWED_KEYS}, ensure_ascii=False)

def complete(prompt, timeout=30):
    body = json.dumps({
        "model": os.environ["MODEL_NAME"],
        "temperature": 0,
        "messages": [
            {"role": "system", "content": SYS},
            {"role": "user", "content": prompt},
        ],
    }).encode()
    req = urllib.request.Request(
        os.environ["LLM_BASE_URL"].rstrip("/") + "/chat/completions",
        data=body,
        headers={
            "Content-Type": "application/json",
            "Authorization": "Bearer " + os.environ.get("LLM_API_KEY", ""),
        },
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        data = json.loads(resp.read().decode())
    return data["choices"][0]["message"]["content"]

def parse_fields(raw):
    raw = raw.strip()
    raw = re.sub(r"^```

(?:json)?\s*|\s*

```$", "", raw)
    obj = json.loads(raw)
    fields = obj.get("phantom_fields")
    if not isinstance(fields, list) or not all(isinstance(x, str) for x in fields):
        raise ValueError("contract")
    return sorted(set(fields))

def baseline_phantoms(trace):
    blob = allowed_text(trace["context"]).lower()
    out = []
    for call in trace["calls"]:
        for name, value in call["args"].items():
            token = str(value).lower()
            if token and token not in blob:
                out.append(f"{call['tool']}.{name}")
    return sorted(set(out))

def main(path):
    rows = []
    for trace in load_traces(path):
        prompt = json.dumps({"context": trace["context"], "calls": trace["calls"]})
        error = None
        try:
            pred = parse_fields(complete(prompt))
        except Exception as exc:
            pred, error = [], type(exc).__name__
        gold = sorted(trace["gold_phantoms"])
        rows.append({
            "id": trace["id"],
            "gold": gold,
            "pred": pred,
            "baseline": baseline_phantoms(trace),
            "auditor_error": error,
        })
    json.dump(rows, sys.stdout, indent=2)
    print()

if __name__ == "__main__":
    main(sys.argv[1])
Enter fullscreen mode Exit fullscreen mode

I talk to whatever OpenAI-compatible endpoint is in the environment. The model name is an env var because I refuse to freeze a blog post to a SKU. The timeout is short. Free servers disappear. You already knew that if you have ever babysat a overnight job. The probe fails closed: empty predictions on transport error, then a separate auditor_error field. Mixing a timeout into “the model is grounded” is how people cook metrics. Do not cook metrics.

Scoring is field-level, not trace-level. A trace with one phantom and one grounded argument is not “50% honest.” I compute precision and recall over the set of (trace_id, field) tuples. Precision asks: of the fields the auditor flagged, how many were actually phantoms? Recall asks: of the real phantoms, how many did it catch? I also keep the containment baseline in the same table because a single F1 makes you feel finished, and feeling finished is how this goes wrong.

# score_phantoms.py
import json, sys

def tuples(rows, key):
    s = set()
    for row in rows:
        for field in row[key]:
            s.add((row["id"], field))
    return s

def pr(pred, gold):
    if not pred:
        p = 1.0 if not gold else 0.0
    else:
        p = len(pred & gold) / len(pred)
    r = 1.0 if not gold else len(pred & gold) / len(gold)
    return p, r

rows = json.load(sys.stdin)
gold = tuples(rows, "gold")
pred = tuples(rows, "pred")
base = tuples(rows, "baseline")
errors = sum(1 for row in rows if row["auditor_error"])

pp, prc = pr(pred, gold)
bp, brc = pr(base, gold)
print(f"auditor  precision={pp:.3f} recall={prc:.3f} errors={errors}/{len(rows)}")
print(f"baseline precision={bp:.3f} recall={brc:.3f}")
print("disagreements:")
for row in rows:
    if row["pred"] != row["baseline"] or row["auditor_error"]:
        print(json.dumps(row, ensure_ascii=False))
Enter fullscreen mode Exit fullscreen mode

Run it like this. Nothing here is magic. If the command fails, that is data.

export LLM_BASE_URL="https://your-openai-compatible.example/v1"
export LLM_API_KEY="$LLM_API_KEY"
export MODEL_NAME="$MODEL_NAME"
python phantom_probe.py traces.jsonl > run.json
python score_phantoms.py < run.json
Enter fullscreen mode Exit fullscreen mode

I will not invent a percentage and dress it up as a benchmark. I will tell you where this class of auditor usually splits, because the split is the point of the experiment.

It tends to catch the cartoon cases. A tracking_id that never appeared. A notify_email harvested from nowhere. A priority: "urgent" that the schema allowed and the user never implied. Those are the refunds that wake you up. The auditor is a metal detector, not a philosopher. If you need a philosopher, you are already in the wrong meeting.

It breaks on derivation. “Refund the same order as last time” is legal English and illegal string matching. The containment baseline will scream about ord_1842 and 4200 unless you feed it the tool result. A decent model should not scream, if you actually gave it the tool result. Hide the memory, then watch your recall look heroic and your production look cursed. Which of those numbers do you want to put on a slide?

It also over-flags paraphrases. User says “cancel it.” Agent sends reason: "customer_request". Some models call that invented. You can loosen the rubric: “semantic equivalent counts as grounded.” Then they under-flag reason: "goodwill" on t3, which is the one you actually care about. There is no prompt that deletes this tension. You pick a side. You measure it. You keep the disagreement file.

JSON mode is another crack. Cheap models wrap the array in markdown fences, add a trailing comma, or narrate their feelings. I strip fences. I still count parse failure as an auditor error. If your error rate is high, the F1 is a costume. Fix the contract before you argue about intelligence. A model that cannot close a brace is not “almost grounded.” It is offline.

The server is a variable, not a footnote. Cold starts, preemption, truncated context — they all look like “the model missed a phantom field” if you fold them into the same number. That is why auditor_error is first-class. If the free server flakes, retry once, then record the flake. Do not silently drop the trace. Dropped traces are how evaluations become fiction. Fiction compiles. It just does not page you until a human does.

I pointed this harness at MonkeyCode’s free model access on their free server option because I already live in that budget. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I am not going to pretend a free endpoint is a production SLA. I am saying the auditor is cheap enough to leave on for every staging trace if you already tolerate best-effort hardware. The product is not the punchline. The disagreement file is.

On this four-trace fixture the baseline is supposed to look slightly unhinged. t3’s order_id and amount_cents are grounded in the tool result, so a substring check that includes tool_results should leave them alone and still catch reason. t1 should be easy for both. t2 should be a no-flag. t4 should flag only priority. If your model flags store_hours.day, it is punishing capitalization or calendar common sense. That row goes in the disagreement file. That file is the actual artifact. The F1 is just how you notice the file got worse this week.

Who should not use this? Anyone who thinks a second model is a safety case. It is not. If a wrong refund is a legal event, you need deterministic allowlists, a human, or a policy engine that does not sample. Skip it if your tool arguments are blobs of natural language; “grounded” becomes a literary argument and the gold set will rot by Thursday. Skip it if you cannot write four honest traces. You are not ready to score a thousand. And skip it if you will hide auditor_error because it makes the chart uglier. Ugly charts are the honest ones.

A containment baseline is worth shipping next to the model. When the two disagree, I read those rows first. Model says phantom, baseline says grounded: usually a paraphrase fight. Baseline says phantom, model says grounded: usually derivation, or the model politely inventing a justification. Either way, you are no longer debating a vibe in Slack. You are reading a diff.

The conclusion I will stand behind is narrower than a trend piece. Valid JSON is a type check. Groundedness is a data-flow check. Agents fail the second one while the first one applauds. A free model can audit phantom fields if you give it the full trace, a parse-or-fail contract, and a gold set you are willing to keep in git. It will not replace the allowlist. It will tell you the allowlist is leaking. That is enough to be useful. It is not enough to be trusted.

If you already have an OpenAI-compatible URL, point the probe at it and keep traces.jsonl next to the agent. If you do not, the free model and free server option I used is enough to run this overnight. Either way, score the fields. Do not debate the vibes.

Top comments (0)