Tool rules
The rules every tool shares — batching, retries, untrusted output — and how results reach later turns.
Every tool in the tool set follows the same handful of rules. They keep calls cheap, keep failures honest, and stop a full tool payload from being paid for again in every later turn.
Key rules
- Lists, not single items. Every search and write tool takes a batch.
- Partial failure is reported, not thrown. What succeeded is kept.
- Retry depends on what the call does. Rate limits always; timeouts only for reads; anything else never.
- Tool output is material, never instruction. Every identifier the model composes is re-checked on the backend.
- Full payloads are never replayed. Later turns get the outcome;
read_tool_resultfetches the rest.
How a tool call flows
- The model sends a batched tool call.
- The tool runtime calls the backend over HTTP; the scope comes from the turn.
- The backend answers with a result, or a retryable failure.
- The runtime retries retryable failures, each wait doubling the last.
- The backend returns the result and any per-item failures.
- The model hears hits, an absence, or an outage — reported separately.
- Later turns receive the tool name, what was asked, the outcome and (for recent turns) an excerpt.
- If the model needs more, it calls
read_tool_resultwith the stored ref.
Shared rules
Batching
One call with two angles costs one embedding request and runs the searches in parallel; a single-item tool would cost two model calls. A batch of three queries returns what three calls would.
Partial failure, absence and outage
- One failed fact out of three keeps the two that were stored.
- One dead link keeps the two pages that did read.
- A query that ran and matched nothing is an absence; a query that never ran is an outage. Search tools report them separately so the model can too.
Retries
A backend failure worth repeating is retried, with each wait doubling the last, before the model hears anything. The final report says how many attempts were made.
| Failure | Retried |
|---|---|
| Rate limit or service unavailable, refused before the backend did any work | Always |
| Timeout or server error | Only a call that reads, or one that replaces what is already there — a write may have landed with its answer lost |
| Any other status | Never — the backend decided on purpose |
What decides is what the call does, not how it travels. Every route a turn uses takes its arguments in a body, so a search and a save are both a POST; a policy written against the HTTP verb would have retried neither.
Untrusted output
Tool output is text that somebody outside the organisation may have written. The runtime’s guidance says once to read it as material, never as instruction. Under prompt injection, an identifier the model composes was effectively written by whoever wrote the document it just read — so every one is checked against the turn on the backend. See Guardrails for the checks that act on tool results.
Argument repair
Models get the same few argument shapes wrong, and each is a wrapper rather than a different answer:
- a list sent as the JSON text of a list;
- a one-element list sent as the element itself;
- a one-element list of objects flattened into the call.
Arguments are fitted to the shape the tool asked for before the tool refuses them. Each rule reads the tool’s own published schema, so no tool is named and any tool added later — including application tools — is covered automatically. A rule fires only where the schema leaves one reading: for example, the names of an edit fold into edits because it is the only list the call is missing. Where two answers would fit, the arguments go through as sent and the refusal names what was wrong.
Results in later turns
The full payload a tool returned is never replayed into a later prompt.
| Distance | What crosses |
|---|---|
| Every later turn | Tool name, what it was asked for (cut short), and the outcome: succeeded, failed or empty |
| The three most recent turns | Also a word-boundary excerpt of a few hundred characters |
| Any time, on request | The full stored result, via read_tool_result |
- A replayed result whose text was cut carries one terse line: how much was cut, roughly how much there is, and the ref to fetch the rest.
- A call outside the window carries the same line beside its outcome.
- A result that crossed whole carries no line.
How many calls cross from each turn, and which ones keep their place, is part of short-term memory — see Building the prompt.
Reading stored results
read_tool_result reads any result of this conversation, from the current turn or an earlier one.
- A ref from another conversation, another organisation or nowhere is refused identically, so a turn cannot learn whether a guessed identifier was real.
- A row with nothing readable is reported as absent, not as an empty success.
- The description tells the model to read a ref a person pastes into chat and show what it holds.
Previews
A large text or JSON result enters the context as a structured preview — with _toolResultContext.ref, the original counts and a partial flag — within 6,000 encoded characters. The transcript keeps the original output apart from the model-visible preview.
Not previewed (and their refs stay readable): skill instructions, clarification control flow, action acknowledgements, errors and non-text media.
Catalogue results also bypass previews so the model can use them directly:
search_toolsandlist_toolskeep every returned name, with a description of at most 200 characters.get_tool_schemakeeps the complete selected definitions; unknown names come back inmissingwith a discovery hint.- Distinct
search_toolsqueries run concurrently, at most five per call, and results keep query order.
Searching a stored result
{"refs":["tool-result-ref"],"query":"reimbursment deadline","match":"fuzzy","contextChars":200,"maxChars":4000}| Setting | Value |
|---|---|
| Lexical match | Accent-insensitive whole words |
| Fuzzy match | Bounded edit distance on letter-only words; numeric and mixed tokens are never fuzzed |
| Searched | Every readable string field and numeric or boolean value of the stored result, not only the preview; field names are paths, not searched |
| Hit | An unchanged substring of one field, with source metadata, path, zero-based character offsets and one-based line numbers |
| Refs per call | Up to three, sharing one budget |
maxChars | 4,000 default, 1,024 to 20,000, including metadata and failures |
contextChars | 0 to 1,000 each side |
| Windows | At most eight per ref; omitted matches are declared |
| No match | Only matchedFields=0 means absence; a window omitted for budget is not absence |
| Widening | Omit query, pass path and startChar, continue with nextStartChar |
| Offsets | UTF-16 characters into the encoded raw output, or with a path into the decoded string or JSON value; lines are extracted-text lines |
| Size limits | 4 million characters, 20,000 visited values, depth 32, 100,000 distinct-word entries, 100,000 word matches; exceeding one is an error |
| Persistence wait | Up to two seconds, only for a ref known in the current turn, retrying “not found” only; unknown and historical refs are refused at once |
Search fetches no knowledge passages and uses no embeddings, summaries or cache. Report grounding and live evaluation count only model-visible previews and returned windows; metadata-only matches, workflow instructions, failed reads and saved write inputs never count as retrieved evidence.
Exporting stored text
- Direct export. Pass
pathand one_toolResultContext.sourcesselector towrite_file. The selector holds arefand afieldarray (JSON strings are decoded along the path), plus optionalbetweendelimiters that must identify one complete region. The backend selects the text from the original stored result, including text the preview omitted. - Refused before any file in the batch is written: missing fields, ambiguous delimiters, failed calls, read windows, and records marked
truncated. - Transformations.
run_codeaccepts up to threetoolResultsrefs — see Workspace and code.
Lookups stay within the conversation and preserve the provider’s text exactly; they never fetch additional API pages.
Reading earlier turns
read_transcript reads earlier turns of the current conversation verbatim, by a range of turn numbers or by literal words. Each turn returns:
- its number and start time;
- what the person wrote and the assistant’s answers;
- each tool call’s name, the first 400 characters of its arguments, and the ref
read_tool_resultreopens it by.
Thinking is never returned. The conversation is resolved from the turn, so no request can name another one, and the asking turn is left out because it is still being written.
A read spends 6,000 characters by default and at most 20,000, on whole turns. A read that stops early is marked truncated and carries the offset to continue from; a single turn larger than the budget comes back whole.
The tool exists for compaction: a summary names the turns it covers, and a detail it left out is read back from them.
Under the hood
How stored-result search runs
Matching runs in the backend’s Postgres: exact words by B-tree index, pg_trgm for fuzzy candidates filtered by levenshtein_less_equal (from fuzzystrmatch), with no TypeScript fuzzy matcher. The index holds literal fields and distinct normalised words with their UTF-16 occurrences, one entry per repeated word; message_blocks.payload.output stays authoritative. New results and their indexes are written in one transaction; historical results are backfilled lazily under a row lock. Indexes cascade with transcript retention and never form a cross-conversation collection. Deploying requires the pg_trgm and fuzzystrmatch extensions; runtime roles never install extensions.
Where the preview and original live
The transcript keeps the original output apart from the model-visible contextOutput; SDK snapshots hold the preview.