Tool calling
Tool calling, also known as function calling, is a structured way to give LLMs the ability to make requests back to the application that called it. You define the tools you want to make available to the model, and the model will make tool requests to your app as necessary to fulfill the prompts you give it.
The use cases of tool calling generally fall into a few themes:
Giving an LLM access to information it wasn’t trained with
- Frequently changing information, such as a stock price or the current weather.
- Information specific to your app domain, such as product information or user profiles.
Note the overlap with retrieval augmented generation (RAG), which is also a way to let an LLM integrate factual information into its generations. RAG is a heavier solution that is most suited when you have a large amount of information or the information that’s most relevant to a prompt is ambiguous. On the other hand, if a function call or database lookup is all that’s necessary for retrieving the information the LLM needs, tool calling is more appropriate.
Introducing a degree of determinism into an LLM workflow
- Performing calculations that the LLM cannot reliably complete itself.
- Forcing an LLM to generate verbatim text under certain circumstances, such as when responding to a question about an app’s terms of service.
Performing an action when initiated by an LLM
- Turning on and off lights in an LLM-powered home assistant
- Reserving table reservations in an LLM-powered restaurant agent
Before you begin
Section titled “Before you begin”If you want to run the code examples on this page, first complete the steps in the Get started guide. All of the examples assume that you have already set up a project with Genkit dependencies installed.
This page discusses one of the advanced features of Genkit model abstraction, so before you dive too deeply, you should be familiar with the content on the Generating content with AI models page. You should also be familiar with Genkit’s system for defining input and output schemas, which is discussed on the Flows page.
Overview of tool calling
Section titled “Overview of tool calling”At a high level, this is what a typical tool-calling interaction with an LLM looks like:
- The calling application prompts the LLM with a request and also includes in the prompt a list of tools the LLM can use to generate a response.
- The LLM either generates a complete response or generates a tool call request in a specific format.
- If the caller receives a complete response, the request is fulfilled and the interaction ends; but if the caller receives a tool call, it performs whatever logic is appropriate and sends a new request to the LLM containing the original prompt or some variation of it as well as the result of the tool call.
- The LLM handles the new prompt as in Step 2.
For this to work, several requirements must be met:
- The model must be trained to make tool requests when it’s needed to complete a prompt. Most of the larger models provided through web APIs such as Gemini can do this, but smaller and more specialized models often cannot. Genkit returns an error if you try to provide tools to a model that doesn’t support it.
- The calling application must provide tool definitions to the model in the format it expects.
- The calling application must prompt the model to generate tool calling requests in the format the application expects.
Tool calling with Genkit
Section titled “Tool calling with Genkit”Genkit provides a single interface for tool calling with models that support it.
Each model plugin ensures that the last two criteria mentioned in the previous
section are met, and the genkit.Generate() function automatically carries out
the tool-calling loop described earlier.
Model support
Section titled “Model support”Tool calling support depends on the model, the model API, and the Genkit plugin. Consult the relevant documentation to determine if tool calling is likely to be supported. In addition:
- Genkit returns an error if you try to provide tools to a model that doesn’t support it.
- If the plugin exports model references, the
ModelInfo.Supports.Toolsproperty will indicate if it supports tool calling.
Defining tools
Section titled “Defining tools”Use the genkit.DefineTool() function to write tool definitions:
package main
import ( "context" "fmt" "log"
"github.com/firebase/genkit/go/ai" "github.com/firebase/genkit/go/genkit" "github.com/firebase/genkit/go/plugins/googlegenai")
// WeatherInput is the tool's input. The schema the model sees is inferred from// this type, so a jsonschema_description tag is how a field gets described.type WeatherInput struct { Location string `json:"location" jsonschema_description:"The location to get the current weather for."`}
func main() { ctx := context.Background()
g := genkit.Init(ctx, genkit.WithPlugins(&googlegenai.GoogleAI{}), genkit.WithDefaultModel("googleai/gemini-flash-latest"), )
getWeather := genkit.DefineTool(g, "getWeather", "Gets the current weather in a given location", func(ctx *ai.ToolContext, input WeatherInput) (string, error) { // Here, we would typically make an API call or database query. For // this example, we just return a fixed value. return fmt.Sprintf("The current weather in %s is 63°F and sunny.", input.Location), nil })
resp, err := genkit.Generate(ctx, g, ai.WithPrompt("What is the weather in San Francisco?"), ai.WithTools(getWeather), ) if err != nil { log.Fatal(err) }
fmt.Println(resp.Text())}The syntax looks like genkit.DefineFlow(), with one addition: you must write a
description. The name and the description are all the model knows about a tool
besides the schemas inferred from the input and output types, so both are prompt
and are worth writing as carefully as one.
Describe fields with the jsonschema_description struct tag. The jsonschema
tag is a comma-separated keyword list for constraints such as enum= and
minimum=, so a description written there is cut off at its first comma with no
error; the separate tag has no list to terminate.
The tool context
Section titled “The tool context”*ai.ToolContext embeds context.Context, so it satisfies context.Context
directly. Pass it unchanged to a flow, an HTTP client, or a database handle, and
it carries the caller’s cancellation and deadline with it. This one needs
time on top of the imports above:
type RevenueInput struct { Region string `json:"region"`}
reportRevenue := genkit.DefineFlow(g, "reportRevenue", func(ctx context.Context, region string) (string, error) { // ... look up the numbers ... return "revenue for " + region, nil })
getRevenue := genkit.DefineTool(g, "getRevenue", "Reports revenue for a region over the last quarter.", func(ctx *ai.ToolContext, input RevenueInput) (string, error) { // ctx is a context.Context, so it goes straight into anything that // takes one. Give a slow dependency a deadline of its own. callCtx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel()
return reportRevenue.Run(callCtx, input.Region) })For the same reason, the tool context goes straight into the runtime verbs of
package github.com/firebase/genkit/go/ai/tool, which take a context.Context.
They let a tool attach content parts to its answer, stream progress, and return
an error to the model, all without a change to its signature. The sections below
show each one. A tool that pauses generation to ask the caller something is
defined with genkit.DefineResumableTool() instead, which the
interrupts guide covers.
Tools run concurrently
Section titled “Tools run concurrently”When a model asks for several tools in one turn, Genkit runs them concurrently,
one goroutine per request. A tool function must be safe to call from several
goroutines at once: guard shared state with a mutex, and copy a slice or map
before returning it. The registry is mutex-protected, so genkit.DefineTool and
the other Define* calls are safe from any goroutine, but defining tools during
startup is still the pattern to follow.
Schemas known only at runtime
Section titled “Schemas known only at runtime”If the input shape is only known at runtime, declare the input parameter as
any and pass the schema instead. ai.WithInputSchema(schema map[string]any)
takes a plain JSON Schema document as a map[string]any, not a
*jsonschema.Schema, not []byte, and not a struct:
schema := map[string]any{ "type": "object", "properties": map[string]any{ "location": map[string]any{ "type": "string", "description": "The location to get the current weather for", }, }, "required": []string{"location"},}
genkit.DefineTool(g, "getWeather", "Gets the current weather in a given location", func(ctx *ai.ToolContext, input any) (string, error) { // input arrives as a map[string]any shaped by the schema above. return "sunny", nil }, ai.WithInputSchema(schema),)ai.WithInputSchemaName references a schema registered with
genkit.DefineSchemasFor, and ai.WithOutputSchema(schema map[string]any) and
ai.WithOutputSchemaName do the same for the output. An explicit schema stands
in for a type parameter, so the parameter it describes must be any.
The basic-tools sample is a runnable program built around one tool, and it is where the deployment example further down comes from.
Using tools
Section titled “Using tools”Include defined tools in your prompts to generate content.
Using genkit.Generate():
resp, err := genkit.Generate(ctx, g, ai.WithPrompt("What is the weather in San Francisco?"), ai.WithTools(getWeather),)ai.WithTools(tools ...ai.ToolRef) accepts anything with a Name() string
method: the *ai.ToolAction that genkit.DefineTool returns, the
*ai.ResumableToolAction that genkit.DefineResumableTool returns, and
ai.ToolName("getWeather") when all you have is the name. So a per-request tool
set is just a slice you spread. Repeating the option appends, and duplicate
names are rejected when the request runs:
// auditLog is a tool registered elsewhere, for example by a plugin.adminTools := []ai.ToolRef{getWeather, getRevenue, ai.ToolName("auditLog")}
resp, err := genkit.Generate(ctx, g, ai.WithPrompt("Who looked at the San Francisco forecast today?"), ai.WithTools(adminTools...),)The model receives the tool definitions in the order you list them, followed by any tools that middleware adds. Two identical calls send identical requests, so provider-side prompt caching keeps working across turns.
Using genkit.DefinePrompt():
weatherPrompt := genkit.DefinePrompt(g, "weatherPrompt", ai.WithPrompt("What is the weather in {{location}}?"), ai.WithTools(getWeather),)
resp, err := weatherPrompt.Execute(ctx, ai.WithInput(map[string]any{"location": "San Francisco"}),)Using a .prompt file:
Create a file named prompts/weatherPrompt.prompt (assuming default prompt directory):
---system: "Answer questions using the tools you have."tools: [getWeather]input: schema: location: string---
What is the weather in {{location}}?Then execute it in your Go code:
// Assuming prompt file named weatherPrompt.prompt exists in ./prompts dir.weatherPrompt := genkit.LookupPrompt(g, "weatherPrompt")if weatherPrompt == nil { log.Fatal("no prompt named 'weatherPrompt' found")}
resp, err := weatherPrompt.Execute(ctx, ai.WithInput(map[string]any{"location": "San Francisco"}),)Genkit handles the tool call automatically if the LLM needs to use the
getWeather tool to answer the prompt.
Controlling tool choice
Section titled “Controlling tool choice”ai.WithToolChoice() decides whether the model may call a tool, must call one,
or must not. ai.ToolChoice is a string type with three values:
| Value | Meaning |
|---|---|
ai.ToolChoiceAuto | The model decides whether to call a tool. |
ai.ToolChoiceRequired | The model must call at least one tool this turn. |
ai.ToolChoiceNone | The model must answer without calling a tool. |
resp, err := genkit.Generate(ctx, g, ai.WithPrompt("What is the weather in San Francisco?"), ai.WithTools(getWeather), ai.WithToolChoice(ai.ToolChoiceRequired),)Omitting the option leaves the zero value, the empty string, which means unset:
Genkit sends nothing and the provider’s own default applies. A model whose
ModelInfo.Supports.ToolChoice is false accepts only the unset value and
ai.ToolChoiceAuto; anything else fails the request with
ai.ErrUnsupportedByModel.
Returning more than one value from a tool
Section titled “Returning more than one value from a tool”A tool sometimes has more to say than a single value: a chart of what it
measured, a screenshot of what it saw, a document it retrieved. Call
tool.AttachParts() from inside the tool to add content parts to its response.
The tool keeps its own return type, so the output schema the model sees is still
the one inferred from that type:
import ( "fmt"
"github.com/firebase/genkit/go/ai" "github.com/firebase/genkit/go/ai/tool" "github.com/firebase/genkit/go/genkit")
// Deploy is what the model fills in to call the tool.type Deploy struct { Service string `json:"service" jsonschema_description:"The service to deploy."` Environment string `json:"environment" jsonschema:"enum=staging,enum=production" jsonschema_description:"Where to deploy it."`}
// Rollout is the tool's answer, and the whole of its return type.type Rollout struct { Service string `json:"service"` Revision string `json:"revision"` Healthy bool `json:"healthy"` P95Ms float64 `json:"p95Ms" jsonschema_description:"The p95 latency after the rollout, in milliseconds."`}
deployService := genkit.DefineTool(g, "deployService", "Deploys a service to an environment and reports how the rollout went.", func(ctx *ai.ToolContext, input Deploy) (*Rollout, error) { latencies, err := rollOut(input) if err != nil { return nil, err }
// The model receives this as a picture, so it can describe the // shape of the rollout rather than only its last number. tool.AttachParts(ctx, ai.NewMediaPart("image/png", barChartPNG(latencies)))
return &Rollout{ Service: input.Service, Revision: fmt.Sprintf("%s-00042", input.Service), Healthy: true, P95Ms: latencies[len(latencies)-1], }, nil })rollOut and barChartPNG stand for your own code: rollOut runs the
deployment and returns its latencies, and barChartPNG renders them as a
data:image/png;base64,... URL, the form ai.NewMediaPart() takes for inline
media.
The attached parts reach the model and the client both, so the model can reason
about the chart and the Dev UI can render it. Attach media or data parts:
providers such as Gemini reject a text part in a tool response. The parts are
added to the response when the tool returns, so attach them before then. A
goroutine the tool starts can attach parts while the tool waits for it, but one
that outlives the call attaches to nothing. A WrapTool hook in
middleware can call tool.AttachParts() too.
Streaming and tool calling
Section titled “Streaming and tool calling”A tool call takes several turns, so a stream carries the tool’s traffic as well
as the model’s text. A slow tool can also stream progress while it works with
tool.SendChunk(). The tool builds the chunk itself; set its Role to
ai.RoleTool so the caller can tell it from the model’s own text:
// Inside the deployService tool, before each step of the rollout.tool.SendChunk(ctx, &ai.ModelResponseChunk{ Role: ai.RoleTool, Content: []*ai.Part{ai.NewTextPart("shifting 50% of traffic")},})Streaming is best effort. With a caller that is not streaming, SendChunk is a
no-op, and the value the tool returns is always the authoritative answer.
Progress is not written to history, since it is for showing rather than for the
model to read.
Use genkit.GenerateStream() when the caller needs to act on that traffic, and
switch on the part kind. core.StreamCallback and genkit.DefineStreamingFlow
come from the flows API, and status here is
github.com/firebase/genkit/go/core/status, not google.golang.org/grpc/status;
see Error types:
import ( "context" "fmt"
"github.com/firebase/genkit/go/ai" "github.com/firebase/genkit/go/core" "github.com/firebase/genkit/go/core/status" "github.com/firebase/genkit/go/genkit")
genkit.DefineStreamingFlow(g, "deployFlow", func(ctx context.Context, input string, sendChunk core.StreamCallback[string]) (string, error) { for val, err := range genkit.GenerateStream(ctx, g, ai.WithPrompt(input), ai.WithTools(deployService), ) { if err != nil { return "", fmt.Errorf("could not deploy: %w", err) } if val.Done { return val.Response.Text(), nil } for _, part := range val.Chunk.Content { switch { case part.IsText() && val.Chunk.Role == ai.RoleTool: sendChunk(ctx, "deploy: "+part.Text) // Progress from tool.SendChunk. case part.IsText(): sendChunk(ctx, part.Text) // The model's own text. case part.IsToolRequest(): sendChunk(ctx, fmt.Sprintf("[calling %s]", part.ToolRequest.Name)) case part.IsToolResponse(): sendChunk(ctx, fmt.Sprintf("[%s answered]", part.ToolResponse.Name)) } } } return "", status.Errorf(status.ErrInternal, "the stream ended without a final result") })A core.StreamCallback[*ai.ModelResponseChunk] handed to ai.WithStreaming()
on an ordinary genkit.Generate() call is shorter and forwards every chunk
untouched. The tool traffic still arrives on it, so the caller switches on the
part kind the same way.
Limiting tool call iterations with WithMaxTurns
Section titled “Limiting tool call iterations with WithMaxTurns”When a tool might trigger several sequential calls, ai.WithMaxTurns() caps how
many back-and-forth iterations the model gets in one generation. It controls
cost, keeps latency bounded, and guards against a loop that never settles. Each
turn is one complete cycle of the model calling tools and receiving their
responses. The default is 50:
resp, err := genkit.Generate(ctx, g, ai.WithPrompt("Research the latest developments in quantum computing."), ai.WithTools(webSearch), ai.WithMaxTurns(8), // Allow up to 8 tool-calling iterations.)if err != nil { if errors.Is(err, ai.ErrMaxTurnsExceeded) { // The loop hit its limit before the model produced a final answer. // resp.History() holds the rounds that completed; send it back with // a higher limit to continue rather than start over. }}When the limit is reached, Genkit stops the loop and returns an error matching
ai.ErrMaxTurnsExceeded, whose status is ABORTED, beside a partial response:
resp.FinishReason is ai.FinishReasonAborted, since a limit the caller set is
a caller stop, and resp.History() carries the completed rounds. Either raise
the limit or look for a tool the model keeps retrying.
When a tool fails
Section titled “When a tool fails”A tool that returns a non-nil error is fail-fast by default. Genkit stops the
tool loop as soon as the error arrives, while any siblings in the same round
finish on their own, and genkit.Generate() returns the error wrapped as
ai.ErrToolFailed, a subtype of status.ErrInternal (HTTP 500). The model never
sees it and never retries. The response comes back beside the error with
ai.FinishReasonFailed: its History() holds the rounds before the failure and
drops the failed round whole, the model’s request message included, because a
conversation cannot end on an unanswered tool request. Output that does not
match the tool’s declared schema reports the same sentinel, and so does input
the model sent that fails the declared input schema: the tool function never
runs and the call aborts with ai.ErrToolFailed wrapping
status.ErrInvalidInput. A tool that stopped because the call’s context ended
is not a tool failure: that error carries CANCELLED, and the response reports
ai.FinishReasonAborted.
A tool that panics fails the same way. The panic becomes the tool’s own error,
with the fixed message tool panicked. The panic value and its stack go only to
the error log, because they can hold anything the tool had in hand, secrets
included.
The tool’s own error is wrapped with %w, so both the framework sentinel and
your own cause still match:
// errStationOffline is the tool's own sentinel, returned from its body.var errStationOffline = errors.New("weather station offline")
resp, err := genkit.Generate(ctx, g, ai.WithPrompt("What is the weather in San Francisco?"), ai.WithTools(getWeather),)if err != nil { if errors.Is(err, ai.ErrToolFailed) && errors.Is(err, errStationOffline) { return fmt.Errorf("retry later: %w", err) } return err}Return an error to the model
Section titled “Return an error to the model”Some errors are the model’s to fix: a misspelled name, a query with a syntax
error, a record that does not exist. Return them with tool.Fail(ctx, err). The
loop answers the call with the error and continues, so the model reads the
message and can call the tool again with better input. Every other error still
stops the loop, so a tool decides error by error:
// errUnknownLocation is returned by readStation, your own call to the// weather service, when it has no station for a location.var errUnknownLocation = errors.New("unknown location")
getWeather := genkit.DefineTool(g, "getWeather", "Gets the current weather in a given location.", func(ctx *ai.ToolContext, input WeatherInput) (string, error) { forecast, err := readStation(ctx, input.Location) if errors.Is(err, errUnknownLocation) { // The model can fix this one: it reads the message and tries // another name. return "", tool.Fail(ctx, fmt.Errorf("no station for %q; use a city name", input.Location)) } // Any other error, such as errStationOffline, still stops the loop. return forecast, err })The model receives the error message as the tool’s output, {"error": "..."},
and model plugins whose API marks a failed call, such as Anthropic’s
is_error, mark it. In the history, part.IsToolError() reports true on that
tool response. ai.IsToolFailError(err) reports whether an error was made with
tool.Fail(), which is how a test checks that a tool returns an error to the
model.
Return every error of a tool to the model
Section titled “Return every error of a tool to the model”For tools you do not own, such as tools from an MCP server
or tools that middleware adds, the SoftToolErrors middleware from
github.com/firebase/genkit/go/plugins/middleware returns their errors to the
model in the same way. Tools limits it to some tool names. Left empty, it
covers every tool, and calls to tool names that do not exist:
resp, err := genkit.Generate(ctx, g, ai.WithPrompt("Find the cheapest flight to Lisbon next Friday."), ai.WithTools(searchFlights, bookFlight), // tools defined with genkit.DefineTool // Errors from searchFlights go to the model; bookFlight stays fail-fast. ai.WithUse(&middleware.SoftToolErrors{Tools: []string{"searchFlights"}}),)It covers the tool’s own errors, input validation errors included. Interrupts,
cancellation, and an error that a WrapTool hook returns in place of the tool’s
error still stop the generation. Other WrapTool hooks, such as a retry, see the
tool’s error first, wherever SoftToolErrors sits in the chain.
See Error types for the rest of the sentinels generation reports.
Inspecting which tools ran
Section titled “Inspecting which tools ran”There is no resp.ToolCalls(). resp.ToolRequests() returns only the requests
left unfulfilled by the final turn, which is empty unless
ai.WithReturnToolRequests(true) is set or an interrupt fired. The full record
of the loop is resp.History(): the request’s messages plus the response, which
includes each model message carrying tool requests and each ai.RoleTool
message carrying their responses. Pair the two on Ref:
calls := map[string]*ai.ToolRequest{}for _, msg := range resp.History() { for _, part := range msg.Content { switch { case part.IsToolRequest(): calls[part.ToolRequest.Ref] = part.ToolRequest case part.IsToolResponse(): req := calls[part.ToolResponse.Ref] fmt.Printf("%s(%v) -> %v\n", part.ToolResponse.Name, req.Input, part.ToolResponse.Output) } }}Tools that come from middleware
Section titled “Tools that come from middleware”Not every tool is one you write. Middleware attached with ai.WithUse() can
contribute tools to a request, and they join the loop like any other. The
Filesystem middleware from github.com/firebase/genkit/go/plugins/middleware
adds list_files and read_file, plus write_file and edit_file when
AllowWriteAccess is set, all confined to one directory:
resp, err := genkit.Generate(ctx, g, ai.WithPrompt("What is in the config file, and what does it configure?"), // Adds list_files and read_file, both confined to ./workspace. ai.WithUse(&middleware.Filesystem{RootDir: "./workspace"}), // Exploring a directory takes several rounds, so leave the loop room. ai.WithMaxTurns(20),)A failed file operation, such as reading a directory as a file, answers the call
with its error, as SoftToolErrors does, so the model can correct the path and
try again.
The Skills middleware lists the available skills in the system prompt and
adds tools that load a skill only when the model asks for it, so the heavy
instructions stay off the hot path. Both are worked through in the
filesystem
and skills
samples, and the middleware guide covers the rest of the
built-in set.
Pause the tool loop by using interrupts
Section titled “Pause the tool loop by using interrupts”By default, Genkit repeatedly calls the LLM until every tool call has been resolved. You can conditionally pause execution in situations where you want to, for example:
- Ask the user a question or display UI.
- Confirm a potentially risky action with the user.
- Request out-of-band approval for an action.
Interrupts halt the loop and return control to your code so that you can
handle more advanced scenarios. A tool defined with
genkit.DefineResumableTool() pauses by returning tool.Interrupt(), and
resumes with the caller’s answer as a typed parameter. Visit the
interrupts guide to learn how to use them.
Explicitly handling tool calls
Section titled “Explicitly handling tool calls”If you want full control over this tool-calling loop, for example to apply more
complicated logic, set the WithReturnToolRequests() option to true. Now it’s
your responsibility to ensure all of the tool requests are fulfilled, to carry
the tools and the option onto every call, and to stop. One round is not enough:
after you answer a tool request the model usually has more to say, and it may
ask for another tool:
getWeather := genkit.DefineTool(g, "getWeather", "Gets the current weather in a given location", func(ctx *ai.ToolContext, input WeatherInput) (string, error) { // Tool implementation... return "sunny", nil })
const maxTurns = 5
messages := []*ai.Message{ ai.NewUserTextMessage("What is the weather in San Francisco?"),}
for turn := 0; turn < maxTurns; turn++ { resp, err := genkit.Generate(ctx, g, ai.WithMessages(messages...), // Both options belong on every call, not just the first: without // them the model has no tools on the second turn. ai.WithTools(getWeather), ai.WithReturnToolRequests(true), ) if err != nil { log.Fatal(err) }
requests := resp.ToolRequests() if len(requests) == 0 { fmt.Println(resp.Text()) return }
// ToolRequests returns the request parts, so the call itself is on // part.ToolRequest. var parts []*ai.Part for _, part := range requests { req := part.ToolRequest
tool := genkit.LookupTool(g, req.Name) if tool == nil { log.Fatalf("tool %q not found", req.Name) }
output, err := tool.RunRaw(ctx, req.Input) if err != nil { log.Fatalf("tool %q failed: %v", tool.Name(), err) }
parts = append(parts, ai.NewToolResponsePart(&ai.ToolResponse{ Name: req.Name, Ref: req.Ref, Output: output, })) }
// Carry the whole turn forward: the model's request message, then the // tool message answering it. messages = append(resp.History(), ai.NewMessage(ai.RoleTool, nil, parts...))}
log.Fatalf("gave up after %d turns", maxTurns)ai.WithMaxTurns() has nothing to bound in this mode, since Genkit is no longer
running the loop, so the turn cap is yours to enforce.
RunRaw returns the tool’s output value. Use RunRawMultipart instead when
you also need the content parts the tool attached with tool.AttachParts();
they go in the Content field of the tool response. An error a tool made with
tool.Fail() comes back from both like any other error, so check it with
ai.IsToolFailError() and answer the call with the message if the model should
see it.