Catch schema drift

Your tool declares a parameter schema. The model emits arguments. Your executor reads them. AgentKit checks, at three points, that those three stay in agreement, so a malformed argument never reaches your executor and a backend schema that drifts from your app fails loud instead of silently.

There are three layers. Two run for every app-driven turn; one is specific to cloud-profile mode, where the backend owns the tool schema and your app owns the executor.

Layer 1: drift gate at session start (cloud-profile)

In cloud-profile mode the SDK sends only tool names; the backend resolves each tool's parameter schema from the server-side profile, and the model is prompted with that schema. Nothing in the app authored it, so if it drifts from what your executor expects the failure is silent: it works in test and fails in production.

To close that gap the cloud publishes its resolved schemas. On the first send(), before any billed stream request, the provider fetches GET /v1/agent/capabilities (a cached, single-flight probe it already uses for document support) and reads the tool_schemas it exports. For each exported tool the SDK proves a subtyping relation against your local schema: every value the cloud schema accepts must be a value your executor's schema accepts. If it cannot prove that, the turn throws before a stream request is spent.

The check proves a subtyping relation, not schema equality. It ignores backend-side narrowing (a tighter numeric range, a stricter pattern) because that can only shrink what the cloud sends, never broaden it. It fails when the cloud is genuinely broader than your executor: a parameter your executor requires that the cloud leaves optional, a type your executor would reject, an enum your executor does not allow, an extra property under a closed local schema, or a tool the cloud enabled that your app cannot execute.

This layer is fail-loud by design. A structural mismatch is a deploy or contract bug, not something to retry, so it halts. It never self-repairs.

Old or unreachable relays degrade observably

The export is additive and independently versioned (capabilities_version, gated on its major). A relay that predates it returns neither key; a transient network failure returns nothing. In both cases the gate cannot run, so it logs a warning and proceeds, relying on layer 2 to backstop every value-level manifestation of drift before any side effect. A relay that responds but returns a malformed or too-new envelope is a different story: that is a reachable broken backend, and it throws rather than degrading.

Layer 2: argument validation before the executor

For every app-driven turn, before your executor runs, the SDK validates the model's arguments against the tool's local schema. A call whose arguments do not match (a missing required field, a wrong type, an undeclared property under a closed schema) is fed back for repair rather than dispatched, so genuinely invalid arguments never reach execute. A no-argument call, sent as null or omitted, validates as an empty object against an object schema, so a no-argument tool is never flagged.

This is a pure local check against your own declared schema, so it runs for every app-driven provider, not only cloud-profile. Validation gates the call; it does not rewrite it. execute receives the model's original arguments (a no-argument call can arrive as .null), so read them with the typed JSONValue accessors, which return nil for an absent field rather than trapping.

Layer 3: bounded self-repair

A mismatch is fed back to the model to correct, the same way a .failed outcome is fed back, and the model tries the call again. The number of repair attempts per turn is bounded by AgentRunLimits.maxToolArgumentRepairs (default 1). The budget is per turn, not per call, so a model that emits a fresh call id each round cannot reset it, and each repair also consumes a maxProviderRoundTrips slot.

When the budget is spent the turn throws ToolArgumentRepairExhausted. Because every attempt happens before the executor runs, an exhausted turn has executed no tool and committed no undo entry.

Repair is pre-execution, so it carries no side-effect risk. It is not available for provider-driven execution (for example Apple Foundation Models), where the provider runs tools internally and the arguments cannot be intercepted before the call.

The typed errors

Error Layer When
SchemaDriftError 1 the cloud schema is provably broader than your executor's, or enabled a tool you cannot execute
UnsupportedCloudToolSchema 1 the cloud schema uses a JSON Schema construct the SDK cannot model, so the subtyping cannot be proven (fail closed)
CapabilitiesContractError 1 the capabilities response itself is malformed or its capabilities_version is a newer major
ToolArgumentRepairExhausted 3 argument validation kept failing past maxToolArgumentRepairs

Layers 1 and 3 throw from send(). Layer 2 on its own does not throw; it feeds back and, only when repair is exhausted, surfaces as the layer-3 error.

Scope at a glance

  • Layer 1 runs only in cloud-profile mode, where a backend owns the schema. Other providers declare nothing to drift against, so it is skipped.
  • Layers 2 and 3 run for every app-driven turn, cloud-profile or not.
  • None of the three apply to provider-driven execution, which runs tools inside the provider.