DEV Community

Zira
Zira

Posted on

Your AI Agent Should Pin Tool Contracts, Not Just Tool Names

A tool name is not an API contract.

If an agent calls send_invoice, the name alone does not tell you which input schema, permission set, side-effect behavior, or provider version will execute. A tool can keep the same name while changing a required field, widening its resource scope, or turning a dry-run into a real mutation.

This article shows a small tool-contract registry and a test plan that makes contract drift explicit. The goal is to make an incompatible change fail before a model can invoke it.

The contract an agent should resolve

Treat a tool reference as a tuple, not a string:

  • name: stable human-readable identifier
  • version: major/minor contract version
  • schema_hash: canonical hash of the input and output schema
  • capabilities: exact resources and actions the call may touch
  • effect_class: PURE, IDEMPOTENT, REPLAYABLE, UNKNOWN, or NON_RETRYABLE
  • credential_version: the identity and policy revision checked at dispatch

A model can see a friendly description. The runtime must resolve and enforce the tuple.

A minimal registry

Here is a deliberately small Python design. Resolution happens before execution, and the resolved contract is recorded with the run.

from dataclasses import dataclass
from hashlib import sha256
import json

@dataclass(frozen=True)
class ToolContract:
    name: str
    version: str
    schema_hash: str
    capabilities: frozenset[str]
    effect_class: str
    credential_version: int

def canonical_hash(schema: dict) -> str:
    raw = json.dumps(schema, sort_keys=True, separators=(",", ":"))
    return sha256(raw.encode()).hexdigest()[:16]

def resolve(registry, requested, current_credential_version):
    contract = registry.get((requested["name"], requested["version"]))
    if contract is None:
        raise RuntimeError("tool_contract_not_found")
    if contract.schema_hash != requested["schema_hash"]:
        raise RuntimeError("tool_schema_drift")
    if contract.credential_version != current_credential_version:
        raise RuntimeError("credential_policy_changed")
    return contract
Enter fullscreen mode Exit fullscreen mode

Do not let the model choose capabilities, effect_class, or credential_version. Those are runtime-owned fields.

Record the contract at dispatch time

Store a dispatch record before the side effect begins:

{
  "run_id": "run_0187",
  "effect_id": "effect_0187_03",
  "tool": "billing.send_invoice",
  "version": "2.1",
  "schema_hash": "a91e7c3b44e0c012",
  "capabilities": ["invoice:write:tenant-42"],
  "effect_class": "IDEMPOTENT",
  "credential_version": 19,
  "status": "DISPATCHED"
}
Enter fullscreen mode Exit fullscreen mode

If the worker crashes after dispatch, the record lets a reconciler ask the provider whether the effect happened. It must not silently retry with the newest contract.

Safe rollout: expand, migrate, contract

  1. Expand: publish the new contract beside the old one. Add an adapter only if it preserves the old effect semantics.
  2. Migrate: update callers, prompts, cached plans, and queued work to request the new version. Reject new work that mixes versions.
  3. Contract: remove the old version only after the queue, retry records, and reconciliation jobs contain no references to it.

For minor changes, keep the same major version only when you can prove backward-compatible inputs and outputs. A new optional field is not automatically safe if it changes authorization, cost, or side-effect behavior.

Tests that catch real drift

Test Expected result
Same name, changed required input Reject with tool_schema_drift
Same schema, narrower capability Reject if the request asks for the old scope
Credential version changes after planning Recheck and reject before dispatch
Worker retries with an older contract Resolve the recorded version, never latest
Crash after provider acceptance Mark UNKNOWN and reconcile by effect ID
Old queued job after contract retirement Quarantine for explicit migration
Adapter changes effect class Require a new major version

The most valuable test is a deliberate mismatch: send a request with a valid tool name but the wrong schema hash. If it executes, the registry is decorative.

What this changes operationally

This boundary separates three different failures:

  • Process failure: the worker died or restarted.
  • Contract failure: the requested tool semantics are no longer available.
  • Effect uncertainty: the provider may have accepted the call before the crash.

They need different recovery paths. Restarting a worker can address the first. It cannot fix the second, and it must not guess about the third.

If you run OpenClaw or another agent continuously, the hosting layer is only useful when this registry, its durable dispatch records, and its reconciliation jobs survive restarts. A managed runtime such as managed OpenClaw hosting on Ampere can be one deployment option to evaluate for that always-on workload, but it does not define your tool contracts or remove credential and prompt-injection risk.

Practical checklist

  • Is the contract version explicit?
  • Is the schema hash computed canonically?
  • Are capabilities and effect class runtime-owned?
  • Does dispatch recheck credential and policy versions?
  • Can queued work resolve the exact recorded version?
  • Is UNKNOWN reconciled by provider evidence rather than a blind retry?
  • Are old contracts retired only after queue and ledger migration?
  • Do CI tests prove that a same-name schema change is rejected?

The model can choose what it wants to do. The runtime must decide whether the exact tool contract is still safe to do it with.

What is the smallest contract field your agent runtime currently leaves implicit?

Top comments (0)