All articles

Agent

Mechanism: CallTool — One Tool to Bind Them

13 min read

Pixie's on-device executor can use five different tools, but it is only ever handed one. That one tool is call_tool, a meta-tool whose entire job is to run other tools. This article is about why a small model works better when you take its toolbox away and give it a concierge instead — and about the plumbing that makes the trick reliable: one-line tool signatures, disposable single-tool sessions, hard call budgets, and a trace tag on every dispatch.

This is a mechanism deep-dive under The L1–L2–L3 Harness, which explains why Pixie splits one request across three model roles — a planner (L1), a skill-follower (L2), and tool-agents (L3) — instead of asking one session to do everything. Here we zoom all the way in on the joint between L2 and L3: the CallTool mechanism. Which tools are on the menu in the first place is the subject of Skills, Tools, and the @-Namespace; what the individual tools like check_notebook and grep_memory actually search is covered in Mechanism: Memory & the Notebook.


1. The cost nobody bills you for: tool schemas

First, a piece of vocabulary. When you attach a tool to a language model, you do not just tell it the tool's name. You hand it a schema — a structured description of every argument the tool accepts: names, types, allowed values, and a paragraph of guidance per field so the model fills them in correctly. The model reads all of that as part of its input, every single turn, whether or not it ends up calling the tool.

On a big cloud model with a huge context window, that cost disappears into the noise. On Pixie's on-device agent it does not. The executor runs on Apple's built-in system model through the FoundationModels framework, inside a context window of about 4,096 tokens — and a full tool schema costs on the order of 200 tokens. Bind five tools and you have spent a quarter of the entire window on argument documentation before the user's question, the skill's steps, or a single tool result has arrived. Every tool result then lands in the same transcript, and the window only shrinks from there.

Worse, the schemas do not just cost space — they cost attention. A small model juggling five argument formats while also following a skill's step list makes more mistakes at both jobs. The observation behind this whole mechanism is that those are two different skills:

  • deciding which tool to use next and what you want from it, and
  • crafting a well-formed call against one tool's exact argument format.

Pixie gives each job to a different session. L2 decides; L3 crafts. CallTool is the seam between them.


2. What L2 actually sees: signatures, not schemas

The L2 skill-follower's instructions contain the plan rationale from the planner, the chosen skill's step list — and, for each tool the plan bound, a single signature line. A signature is deliberately shaped like a function prototype from a header file: the name, the key argument, and a terse statement of purpose. These are the real ones, verbatim from SkillTool.signature in the codebase:

check_notebook(query) — search your distilled private Notebook (facts about the
    user, a page per person in their life, standing instructions, conversation
    digests); empty query browses recent notes
find_file(query) — find one of the user's own files (docs/scans/photos/recordings
    across their devices) by description and attach the best match; searches all
    their devices
grep_memory(query) — last-resort search of your full long-term memory log (past
    chats, journal, posts, messages, calls) for older/broader things not in the
    Notebook
current_date() — today's date plus concrete YYYY-MM-DD ranges for relative
    expressions (today/this week/next week/…); no args
queryCalendar(when) — list the user's calendar events (title-first) for a time
    range you describe in natural language

One line each. Enough to choose with, nowhere near enough to call with — and that is the point. The full argument-crafting rules live one layer down, where L2 never sees them.

There is a second, quieter filter here. Each tool has an l2Exposed flag, and not every bound tool makes the list. list_devices — the plumbing that resolves a device name to a device id for cross-device file search — is hidden from L2 entirely. L3's find_file defaults to searching all of the user's devices in code, so L2 never has to reason about device scoping at all. A whole category of decision was deleted from the small model's plate by making the right default a property of the runtime instead of the prompt.


3. The meta-tool itself

With the signatures in its instructions, L2's session is bound with exactly one FoundationModels tool. Here is its shape, condensed from SkillPlanTools.swift:

struct CallTool: Tool {
    let name = "call_tool"
    let description = """
        Use ONE of your available tools. Put the tool's name in `tool` and, in
        `instruction`, a plain-language description of exactly what you want from
        it (e.g. tool "find_file", instruction "the user's resume, to read their
        email address"). You'll get the result and can call again. Do NOT write
        tool arguments yourself — just say what you want. When your steps are
        done, DON'T call this — reply with your answer.
        """
    let dispatch: @Sendable (_ tool: String, _ instruction: String) async throws -> String

    @Generable
    struct Arguments {
        let tool: String          // the tool's name, from the signature list
        let instruction: String   // plain language: what you want from it this time
    }
}

Two things are worth noticing.

The arguments are just two strings. @Generable is FoundationModels' way of saying "generate the arguments with constrained decoding against this type" — the framework guarantees the model produces a well-formed {tool, instruction} pair. Because the pair is tiny, the only schema L2 ever carries is this one, and it costs a few dozen tokens no matter how many real tools the plan bound. That is the flat-context property: add a sixth tool, a tenth, a fiftieth, and L2's per-turn overhead grows by one signature line each, not one schema each.

The instruction is intent, not arguments. L2 is explicitly told not to write tool arguments. It says what it wants in prose — "the user's resume, to read their email address" — and hands the how to the layer below. This division is what lets the same L2 prompt drive tools whose argument conventions it has never been told.


4. Dispatch: what one call sets in motion

When L2 calls call_tool, FoundationModels invokes the tool's call method, which runs the dispatch closure the executor built for this turn. Dispatch is the deterministic gatekeeper between L2's intent and any real tool running, and it does four things in order (all in executePass in AppleFoundationProvider.swift):

  1. Resolve the name. The raw tool string is trimmed of stray @ and whitespace, lowercased, and matched against the exposed tools by name or slug. A miss does not throw — it returns a corrective message straight back into L2's loop: "There is no tool 'X'. Available: find_file, check_notebook, … Choose one, or reply with your answer." The small model reads that as a tool result and self-corrects on the next iteration.
  2. Deduplicate. The pair (tool, lowercased instruction) is checked against a per-turn set. An exact repeat gets back "You already made that exact call. Use what you have, or do something different." — again a nudge, not an error.
  3. Check the budget. Each tool name has a hard per-turn cap of 3 calls. Exceeding it does throw — more on that in section 6, because the throw is the interesting part.
  4. Run L3. A fresh tool-agent session is dispatched for this one call, and its compact result string is returned into L2's loop as the tool output.

As a picture:

 L2 (skill-follower)              dispatch                    L3 (tool-agent)
 ───────────────────              ────────                    ───────────────
 steps + signatures
 + one tool: call_tool
        │
        │ call_tool("find_file",
        │   "the user's resume, to
        │    read their email")
        ▼
                          resolve name ── unknown ──► corrective text back to L2
                          dedup check ── repeat ────► "you already made that call"
                          budget check ── over cap ─► throw ToolLoopHalt (halts L2)
                                │
                                ▼
                          fresh session, ONE real tool,
                          full schema, empty history ──►  crafts arguments,
                                                          calls find_file once,
                                                          reports what it returned
        ▲                                                       │
        └── compact result ─────────────────────────────────────┘
 (L2 loops: next call_tool, or stops and answers)

5. The L3 tool-agent: one tool, one call, then gone

Each dispatch builds a fresh LanguageModelSession — empty history, no memory of prior calls — bound with exactly one real tool, this time with its full schema. The session's instructions are blunt:

You have exactly one tool: find_file. Your ONLY job is to call it once, with well-crafted arguments, to satisfy the user's instruction — then briefly state what it returned. You MUST call find_file; never answer from your own knowledge.

Appended to that base are the per-tool crafting rules — the knowledge that was evicted from L2's context. For find_file: craft the query as several ranking keywords, never a single bare word, and leave the device list empty to search everywhere. For the calendar tool: resolve the instruction's time range into concrete YYYY-MM-DD bounds before calling. For grep_memory: a concise keyword or short phrase. Each rule lives exactly where it is needed and nowhere else.

L2's plain-language instruction becomes the L3 session's user turn, the model crafts the arguments (constrained decoding against the real schema again — the framework will not let it produce malformed ones), the tool runs, and L3 reports back in a sentence or two.

Three details make this layer honest:

Guards off. The real tools carry their own dedup and budget guards for when they are bound directly (outside the harness). Inside L3 those are constructed with enforceGuards: false — the L2 dispatch already owns dedup and the cap, and a guard firing inside L3 would double-count or throw a halt that L3's own loop would swallow. One layer owns each rule.

Serialized generation. FoundationModels marks tool call methods as concurrent, so an L2 turn that emits two call_tools could run two nested L3 generations in parallel on the single shared on-device model — and the framework's own guard against that is per-session, so it cannot see the collision. A small async lock (a real lock-with-waiter-queue, because Swift actors are reentrant across await) serializes L3 dispatches one at a time.

Results are control flow, not ground truth. When find_file locates a file or check_notebook finds notes, the actual payload — the file hit, the note text — is recorded in a side channel (FileAgentBridge's pendingHits and gatheredNotes), not merely paraphrased in L3's reply. L3's returned string is a compact control signal that tells L2 how the step went; the final user-facing answer is later synthesized from the side channels, where the exact bytes live. A small model's paraphrase of a tool result is where names get misspelled and numbers drift — so the harness never treats it as the source of truth.


6. Why the native loop, and why exceptions steer it

A tool-using conversation is a loop: model emits a tool call → runtime executes it → result goes back in → model continues. There are two ways to run that loop. You can let the framework drive it — FoundationModels' respond() natively executes bound tools mid-generation and keeps going until the model emits plain text. Or you can drive it yourself: catch each tool call, run it, and start a rebuilt continuation turn with the result appended to the transcript.

Pixie's harness tried the second way, and the small model failed in a characteristic manner: on the rebuilt turn it would narrate the next call as prose — "Now I'll use find_file to look for the resume" — instead of emitting an actual tool call. Nothing ran; the user got a confident description of work that never happened. On the framework's native loop, the same model invokes tools reliably across many iterations. The native loop keeps the session's internal state exactly as the model's tool-use training expects it, where a hand-rebuilt transcript is just similar enough to derail a 3-billion-parameter model. So the rule became: L2 always runs on the native loop, and the harness steers it from inside the tools rather than from outside.

But the native loop has no built-in "stop now" lever — which is a real problem, because the small model does not take hints. When its per-tool budget was enforced by telling it to stop ("you've searched enough"), it acknowledged and kept calling; each wasted round grew the transcript toward the 4,096-token cliff. Seen live before the cap existed: grep_memory called more than twenty times in one turn against an empty memory log, rewording the query each time — which is also why exact-call dedup alone is not enough; every reworded call is technically new.

The lever that works is an exception. When dispatch's budget check trips, it throws ToolLoopHalt, which propagates out of the tool, unwinds the framework's respond(), and lands in the executor's catch block — ending L2's turn instantly, mid-loop. The planner uses the identical mechanism (PlanSignal, thrown by the plan tool) to end its own pass the moment a plan is declared. In both cases the exception is not an error; it is the harness's only way to pull the plug on a native loop it deliberately chose not to drive.

Crucially, a halt is not a failure. The executor's catch falls through to the same answer selection that runs on a clean finish: answer from the located file if one is in the side channel, else from the gathered notes, else fall back to L2's own text. The work already done survives the halt — the budget only cuts off the spinning, never the progress. And if a whole plan attempt produces nothing usable, the replan path calls resetToolGuards() first, clearing the per-turn dedup set and counters so the next attempt can actually re-issue calls instead of silently no-oping against stale guards.

The budget numbers, for the record: exact-duplicate calls are free (they short-circuit with a nudge), and each tool name gets at most 3 real calls per turn at the L2 layer. Both counters are per-turn state, reset when the turn — or the re-plan — begins.


7. Trace tagging: naming every session in the swarm

One user question can now fan out into half a dozen model sessions: a planner pass, an L2 pass, and an L3 session per dispatch. When something goes wrong — and with small models, something regularly does — you need to know which session misbehaved and what it was actually shown.

Debug builds carry a deep-trace layer for exactly this. Every session gets a role tag: planner, l2, and for tool-agents l3.<tool>.<step> — where step comes from an atomic counter incremented once per dispatch. Two find_file dispatches in one turn trace as l3.find_file.1 and l3.find_file.3 (the counter is shared across tools, so step numbers also record the order of dispatches). Each trace record captures the session's exact instructions, the bound tools, the input, the output, the timing, any error — and the full transcript as the framework itself serializes it, so you inspect what the model was actually given rather than what the code intended to give it.

There is also a user-visible sliver of the same story: each real tool reports its invocation as a labeled pill in the chat ("Files", "Notebook", "Calendar"), yielded mid-generation so it appears before the answer does. The deep trace is for the developer; the pill is the user's honest, low-resolution view of the same dispatches.


8. What the shape buys

Step back and look at what the meta-tool actually changed. Without it, tool count is a tax on every single turn: each new tool costs a schema's worth of context and a slice of the small model's attention, and somewhere around a handful of tools the executor stops being able to follow its own steps. With it, L2's world is constant-size — one tiny schema plus one line per tool — and each real schema is paid for only in the moment it is used, inside a disposable session that exists for one call.

That flat-cost property is not just tidiness. It is the precondition for the roadmap: a tool surface that keeps growing — including toward externally connected tools, the world that Mechanism: MCP — Letting Other Agents In opens up — without ever renegotiating the executor's context budget. A harness that survives its own success, on a model that fits in your pocket.

The through-line of the whole mechanism is a kind of humility about small models. Don't ask one session to decide and craft — split it. Don't document arguments to a model that only needs to choose — summarize. Don't hint a looping model into stopping — throw. Every piece of CallTool is the same lesson applied at a different joint: make the runtime deterministic wherever the model is unreliable, and spend the model only on the judgment calls nothing else can make.


Up: The L1–L2–L3 Harness. Siblings under it: Skills, Tools, and the @-Namespace for where the tool menu comes from, and Mechanism: Memory & the Notebook for what the search tools actually search.