The wrapper that calls your tool is the place to enforce the contract. Every exception path, including ones you did not anticipate, has to leave that wrapper in the standard shape.
from dataclasses import dataclass, field
from typing import Literal, Optional
@dataclass
class ToolError:
code: str
message: str
retryable: bool
next_action: Literal["retry", "ask_user", "stop"]
context: Optional[dict] = None
def to_payload(err: ToolError) -> dict:
return {
"code": err.code,
"message": err.message,
"retryable": err.retryable,
"next_action": err.next_action,
"context": err.context,
}
def run_tool(tool, args):
try:
return {"ok": True, "data": tool(**args)}
except ValidationError as e:
return {"ok": False, "error": to_payload(ToolError(
code="validation_error",
message=f"{e.field} is invalid. Expected {e.expected}, for example {e.example}.",
retryable=False,
next_action="ask_user",
context={"field": e.field, "received": e.value},
))}
except RateLimited as e:
return {"ok": False, "error": to_payload(ToolError(
code="rate_limited",
message="The upstream service is rate limiting requests.",
retryable=True,
next_action="retry",
context={"retry_after_ms": e.retry_after_ms},
))}
except Exception:
return {"ok": False, "error": to_payload(ToolError(
code="internal_error",
message="The tool failed unexpectedly and did not complete.",
retryable=False,
next_action="stop",
context=None,
))}
Three details make this work in practice. First, the success and error payloads share a top level ok boolean, so the model does not have to guess whether a response is an error. Second, the catch-all keeps the shape stable even for bugs, which is far better than a stack trace leaking into the transcript. Third, the generic message never claims to know what went wrong when it does not.
Document the enum in the tool description the model reads. A short paragraph listing the codes, what each means, and the meaning of the three next_action values outperforms a longer prompt full of hints, because the model can match the error it receives to the vocabulary it was given.
For retries, include a numeric hint rather than prose. A context field of retry_after_ms set to 2000 is unambiguous; "please wait a moment" is not. If your tool surface spans many providers with different error conventions, a single gateway that normalizes them all into this shape removes a whole class of prompt-level work. Quanta Inference (https://quantaintelligence.ai/inference) exposes text, image, video, audio and embedding models through one OpenAI-compatible key, so the wrapper above sits in one place rather than one per provider.