An Agent With Tools

An agent with four tools answers 80% of questions correctly and picks the right tool 96.7% of the time. The gap is arguments, and nobody benchmarks it.

In the Real World · AI · Core · 2 days

An agent with four tools answers 80.0% of sixty operations questions correctly. It picks the right tool on 96.7% of them. Those two numbers are not in tension: it chooses correctly and then passes arguments that are wrong, which is a failure nobody benchmarks and every tool returns a confident answer to.

The situation. Marlowe Freight is a freight forwarder. Its operations desk answers the same few hundred questions a week about where a consignment is, what the duty will be, when a depot is open and what a shipment will cost.

What the business is left with. A working agent, four scores rather than one, and a written list of the questions it must never be allowed to answer on its own.

Attempt it first. The brief has the same four tools and the same sixty questions, with the correct tool call for each.

Section 1Problem Definition

An agent that calls tools has four separate ways to be wrong and one number that hides all of them. It can pick the wrong tool. It can pick the right tool and pass the wrong arguments. It can get both right and summarise the result badly. And it can answer a question that no tool it holds can answer, which is the only one of the four that produces a confident, plausible, entirely invented reply.

So this project scores four things separately, and the headline accuracy is the least useful of them.

What is measuredWhat it tells youWhy the headline hides it
Tool choiceWhether the agent understood the questionA wrong tool often still returns something the model can talk around
ArgumentsWhether it understood the specificsA tool given wrong arguments answers cheerfully and correctly for those arguments
Final answerWhat the user actually seesThis is the headline, and it can be right by luck
RefusalWhether it knows the limits of what it holdsNever measured, because the questions are usually all answerable

The eight questions that matter most are the ones with no answer

Fifty two of the sixty questions can be answered with the four tools. Eight cannot: they ask about drivers, invoices, insurers, emissions, staffing, exchange rates and rerouting. There is no tool for any of it. The correct behaviour is to say so, and the interesting question is whether an agent ever does.

Section 2Data Collection

A small deterministic world: 40 consignments, 6 depots, a duty table and a pricing formula. Four tools read it, and every one is a pure function, so the same call always returns the same thing.

# The entire tool description the model ever sees, verbatim.
#
#   track(consignment_id)                status, location, eta_days,
#                                        weight_kg, commodity, origin
#   duty(commodity, origin, weight_kg)   customs duty in pounds
#   depot(city, day)                     opening hours
#   quote(destination, weight_kg)        shipping price in pounds

That block is not a summary of the tools. It is the entire description the model ever sees, which is deliberate: an agent that needs a page of instructions per tool does not scale to the forty tools this desk would eventually want.

Sixty questions, five kinds

import pandas as pd

q = pd.read_csv("data/marlowe-questions.csv")
print(q.kind.value_counts().to_dict())
print(q[["kind", "question"]].head(4).to_string(index=False, max_colwidth=52))
{'single': 20, 'trap': 12, 'two_tool': 12, 'units': 8, 'unanswerable': 8}
  kind                                question
single      Any idea where CN-4800 has got to?
single   Whereabouts is CN-4807 at the moment?
single Where is consignment CN-4814 right now?
single      Any idea where CN-4821 has got to?
KindHow manyWhat it tests
single20One tool, said plainly
trap12One tool, worded so the obvious keyword points at a different one
two_tool12The answer needs one tool’s output as another tool’s input
units8The question gives tonnes, the tool takes kilogrammes
unanswerable8No tool can answer it. The correct move is to say so

The first version of these questions was thrown away

Every question was asked in one fixed shape, and a regular expression written against those shapes extracted every argument correctly. A baseline scoring one hundred per cent on arguments is not a strong baseline, it is a sign that the test measures template matching. The questions now vary in phrasing, name commodities the way a person would, and give weights in whatever unit came to hand.

# Four questions from the file, one of each kind:
#
#   Any idea where CN-4800 has got to?
#   What do we pay to clear machine parts from Brazil, 87 kilos?
#   Duty on 0.469 t of porcelain tiles out of India?
#   Saturday hours for wherever CN-5045 is sitting?

Note what that does to the arguments. The duty tool takes a commodity from a fixed list, and the questions say machine parts and porcelain tiles. It takes kilogrammes, and the questions say tonnes. Extracting arguments here requires understanding rather than copying, which is the point.

Section 3Data Preprocessing

3aDuplicates and schema checks

assert q.question_id.is_unique
assert set(q.correct_tool) <= {"track", "duty", "depot", "quote", "none",
                               "track+duty", "track+depot"}
assert (q.kind == "unanswerable").sum() == 8

print("questions", len(q), "| answerable", int((q.kind != "unanswerable").sum()))
questions 60 | answerable 52

3bHandling categorical mess

The tools take enumerated values and the questions do not use them. That mismatch is the preprocessing problem in this project, and it is not one you can solve in preprocessing, because it happens inside the agent at runtime.

SYNONYMS = {"machine parts": "machinery", "porcelain tiles": "ceramics",
            "coffee beans": "coffee", "fabric rolls": "textiles"}

# This table exists in the write up and not in the agent. Building it is the
# maintenance cost that the whole comparison is about: every new supplier
# vocabulary means another line here, forever.

3cDealing with outliers

No numeric outliers. There is one structural check: every question that needs two tools has to start with the same one, because nothing except track turns a consignment id into a commodity and a weight.

import numpy as np

q["first_tool"] = np.where(q.kind == "two_tool", "track", q.correct_tool)
q.loc[q.kind == "unanswerable", "first_tool"] = "none"

for kind, tools in q.groupby("kind").first_tool.unique().items():
    print("%-13s %s" % (kind, list(tools)))
single        ['track', 'duty', 'depot', 'quote']
trap          ['duty', 'track', 'quote']
two_tool      ['track']
unanswerable  ['none']
units         ['duty', 'quote']

3dHandling missing values

A tool called with a missing argument returns an error rather than a guess, which matters: it means a wrong argument is visible in the trace instead of silently becoming a default.

from tools import duty

print(duty(commodity="machine parts", origin="Brazil", weight_kg=87))
print(duty(commodity="machinery", origin="Brazil", weight_kg=None))
print(duty(commodity="machinery", origin="Brazil", weight_kg=87))
{'error': 'unknown commodity or origin'}
{'error': 'weight_kg must be a number of kilogrammes'}
{'duty_pounds': 217.5}

3eHandling skewed data

Nothing here is skewed in the statistical sense. What is skewed is the cost of the five kinds of question: the eight unanswerable ones are a seventh of the set and carry most of the risk, because a wrong answer to those is invented rather than merely incorrect.

3fData types and normalisation

Arguments are compared the way the tool reads them, not as raw JSON, so a weight given as a string and the same weight as a number both count as correct.

def norm(args):
    out = {}
    for k, v in (args or {}).items():
        if v is None:
            continue
        out[k] = round(float(v), 2) if k == "weight_kg" else str(v).strip().lower()
    return out

print(norm({"weight_kg": "87", "commodity": "Machinery", "origin": None}))
{'weight_kg': 87.0, 'commodity': 'machinery'}

Section 4Exploratory Data Analysis

4aTarget variable analysis

There are four targets and they disagree with each other, which is the reason the project exists. The tool a question needs, the arguments it needs, the answer it should produce and whether it should be answered at all are four different labels on the same sixty rows.

4bNumerical variables

The only numeric argument is weight, and it appears in four surface forms across the corpus: kilogrammes with a space, kilogrammes without, kilos, and tonnes. Two of those four need no conversion and two do.

4cCategorical variables

Six commodities, five origins, six cities. Each commodity is written three ways, so a lookup on the exact enumerated value succeeds on roughly a third of mentions.

4dRelationships between variables

The relationship that turns out to matter is between the presence of a recognisable entity in a question and whether the agent calls a tool at all. It is measured in section 7 and it is the strongest single effect in the project.

4eTesting our hypotheses

HypothesisHow it is testedResult
Choosing the right tool is the hard partTool accuracy against argument accuracy on the same questionsRejected. The best agent picks the right tool every time and still gets 9 argument sets wrong
A multi-step loop is generally better than one callSame agent, one call against up to threePartly. It is worth +25.0 points overall and all of that comes from one kind of question
A keyword router is a reasonable cheap substituteSame questions, no model anywhereRejected. 43.3% against 80.0%
An agent knows when it has no tool for the jobRefusal rate on the eight unanswerable questionsRejected, and it depends on the wording rather than the question

4fSubgroups

Every number below is reported by question kind as well as overall, because the five kinds behave so differently that the average describes none of them.

Section 5Feature Engineering

5aThe leakage trap

The trap here is writing the baseline after looking at the questions

The keyword router is a fair baseline only if it is written from the tool descriptions and a reasonable reading of the domain. Written against the test questions instead, it scores whatever you want it to score, and the first version of this project proved it by scoring one hundred per cent on argument extraction. The router below has never been adjusted to fix a question it got wrong.

5bNew features

The router needs three things out of a question: an identifier, a city and a weight. All three are regular expressions.

ID_RE    = re.compile(r"CN-\d{4}", re.I)
CITY_RE  = re.compile("|".join(WORLD["depots"]), re.I)
KG_RE    = re.compile(r"([\d,]+(?:\.\d+)?)\s*(kg|kilo)", re.I)
TONNE_RE = re.compile(r"([\d,]+(?:\.\d+)?)\s*(tonne|ton)", re.I)

# tonnes are converted, because anyone writing this would do that much
weight = float(kg.group(1)) if kg else float(tonne.group(1)) * 1000

5cEncoding

Tool choice is a five way decision: four tools or none. The router makes it with ordered rules, most specific vocabulary first.

def router(question):
    q = question.lower()
    if re.search(r"\bduty|customs|tariff\b", q):
        return "duty", {...}
    if re.search(r"\bopen|hours|saturday|sunday|depot\b", q) and city:
        return "depot", {...}
    if ident:
        return "track", {"consignment_id": ident.group(0).upper()}
    if re.search(r"\bquote|cost|charge|price|ship|move|send\b", q) and city:
        return "quote", {...}
    return "none", {}

5dFeature selection

The model agents get no features at all. They get the four line tool description, the question, and a schema that forces a tool name and an arguments object. That asymmetry is the comparison: the router encodes what somebody knew about the domain, and the agent has to work it out.

Section 6Model Selection

Three agents, and the two model agents are run on both local models, giving five columns in every table below.

AgentTool calls allowedModel calls per question
Keyword routerOneNone
One callOneTwo: choose a tool, then answer
LoopUp to threeTwo to four
CHOICE = {"type": "object",
          "properties": {"tool": {"type": "string"},
                         "args": {"type": "object"},
                         "reason": {"type": "string"}},
          "required": ["tool", "args"]}

# The schema forces a tool name and an args object. It does not force the
# tool to exist, and it cannot force the arguments to be right, which is
# where the rest of this article ends up.

The loop is told it can stop

After each tool call the results so far are appended to the prompt with an explicit instruction: if these already answer the question, return the tool none. Without that an agent given three steps uses three steps, and the comparison measures patience rather than judgement.

cache = Cache("agent_cache.json")

got = cache.ask_json(CHOOSE_PROMPT.format(q=question, history=history),
                     CHOICE, model="qwen3.6:latest", num_predict=300)
cache: {'calls': 587, 'hits': 587, 'misses': 0, 'empty_responses': 0, 'unparsed_responses': 0}

Section 7Model Training

7aBaselines

The router is the baseline and it is not a bad one. It picks the right tool on 80.0% of the sixty questions and answers 43.3% of them correctly. The gap between those two numbers is the whole subject of this article, and it appears in the baseline before any model is involved.

7bComparing candidates

rows = []
for name, out in runs.items():
    d = pd.DataFrame(out)
    d["tool_ok"]   = d.first_tool.values == q.first_tool.values
    d["args_ok"]   = [args_match(e, g) for e, g in zip(q["args"], d["args"])]
    d["answer_ok"] = [answer_matches(c, a) for c, a in zip(q.correct_answer, d.answer)]
    rows.append({"method": name,
                 "tool":   round(100 * d.tool_ok.mean(), 1),
                 "args":   round(100 * d[answerable].args_ok.mean(), 1),
                 "answer": round(100 * d.answer_ok.mean(), 1),
                 "steps":  round(d.steps.mean(), 2)})

print(pd.DataFrame(rows).to_string(index=False))
               method   tool   args  answer  steps
       Keyword router   80.0   76.9    43.3   0.87
    One call, Gemma 4   86.7   71.2    50.0   0.88
        Loop, Gemma 4   86.7   71.2    60.0   0.98
   One call, Qwen 3.6   96.7   82.7    55.0   0.90
       Loop, Qwen 3.6   96.7   82.7    80.0   1.30
Final answer accuracy on all sixty questions018.8837.7656.6475.5294.443.3%Keyword router50.0%1 call Gemma 460.0%loop Gemma 455.0%1 call Qwen 3.680.0%loop Qwen 3.6Four tools, the same sixty questions, the same scoring. The baseline uses no model at all.

The best agent answers 80.0% of the questions. That is the number a demo shows you.

The two numbers that should not both be true

The best agent picks the right tool on 96.7% of all sixty questions, and on every single one of the 52 that are answerable. Its arguments are right on 82.7%.

answerable = q.kind != "unanswerable"
for name, d in per_q.items():
    a = d[answerable]
    print("%-22s tool wrong %2d | tool right args wrong %2d | both right %2d"
          % (name, (~a.tool_ok).sum(),
             (a.tool_ok & ~a.args_ok).sum(), (a.tool_ok & a.args_ok).sum()))
Keyword router         tool wrong  6 | tool right args wrong  6 | both right 40
One call, Gemma 4      tool wrong  6 | tool right args wrong  9 | both right 37
Loop, Gemma 4          tool wrong  6 | tool right args wrong  9 | both right 37
One call, Qwen 3.6     tool wrong  0 | tool right args wrong  9 | both right 43
Loop, Qwen 3.6         tool wrong  0 | tool right args wrong  9 | both right 43
Where the fifty two answerable questions go wrong-1.350+0.990+3.330+5.670+8.010+10.350Keyword router1 call Gemma 4loop Gemma 41 call Qwen 3.6loop Qwen 3.6Wrong toolRight tool, wrong argumentsBoth models pick tools better than they fill them in. For the stronger one, tool choice never fails at all.

Choosing the tool is the part that works. The arguments are where the agent quietly goes wrong, and a tool with wrong arguments still answers.

For Loop, Qwen 3.6 the wrong tool column is 0. Every failure it has on an answerable question is an argument it filled in wrongly, and a tool handed the wrong arguments does not complain. It returns the duty on the wrong commodity, or the price for the wrong weight, correctly and to two decimal places.

Where the arguments break

print(pd.DataFrame({m["method"]: m["args_by_kind"]
                    for m in methods}).to_string())
          Keyword r  One call,  Loop, Gem  One call,  Loop, Qwe
single         95.0       85.0       85.0       85.0       85.0
trap           50.0       66.7       66.7       75.0       75.0
two_tool      100.0       58.3       58.3      100.0      100.0
units          37.5       62.5       62.5       62.5       62.5

Plainly worded questions are fine for everyone. The wrong unit costs the router 62.5% of those questions and the models about a third, and the misleading wording costs everyone. Note the router’s 100.0% on two-tool questions next to its 0.0% on answering them: it extracts the consignment id perfectly and then has nowhere to go with it.

7cHyperparameter tuning

The only setting worth tuning is how many tool calls the agent may make, and the answer is not the same for every question.

for model, e in loop_effect.items():
    print("%-10s one call %5.1f%%  loop %5.1f%%  delta %+.1f  extra steps %+.2f"
          % (model, e["one_call"], e["loop"], e["delta"], e["extra_steps"]))
    print("           two tool %5.1f -> %5.1f   single %5.1f -> %5.1f"
          % (e["two_tool_one_call"], e["two_tool_loop"],
             e["single_one_call"], e["single_loop"]))
Gemma 4    one call  50.0%  loop  60.0%  delta +10.0  extra steps +0.10
           two tool   0.0 ->  50.0   single  70.0 ->  70.0
Qwen 3.6   one call  55.0%  loop  80.0%  delta +25.0  extra steps +0.40
           two tool   0.0 -> 100.0   single  85.0 ->  90.0

The loop is worth +25.0 points for the stronger model and +10.0 for the weaker one, and essentially all of it comes from one kind of question. Two-tool questions go from 0.0% to 100.0%. Plain single-tool questions go from 85.0% to 90.0%, which is one question.

Two-tool questions: the only place the loop earns its keep-15.000+11.000+37.000+63.000+89.000+115.000Gemma 4Qwen 3.6One callUp to three callsA question that needs one tool’s output as another tool’s input cannot be answered in one call, so this is the comparison the loop exists for.

The loop is not generally better. It is better at the thing it is for, and neutral everywhere else.

Which means the setting is per question, not per agent

Roughly a fifth of this corpus needs more than one call. Allowing three calls for all of it costs +0.40 steps per question on average and buys nothing on the four fifths that do not. If latency matters, the honest design is one call by default with an escalation, not a loop by default.

7dFinal evaluation

The eight questions with no answer

Every agent refuses some of them, and none of them refuses on the right basis.

for r in unanswerable_detail:
    print("%d/%d  %s" % (r["refused_count"], 5, r["question"]))
2/5  Who is the driver assigned to CN-4800?
5/5  What was the euro to sterling rate last Tuesday?
4/5  Has the customer paid the invoice for CN-4807?
4/5  Which insurer covers the Rotterdam depot?
2/5  What is the carbon footprint of CN-4814?
0/5  Can we reroute CN-4821 to Leeds tonight?
4/5  How many staff work at the Felixstowe depot?
5/5  What did we quote this customer last year?

Read the bottom of that list. Can we reroute CN-4821 to Leeds tonight? is refused by none of the five. It asks the agent to do something, not to look something up, and there is no tool that changes anything in the world. Every agent calls track on it and answers from whatever comes back.

The pattern across all eight is mechanical, and it has nothing to do with whether a tool can answer.

named = [r for r in detail if re.search(r"CN-\d{4}|" + cities, r["question"])]
none  = [r for r in detail if r not in named]

print("names a consignment or depot : refused by %.2f of 5 on average" %
      np.mean([r["refused_count"] for r in named]))
print("names neither                : refused by %.2f of 5 on average" %
      np.mean([r["refused_count"] for r in none]))
names a consignment or depot : refused by 2.67 of 5 on average
names neither                : refused by 5.00 of 5 on average
The eight unanswerable questions, by how many agents refused-3.250-1.950-0.650+0.650+1.950+3.250+2.5What was the euro to sterling rate last Tues+2.5What did we quote this customer last year?+1.5Has the customer paid the invoice for CN-480+1.5Which insurer covers the Rotterdam depot?+1.5How many staff work at the Felixstowe depot?-0.5Who is the driver assigned to CN-4800?-0.5What is the carbon footprint of CN-4814?-2.5Can we reroute CN-4821 to Leeds tonight?Above the line, most agents refused. Below it, most called a tool anyway. The questions naming a consignment are the ones below.agents refusing, relative to half

Refusal tracks whether the agent recognises something in the question, not whether it holds a tool that answers it.

Every unanswerable question that names nothing the agent recognises is refused by all 5 agents, without exception. Every one that names a consignment or a depot is refused by 2.67 of 5 on average. A familiar identifier is enough to start a tool call, and once a tool has returned something, the agent writes an answer out of it.

What to actually do

Score arguments separately from tool choice, and do it before shipping. The best agent here chooses correctly every time and is wrong about the arguments on 9 of 52 questions. A benchmark that reports tool selection accuracy would have called this agent perfect.

Constrain the arguments, not just the shape. The schema forces an args object and cannot force the commodity to be one of six or the weight to be in kilogrammes. An enum in the schema and a unit named in the parameter would remove most of what fails here, and neither needs a better model.

Do not rely on an agent to refuse. It refuses when it recognises nothing, which is the easy case. Put the list of things the tools cannot do in front of the tool call, and make anything that asks to change the world route to a person, because no agent in this project declined to try.

Allow a second call, not a loop. One kind of question needs it, the rest do not, and the difference is measurable in advance.

Section 8Documentation and Handoff

What ships is the harness and the four scores, not a single accuracy figure.

ScoreBest agentWhat a bad result here looks like in production
Tool choice96.7%The agent answers a different question than the one asked
Arguments82.7%A correct-looking answer about the wrong shipment, commodity or weight
Final answer80.0%The user is told something untrue
Refusal75.0% of the eightThe agent invents an answer about drivers, invoices or emissions

The checks that run before any of this is believed

  • Every figure is recomputed from the cached model responses by a second route, and no model is called during the gate.
  • The router is reimplemented in the gate and has to produce the same tool and arguments on all sixty questions.
  • Every tool is called independently and checked against the world file.
  • The claim that a named entity drives refusal is asserted, not just printed: if the questions naming nothing are not all refused, the build fails.
  • Every code block in this article is extracted and executed in document order.

One number in this article is deliberately unflattering to the method the project recommends. The best agent still answers 12 of the sixty questions wrongly. This is not a solved problem, and an operations desk that deploys it without the refusal list is going to tell somebody the wrong duty on a real shipment.

Generate the checks this agent needs

Monitoring and Drift Code Generator

The recommendation here is a second scoring dimension and a standing refusal list, both of which have to be re-measured as the tool set grows. This generates monitoring and drift checks calibrated against your own data.

Open the generator

Free, no signup. Runs in the browser.

The models doing the tool choosing

Deep Learning Cheatsheet

Both agents are decoder transformers constrained to a response schema. This is every architecture and training concept on one page, including what constrained decoding does and, importantly, what it cannot enforce.

Get the cheatsheet

Free, one page, LAD branded. No signup.

Add a Comment

Leave a Reply

Subscribe to My Newsletter

Subscribe to my email newsletter to get the latest posts delivered right to your email. Pure inspiration, zero spam.