Tool calling
Give a model functions it can call.
@tool("look up the current weather for a city")
fn weather(city) = http.get("https://api.example/w?q={city}").body
@tool("search internal docs")
fn docs(query) = db.search(query)
answer = ai[Report] "Compare weather in Cairo and Oslo" using [weather, docs]
The runtime drives the loop: the model requests a tool, Ecko invokes your function, feeds the result back, and repeats until there is a final answer - which is still coerced to ai[T] and still checked by any @ensures.
@tool and using
Every function in a using list must carry a @tool("description"). The description is what the model sees, so it is part of the interface: write it for a reader who cannot see the body. Parameters default to string type.
using resolves names in the lexical scope of the ai expression. A local tool can capture its defining scope and shadow an outer tool of the same name.
Concurrency and bounds
With a live provider, the tools requested in one round run concurrently - at most ECKO_MAX_PARALLEL at a time - each bounded by ECKO_AI_TOOL_TIMEOUT_MS (default 30000; 0 disables).
A tool that errors, times out, or names nothing known yields an error string fed back to the model, so one slow or broken tool never stalls the loop. The whole loop is capped at ECKO_AI_MAX_TOOL_ROUNDS rounds (default 8) - a backstop against a model that never stops calling tools.
Concurrent tools follow the usual share-nothing model. Coordinate through a cell if they must share state.
What the model is allowed to send
Arguments are checked against the schema the tool advertised, then bound to your function by name:
- An argument your function does not declare comes back to the model as an error, instead of being quietly dropped.
- One the model left out takes your declared default. Only parameters without a default are advertised as
required, so the model is never pushed into inventing a value for an optional one. - A genuinely missing argument is an arity error the model can act on, rather than a
nullthat fails somewhere deeper in your function.@tool("search the docs") fn search(q, limit = 10) { # the model may send just `q`; `limit` is 10 q + " (" + string(limit) + ")" }
Result size
Each tool result is capped at ECKO_AI_TOOL_MAX_RESULT bytes (default 32768; 0 disables), and says in the text where it was cut.
A tool result is not returned once - it stays in the conversation for every remaining round. One tool that hands back a whole document is therefore paid for again on every later request in latency, tokens and money. Cap it, or better, have the tool return the part that answers the question.
Offline
The loop is deterministic in mock mode: it invokes every tool named in the prompt, passes the prompt as the argument, and returns the last tool's result. Enough to test that the wiring is right.
What an untyped call returns
The last invoked tool's result - the same offline and against a real provider. If no tool runs, you get the model's answer instead.
That parity is the point. Live used to return the model's prose while mock returned the tool's value, so this passed its offline test and crashed against a provider with Can't access field 'sources' of string:
r = ai "lookup the sources" using [lookup]
r.sources
When you want the model's own answer with tools available, type the call - ai[T] ... using [...] coerces the final answer to T on both paths:
type Evidence = { answer: String, sources: [String] }
e = ai[Evidence] "what do the notes say?" using [lookup]
A failing tool throws offline, unlike live mode where the error is fed back. Live, the model recovers; offline, you are the recovery mechanism, so the failure surfaces with its kind preserved and catchable.
Security
A tool is your code running on a model's decision, with your program's authority. Two consequences worth internalizing:
- A tool that writes, deletes, sends or pays should validate its own arguments. The model is not a trusted caller. Treat a tool boundary the way you would treat an HTTP handler.
- A tool result is untrusted input. It flows back into a prompt, so a compromised or hostile source can attempt injection. Mark it
@untrustedand render it through{input}in a template.
Restrictions
Cannot combine with voting or -> stream.
A tool set built at runtime
using [weather, docs] names functions written in the source. When the tools are not known until the program runs - discovered from an MCP server, read from config, built in a loop - the using expression is evaluated instead, and each element may be a tool spec:
answer = ai "what changed?" using mcp.as_tools(session)
{ name: "search", description: "Search the docs", params: ["query"], call: fn(args) ... }
name, description and call are required; params defaults to [].
A value created while the program runs cannot carry a @tool annotation, which is applied at parse time, and has no identifier to be named after. So it carries its own name and description, or there is nothing to describe it to the model with.
The two forms mix in one list.