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 instead of asking one session
to do everything: a planner (L1), a skill-follower (L2), and tool-agents (L3). 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.
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.
Two more units of vocabulary. A token is a word-piece, a chunk of text roughly three-quarters of a word long. A model's context window is the fixed budget of tokens it can hold in mind at once. On a big cloud model with a huge context window, the cost of schemas 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 (the running record of the conversation, which the model re-reads every turn), and the room left in 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:
Pixie gives each job to a different session. L2 decides; L3 crafts. CallTool is the
seam between them.
The L2 skill-follower's instructions contain the plan rationale from the planner, the
chosen skill's step list, and a single signature line for each tool the plan bound.
A signature is deliberately shaped like a function prototype from a header file: the one-line
summary a programmer skims to learn what a function is called and what it takes, without
reading its code. It gives 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. 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.
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." Constrained decoding
means the framework restricts the model's output as it is generated, one word-piece at a
time, to continuations that still fit the declared shape, so the model is guaranteed to
produce 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.
When L2 calls call_tool, FoundationModels invokes the tool's call method, which runs
the dispatch closure, a small function the executor prepared ahead of time for this turn.
Dispatch is the deterministic gatekeeper between L2's intent and any real tool running:
plain code, with no model judgment involved. It does four things in
order (all in executePass in AppleFoundationProvider.swift):
tool string is trimmed of stray @ and whitespace,
lowercased, and matched against the exposed tools by name or
slug (a tool's short, lowercase handle). 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.(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.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 callfind_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, so 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 guards are switched off with
enforceGuards: false. The L2 dispatch already owns dedup and the cap; 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,
meaning two of them may run at the same time. So an L2 turn that emits two call_tools
could start two nested L3 generations in parallel on the single shared on-device model.
The framework has its own guard against overlapping generations, but that guard is
per-session: it cannot see a collision between two different sessions. So the harness
serializes L3 dispatches itself, one at a time, with a small async lock: a queue in which
each waiting task holds its place until the one ahead of it finishes. It has to be a real
lock with a waiter queue, because Swift actors (the language's usual one-at-a-time
mechanism) are reentrant across await: while one task is suspended waiting, another is
let straight in, so actor isolation alone serializes nothing.
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, a store that bypasses the conversation text entirely
(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.
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. A hand-rebuilt transcript is just similar enough to the real thing 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. That 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. That is also why exact-call dedup
alone is not enough; every reworded call is technically new.
The lever that works is an exception: the programming-language mechanism that aborts out of
every nested call in one jump. When dispatch's budget check trips, it throws
ToolLoopHalt. The exception 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.
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, one that is safe to bump from concurrent code, 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.
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 without ever renegotiating the executor's context budget, including toward externally connected tools, the world that Mechanism: MCP, Letting Other Agents In opens up (MCP being a standard protocol for exposing an outside tool server to an agent). 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.