Tools fail. Telling the model so is what lets it adapt instead of building on a result that is not there — and this is one of the places where skyl's abstraction is genuinely lossy, on three adapters out of four.
You will learn
- How to report a failure with
ToolErrorMessage - Where the
IsErrorflag is lost, and what that means - The portable way to tell the model something went wrong
- Why you should not just return an empty result
The intended way#
result, err := runTool(call)
if err != nil {
req.Messages = append(req.Messages,
skyl.ToolErrorMessage(call.ID, err.Error()))
} else {
req.Messages = append(req.Messages,
skyl.ToolResultMessage(call.ID, result))
}result, err := runTool(call)
if err != nil {
req.Messages = append(req.Messages,
skyl.ToolErrorMessage(call.ID, err.Error()))
} else {
req.Messages = append(req.Messages,
skyl.ToolResultMessage(call.ID, result))
}ToolErrorMessage is ToolResultMessage with IsError: true.
Where the flag goes#
| Adapter | What reaches the model |
|---|---|
| anthropic | A real is_error boolean — the flag arrives intact. |
| openai / openaicompat | Lossy. Prefixes "error: " to the content — and drops the flag entirely when the content is empty. |
| gemini | Dropped. Never reaches the wire at all. |
The portable way#
Put the failure in the text, where every provider carries it faithfully:
func toolResult(call skyl.ToolCall, out string, err error) skyl.Message {
if err != nil {
// The prose is the signal. IsError is a bonus where it works.
return skyl.ToolErrorMessage(call.ID,
fmt.Sprintf("ERROR: %s failed: %v. Do not retry; tell the user the "+
"data is unavailable.", call.Name, err))
}
return skyl.ToolResultMessage(call.ID, out)
}func toolResult(call skyl.ToolCall, out string, err error) skyl.Message {
if err != nil {
// The prose is the signal. IsError is a bonus where it works.
return skyl.ToolErrorMessage(call.ID,
fmt.Sprintf("ERROR: %s failed: %v. Do not retry; tell the user the "+
"data is unavailable.", call.Name, err))
}
return skyl.ToolResultMessage(call.ID, out)
}Three things that text does, which the boolean alone cannot:
- Names the tool, so a turn with several calls is unambiguous.
- Says what to do next — "do not retry" prevents the loop where the model keeps calling a tool that will keep failing.
- Works on all four adapters, because it is just content.
Keep ToolErrorMessage as well. On Anthropic you get both signals; elsewhere you
lose nothing.
Never return empty#
Errors the model should not see#
Not every failure belongs in the conversation. A credential problem or a programming error is yours to fix, not the model's to work around:
result, err := runTool(call)
switch {
case errors.Is(err, errNotConfigured), errors.Is(err, errBug):
// Fail the request. The model cannot help with this, and telling it
// invites a confidently wrong answer built on an apology.
return nil, fmt.Errorf("tool %s: %w", call.Name, err)
case err != nil:
// A genuine runtime failure the model can adapt to.
msgs = append(msgs, toolResult(call, "", err))
default:
msgs = append(msgs, toolResult(call, result, nil))
}result, err := runTool(call)
switch {
case errors.Is(err, errNotConfigured), errors.Is(err, errBug):
// Fail the request. The model cannot help with this, and telling it
// invites a confidently wrong answer built on an apology.
return nil, fmt.Errorf("tool %s: %w", call.Name, err)
case err != nil:
// A genuine runtime failure the model can adapt to.
msgs = append(msgs, toolResult(call, "", err))
default:
msgs = append(msgs, toolResult(call, result, nil))
}Deep diveWhy does skyl not normalise this itself?
It could prefix "error: " on Gemini too, and make all four behave alike.
It does not, because that would be skyl putting words into a conversation the caller owns. The prefix the OpenAI adapters add is already a compromise — it exists because the wire format has no other place to put the flag — and extending that invention to a provider that simply has no such concept would mean skyl silently editing your tool output on one adapter and not another.
Publishing the gap and letting you write the sentence you want is the honest trade. It is the same reasoning as not fetching image URLs for you.
Recap
ToolErrorMessagesetsIsError;ToolResultMessagedoes not.- The flag arrives intact only on Anthropic.
- OpenAI-family adapters prefix
"error: "and drop the flag when content is empty. - Gemini drops it entirely — the model cannot tell the tool failed.
- Put the failure in the text, name the tool, and say what to do next.
- Never return an empty result for a failure; it erases the signal completely.
Try out some challenges
Each of these is solvable with what this page covered. Run them against the sandbox — no API key needed.
Stop a retry loop caused by a failing tool
A tool is down, and the model keeps calling it. Write the error text that stops the loop.
Show hint
The model is retrying because nothing told it not to. Say so explicitly, and say what to do instead.
Show solution
skyl.ToolErrorMessage(call.ID,
"ERROR: get_weather is unavailable (HTTP 503). This is a persistent outage — "+
"do not call get_weather again in this conversation. Answer using what you "+
"already know, and tell the user that live weather data is unavailable.")skyl.ToolErrorMessage(call.ID,
"ERROR: get_weather is unavailable (HTTP 503). This is a persistent outage — "+
"do not call get_weather again in this conversation. Answer using what you "+
"already know, and tell the user that live weather data is unavailable.")Three instructions in one message: what failed, not to retry, and what to do
instead. Compare it to "503", which tells the model nothing and reads exactly
like a transient failure worth retrying.
This is prompt engineering rather than API usage — but it is the part of tool calling that actually determines whether your loop terminates.