Bilal Tahseen
All posts

Tool Contracts Beat Prompt Rewrites

· Bilal Tahseen

10|I've watched too many production agents fail the same way. The agent hallucinates tool arguments. It calls a database write when the user asked for a read. It loops until it blows through the token budget. The team's response is always to rewrite the prompt: "be more careful," "validate before calling," "don't make assumptions."

The prompt gets longer. The failures don't stop. That's because prompts are suggestions, not contracts. The model can ignore them, misunderstand them, or follow them 95% of the time and fail on the edge case that matters.

I stopped trusting prompts to prevent tool failures. Now I wrap every tool behind a contract layer: a JSON schema for arguments, an explicit allowlist of what the agent can call, a token and call budget, and typed result validation that rejects hallucinated responses. The agent never sees the raw tool. It sees the contract, and the contract decides whether the call runs.

This isn't about prompt engineering. It's about putting structure between the LLM's output and the tools that change state. Here's how I build it.

Why prompt rewrites fail for tools

The classic mistake is putting guardrails in the system prompt and hoping the model obeys them. "Only call write_customer_record when the user explicitly confirms." "Never pass null for the email field." "If you're unsure, ask first." 20| That works until it doesn't. I've debugged agents that called a CRM write API with an invented customer_id because the model pattern-matched from the conversation and assumed the ID existed. The prompt said "validate all fields." The model thought it did. It was wrong.

The other failure mode is the runaway loop. The agent calls a tool, gets an error, rephrases the arguments, calls again, gets another error, and repeats until the token budget runs out. The prompt said "stop after three failed attempts." The model lost count or reinterpreted what "failed attempt" meant. By the time someone noticed, the agent had burned through 200K tokens calling the same API with slight variations.

Prompts are natural language. Natural language is ambiguous. You can't debug ambiguity at scale. You need something the agent can't misinterpret: a schema it must satisfy, an allowlist it can't bypass, and a budget it can't exceed.

Diagram showing Agent sending requests to Tool Contract layer which validates schema, checks allowlist, enforces budget, then forwards to actual tools like Search API, SQL/CRM, and File Write, with a reject feedback loop

What a tool contract is

A tool contract sits between the agent and the actual tool implementation. When the agent tries to call a function, the contract intercepts the request and checks four things before the tool runs. 30| 1. Schema validation. The arguments must match a JSON schema. If the agent invents a field, misspells a key, or passes a string where the schema expects an integer, the contract rejects the call and returns a typed error. The agent never gets a chance to corrupt the database with bad data.

2. Allowlist check. The contract maintains an explicit list of tools this agent is allowed to call. If the agent tries to invoke a function that's not on the list—maybe it hallucinated a tool name, or someone added a new tool to the codebase without updating the agent's permissions—the call is rejected. No prompt can override the allowlist.

3. Budget enforcement. Each agent gets a call budget and a token budget. The contract tracks how many tools it's invoked and how many tokens it's spent. If the agent exceeds either limit, further calls are rejected and the agent is terminated or flagged for review. This prevents runaway loops and cost overruns.

4. Result validation. When the tool returns a response, the contract validates the result against an expected type. If the tool returns malformed JSON, an error code the agent isn't equipped to handle, or a response that doesn't match the schema, the contract logs the failure and surfaces a clean error to the agent. This prevents the agent from misinterpreting garbage output as success.

40|The contract is not part of the prompt. It's infrastructure. The agent interacts with it through a typed interface, and the contract decides whether each call is admitted.

The decision tree for admitting a call

Every tool invocation goes through the same decision process. The contract doesn't care what the agent thinks it's doing. It checks the rules.

Decision tree flowchart showing: Is tool in allowlist? → Do args match schema? → Is agent within budget? → Execute tool → Does result validate? Each "No" path leads to rejection with specific error

First: is the tool in the allowlist? If no, reject immediately. The agent doesn't get to argue.

Second: do the arguments match the JSON schema? If no, reject with schema errors. The error message tells the agent which fields failed validation. It can retry with corrected arguments if it's still within budget.

Third: is the agent within its call budget and token budget? If no, reject and log the budget violation. The agent doesn't get to negotiate. 50| Fourth: execute the tool. If the tool throws an exception, the contract catches it, logs it, and returns a structured error to the agent. The agent never sees raw stack traces or internal implementation details.

Fifth: does the result validate against the expected return type? If no, log the validation failure and return a generic error. The agent doesn't get to parse broken output and invent meaning.

If all five checks pass, the contract returns the validated result to the agent. The agent can use it, knowing the data is well-formed and the call was within policy.

A minimal contract wrapper

Here's the shape of a contract wrapper in TypeScript. This isn't production code. It's the structure I start with before adding logging, retries, and observability hooks.

interface ToolContract<TArgs, TResult> {
  name: string;
  schema: JSONSchema;
  execute: (args: TArgs) => Promise<TResult>;
  resultSchema: JSONSchema;
}
    60|
class ContractLayer {
  private allowlist: Set<string>;
  private callCount: number = 0;
  private tokenCount: number = 0;
  private maxCalls: number;
  private maxTokens: number;

  constructor(allowlist: string[], maxCalls: number, maxTokens: number) {
    this.allowlist = new Set(allowlist);
    this.maxCalls = maxCalls;
    this.maxTokens = maxTokens;
  }

  async call<TArgs, TResult>(
    tool: ToolContract<TArgs, TResult>,
    args: unknown,
    estimatedTokens: number
  ): Promise<{ ok: true; result: TResult } | { ok: false; error: string }> {
    // Check allowlist
    if (!this.allowlist.has(tool.name)) {
      return { ok: false, error: `Tool ${tool.name} not in allowlist` };
    }

    // Validate args against schema
    const argsValid = validateAgainstSchema(args, tool.schema);
    70|if (!argsValid.ok) {
      return { ok: false, error: `Schema error: ${argsValid.errors.join(', ')}` };
    }

    // Check budget
    if (this.callCount >= this.maxCalls) {
      return { ok: false, error: 'Call budget exceeded' };
    }
    if (this.tokenCount + estimatedTokens > this.maxTokens) {
      return { ok: false, error: 'Token budget exceeded' };
    }

    // Execute tool
    this.callCount++;
    this.tokenCount += estimatedTokens;

    let rawResult: TResult;
    try {
      rawResult = await tool.execute(args as TArgs);
    } catch (err) {
      return { ok: false, error: 'Tool execution failed' };
    }
    80|
    // Validate result
    const resultValid = validateAgainstSchema(rawResult, tool.resultSchema);
    if (!resultValid.ok) {
      return { ok: false, error: 'Result validation failed' };
    }

    return { ok: true, result: rawResult };
  }
}

In Python, it's the same idea with Pydantic for schema validation and a class that tracks state across calls. The contract layer doesn't care what the agent is doing. It enforces the rules, rejects violations, and logs everything for post-mortem debugging.

When you still need prompt work

The contract layer doesn't replace prompts. It prevents the prompt's failures from reaching production.

90|You still need a system prompt that tells the agent what the tools do, when to use them, and what the expected workflow is. You still need few-shot examples if the agent struggles with a specific tool's argument structure. You still need prompt iteration to improve task success rates.

What you don't need is a prompt that says "be careful" or "double-check your work." The contract enforces careful. The agent can't bypass it by accident or on purpose.

The best setup I've found is: write a clear, concise prompt that describes the tools and the task. Then wrap every tool in a contract layer that enforces the rules the prompt describes. The prompt is documentation. The contract is enforcement.

When the agent fails, check the contract logs first. Did it hit the budget? Did it try to call a disallowed tool? Did the arguments fail schema validation? Those failures tell you where the agent's reasoning broke down. You can fix the prompt, adjust the schema, or expand the allowlist. But you're debugging structured data, not trying to interpret why the agent decided to invent a field.

War stories

I've seen this fail in predictable ways when the contract layer is missing or incomplete. 100| One agent was supposed to search a customer database and return results. The tool took a customer_id parameter. The agent pattern-matched a name from the conversation, hallucinated an ID that looked plausible, and called the search tool. The tool failed silently. The agent invented a response. The user got completely wrong information. The prompt said "only use IDs you've confirmed." The model thought it had confirmed it.

Solution: schema validation that required customer_id to match a UUID format, plus a contract check that rejected any ID not returned by a prior lookup tool. The agent could no longer invent IDs. It had to call the lookup tool first or admit it didn't have the data.

Another agent was given access to a file-writing tool with no allowlist. Someone refactored the codebase and added a new delete_all_files function in the same module. The agent saw it in the context window, misunderstood a user request, and called it. Wiped a test environment. The prompt said "never delete files." The model misinterpreted what the user meant by "start fresh."

Solution: explicit allowlist. The agent could call write_file and read_file. It couldn't call anything else, even if the function existed in the codebase. The refactor didn't change what the agent could do, because the allowlist controlled access, not the prompt.

110|A third agent ran a loop where it called a search API, got no results, rephrased the query, called again, got no results, and repeated 200 times. The prompt said "if you don't find anything after three attempts, stop and tell the user." The model kept trying because it thought each rephrase was a new query, not a retry.

Solution: call budget of 50 and token budget of 100K. The contract killed the agent after 50 calls. The logs showed the loop. I fixed the prompt to recognize when rephrasing wasn't helping, but the budget made sure a broken prompt couldn't burn through the entire API quota.

If you're shipping agents that touch production systems, build the contract layer first

You can iterate on prompts forever. You can tune model selection, adjust temperature, add retrieval context. But if your tools don't have schemas, allowlists, and budgets, you're shipping a system where the agent can invent arguments, bypass rules, and loop until it costs you thousands of dollars.

The contract layer is not complicated. It's a validation pass, an allowlist check, and a counter. It's 200 lines of code. It's also the difference between an agent that fails gracefully when the prompt is ambiguous and an agent that writes garbage to your database and keeps going. 120| I've debugged enough production incidents to know: the failure is rarely the model. It's the missing structure around the model. The contract layer is that structure.

Build it before you ship. Run it on every tool call. Log every rejection. When the agent fails, you'll have data, not vibes.

If you're working on production AI agents, tool orchestration systems, or anything in this space and you want to talk through your contract layer, reach out: bilaltehseen@gmail.com or check out my hire page.

Building something with AI?

I help teams ship production AI agents, retrieval systems, and document intelligence. Let's talk about yours.