方寸 Portal

AI Engineer Portal

Your personal operating system for career transition.

Private mode

Knowledge Note

Tool design best practices for production agents

How to design reliable, testable agent tools using JSON Schema, structured error contracts, idempotency, isolation testing, and versioning strategies.

Category

agents

Tags

agents · tools · schema · testing · production

Sources

2 linked references

Tool design best practices for production agents

Tools are the action surface of an agent. The quality of your tool design determines whether your agent is reliable or fragile in production.

A poorly designed tool is invisible in demos and catastrophic in production. The model calls it with unexpected inputs. It raises an unhandled exception. The agent retries with the same bad call. You burn tokens, produce wrong results, and cannot debug it.

Good tool design is boring, explicit, and defensive.


Schema design with JSON Schema

Every tool should have a machine-readable schema that constrains the model's inputs. Provider SDKs (OpenAI, Anthropic) accept JSON Schema for function definitions.

get_invoice_tool = {
    "name": "get_invoice",
    "description": "Retrieve a single invoice by ID. Returns the invoice record or an error object if not found.",
    "input_schema": {
        "type": "object",
        "properties": {
            "invoice_id": {
                "type": "string",
                "description": "The invoice ID. Format: inv_ followed by alphanumeric characters.",
                "pattern": "^inv_[a-zA-Z0-9]+$"
            },
            "include_line_items": {
                "type": "boolean",
                "description": "Whether to include individual line items in the response. Defaults to false.",
                "default": False
            }
        },
        "required": ["invoice_id"],
        "additionalProperties": False
    }
}

Design rules:

  • Be specific in descriptions. The model uses the description to decide when and how to call the tool. Vague descriptions produce wrong calls. Describe the tool's purpose, inputs, and what success looks like in 1–3 sentences.
  • Include format hints in descriptions. If an ID has a prefix format, say so. If a date expects ISO 8601, say so. The model will follow these hints.
  • Use additionalProperties: false. This prevents the model from inventing fields that do not exist in your schema.
  • Set required explicitly. Do not rely on the model knowing which fields are optional.
  • Prefer enums over open strings for constrained values. If a status field accepts only "open", "closed", "pending", use an enum. This eliminates a class of invalid inputs.

Structured error contracts

The worst tool contract is one that raises an unhandled exception. The agent's executor crashes, the loop breaks, and you have no actionable information.

Better: return a typed error object the model can reason about.

from typing import TypedDict, Literal, Union

class InvoiceResult(TypedDict):
    invoice_id: str
    amount: float
    status: str
    line_items: list

class ToolError(TypedDict):
    error: bool
    error_code: Literal["not_found", "permission_denied", "invalid_input", "upstream_failure"]
    message: str
    retryable: bool

def get_invoice(invoice_id: str, include_line_items: bool = False) -> Union[InvoiceResult, ToolError]:
    if not invoice_id.startswith("inv_"):
        return {
            "error": True,
            "error_code": "invalid_input",
            "message": f"Invoice ID must start with 'inv_'. Received: {invoice_id}",
            "retryable": False
        }

    invoice = db.invoices.get(invoice_id)
    if invoice is None:
        return {
            "error": True,
            "error_code": "not_found",
            "message": f"No invoice found with ID {invoice_id}",
            "retryable": False
        }

    result = {"invoice_id": invoice.id, "amount": invoice.amount, "status": invoice.status}
    if include_line_items:
        result["line_items"] = invoice.line_items
    return result

Error contract design rules:

  • Never raise raw exceptions from tools. Catch all exceptions at the tool boundary and return an error object.
  • Include retryable. The model or executor can use this to decide whether to try again or surface the failure.
  • Include error_code as an enum string, not a freeform message. The model can reason about structured codes. It cannot reliably pattern-match error messages.
  • Include enough context in message to debug without logs. What was the input? What was expected?
  • Keep the shape consistent. An error from get_invoice and an error from list_orders should have the same envelope. The executor can handle them uniformly.

Idempotency

Production agents retry. Network calls fail. The model sometimes calls the same tool twice. If your tools are not idempotent, retries cause double-writes, duplicate charges, or inconsistent state.

Read operations are naturally idempotent. Focus your effort on write operations.

def create_invoice(
    customer_id: str,
    amount: float,
    idempotency_key: str  # Caller-supplied key — agent generates UUID per logical operation
) -> Union[InvoiceResult, ToolError]:
    existing = db.invoices.find_by_idempotency_key(idempotency_key)
    if existing:
        # Return the existing result, same as if we created it now
        return {"invoice_id": existing.id, "amount": existing.amount, "status": existing.status}

    invoice = db.invoices.create(
        customer_id=customer_id,
        amount=amount,
        idempotency_key=idempotency_key
    )
    return {"invoice_id": invoice.id, "amount": invoice.amount, "status": invoice.status}

Idempotency design rules:

  • Include an idempotency_key parameter on all write tools. The agent generates a UUID at the start of each logical operation. Retries reuse the same key.
  • Store the key with the record. Look it up before writing.
  • Return the original result on duplicate calls. Do not signal an error for idempotent duplicates — the model should not need to know.

Testing tools in isolation

Tools must be testable without running the full agent loop. If you can only test a tool inside a live agent session, you cannot iterate quickly and you cannot catch regressions.

# test_tools.py
import pytest
from app.tools.invoices import get_invoice, create_invoice

class TestGetInvoice:
    def test_valid_id_returns_invoice(self, db_with_fixture):
        result = get_invoice("inv_abc123")
        assert result["invoice_id"] == "inv_abc123"
        assert result["amount"] == 49.0

    def test_invalid_id_format_returns_structured_error(self):
        result = get_invoice("abc123")  # Missing inv_ prefix
        assert result["error"] is True
        assert result["error_code"] == "invalid_input"
        assert result["retryable"] is False

    def test_nonexistent_id_returns_not_found(self, db_empty):
        result = get_invoice("inv_missing")
        assert result["error"] is True
        assert result["error_code"] == "not_found"

class TestCreateInvoice:
    def test_idempotent_on_duplicate_key(self, db_with_fixture):
        key = "idem-key-001"
        first = create_invoice("cust_1", 49.0, key)
        second = create_invoice("cust_1", 49.0, key)
        assert first["invoice_id"] == second["invoice_id"]
        assert db_with_fixture.invoices.count() == 1  # Not duplicated

Testing rules:

  • Test happy path, invalid input, not-found, and permission denied for every tool.
  • Test idempotency explicitly — call the tool twice with the same key and assert the record count.
  • Use the structured error envelope in assertions, not exception checks.
  • Keep fixtures minimal. A tool test should need at most a handful of rows.

Versioning

Tools evolve. The model's cached schema may differ from the deployed tool. Field renames break agents silently.

# Version in the tool name when breaking changes are necessary
list_orders_v2 = {
    "name": "list_orders_v2",  # Explicit version suffix
    "description": "...",
    ...
}

# Or version in a wrapper with deprecation handling
def get_customer(customer_id: str, _version: str = "v1") -> dict:
    if _version == "v1":
        return _get_customer_v1(customer_id)
    elif _version == "v2":
        return _get_customer_v2(customer_id)
    else:
        return {"error": True, "error_code": "invalid_input", "message": f"Unknown version: {_version}"}

Versioning rules:

  • Additive changes (new optional fields) are safe. Just add them.
  • Renaming fields, removing fields, or changing types requires a new version.
  • Keep old versions alive until all agents using them are redeployed.
  • Log which tool version each agent call used. This makes rollback decisions tractable.

Summary checklist

When designing a new tool, ask:

  • Does the JSON Schema description tell the model when, why, and how to call this tool?
  • Does additionalProperties: false prevent the model from inventing inputs?
  • Do all error paths return a structured error object, never a raw exception?
  • Do write operations accept an idempotency key?
  • Is the tool testable in isolation without a live agent session?
  • Are breaking changes handled with versioning?

If all six are yes, the tool is production-ready. If any are no, you have a known failure mode waiting for load.