Models and Replay - joeylking/agent-runtime GitHub Wiki

Models and Replay

The model interface

type Model interface {
    Name() string
    Generate(ctx context.Context, req ModelRequest) (ModelResponse, error)
}

A request has a system prompt, messages of content blocks, tool specifications, and an output cap. A response has text, tool uses, usage, a stop reason, and the provider's raw bytes.

What Generate returns on failure tells the accounting caller how to charge and whether to retry:

Return Means The caller
TransientError{Err, RetryAfter} a failure worth retrying, such as a connection refused or a server overload retries, if retries are configured, waiting at least RetryAfter
a timeout, or a body cut off mid-read, returned bare the request was sent and no answer came back; the provider may have served and billed it records the attempt as ambiguous and charges the conservative estimate; retries it as transient
ServedError{Usage, Err} the provider served the request, and so billed it, but the reply cannot be used: it did not decode, or carried a tool call whose arguments are not JSON charges the usage the provider reported; does not retry, since asking again would be billed again; the run fails as model_unavailable
ServedError wrapping ErrUnusableUsage the reply's usage could not be read: a count negative, too large to hold, or not an integer charges the conservative estimate; does not retry; the run fails as model_unavailable
anything else permanent records an error and ends the run as model_unavailable

The adapters that implement it

Three adapters ship, each a nested module in the repository at v0.2.0: providers/ollama, providers/openai, and providers/anthropic. They are not a provider abstraction (there is no layer between them and this interface), but they mean a consumer no longer writes one. Provider Adapters covers them in full; two conventions matter everywhere else.

Name() is <provider>:<model>: ollama:qwen3:30b-a3b, anthropic:claude-opus-5. The name keys the price table and the recording directory, so one prefixed namespace holds several providers without collisions, and a run's model calls say which provider served them. anthropic.Prices() is keyed by these names; ollama.Free(...) and openai.Free(...) take the built adapters and record their models as free, which is what providers.PriceFor needs to tell a free model from an unpriced one. ollama.Name, openai.Name, and anthropic.Name return the default name for a model id, for a consumer that keys a table before it builds the adapter.

Config.Name overrides it. That exists for a consumer whose recordings or price table already use another name: Casework's recordings are keyed by the model name together with the request hash, so adopting the prefixed name would turn every recorded response into a miss. An override keeps existing recordings valid, at the cost of pricing the model by hand.

The accounting caller

The driver wraps the model in a ModelCaller and gives that to the agent. For each Generate:

  1. the output cap is clamped to Limits.MaxOutputTokensPerCall when that is set;
  2. the limits are checked: the call count including retries, and the token and cost totals plus a conservative estimate for this call and every call of this step still in flight. A request with no output cap is refused under a token or cost limit, because it cannot be projected;
  3. a model_calls row is reserved before dispatch, in a write that requires the run's lease held and unexpired, so a loop that has lost its run sends nothing;
  4. the call runs under a per-attempt timeout;
  5. usage, latency, and cost from the price table are recorded and added to the run totals, even if the caller's context was cancelled after dispatch;
  6. a transient error is retried with backoff, each attempt its own row, until the retry count is exhausted;
  7. a timeout or lost connection after dispatch is recorded as ambiguous and charged at the conservative estimate, because the provider may have done the work. A lost connection is matched by error (io.EOF, a reset, a closed connection, a failed read or write), not by message text.

Exhausting retries ends the run as model_unavailable. Crossing a limit ends it with the matching limit reason. Every attempt is visible in the store and the event log.

Configuration: CallTimeout defaults to two minutes and Backoff to one second. MaxRetries defaults to zero, which means no retries: set it to retry transient failures. MaxRetryAfter (default DefaultMaxRetryAfter, one minute) is the longest Retry-After honoured; see Limits. Prices are per model name in a PriceTable; an unpriced model costs zero, which is why providers.PriceFor treats a missing price as an error rather than as free: silently charging nothing would defeat a cost limit on a paid model.

Usage that cannot be charged

A provider's usage is what the cost and token limits are checked against, so believing a wrong number disarms them. The caller refuses two shapes itself: a negative count, and more cached input than input. The adapters refuse a third before it reaches the caller: a count that is negative, too large to hold, or not an integer, returned as a ServedError with zero usage wrapping ErrUnusableUsage (providers.ErrUnusableUsage is the same value).

Either way the attempt is charged the conservative estimate, the request's estimated input tokens and its output cap; the reply is not used, because whoever reported the usage is not to be believed about the rest; the attempt is recorded as an error naming why; it is not retried; and the run fails as model_unavailable.

Run totals and costs are added with saturating arithmetic. A huge but well-formed count fills a total to the maximum rather than wrapping negative, and the next limit check refuses the run.

Replay

The replay package provides three models so tests never call a provider:

  • Scripted returns fixed responses in order.
  • Recorder wraps a live model and writes each response to a directory, keyed by the model name and a canonical hash of the request.
  • Replayer serves responses from a directory and fails on a miss.

Two properties came from the second consumer, Casework, which feeds raw provider messages back into later requests. First, the provider's raw bytes are stored as a JSON string, never as embedded JSON, so replay returns them byte-identical: an embedded value would be re-indented and, with the default encoder, HTML-escaped, and a request rebuilt from it would hash to a different key. Second, identical requests are stored in sequence, the first at <key>.json and the rest at <key>.<n>.json, and replayed in the order recorded; a request made more times than it was recorded is unrecorded, because the replayed session has diverged. Re-recording a key replaces its whole sequence.

The key keeps large integers. replay.Key is the SHA-256 of the model name and the canonical JSON of the request, and numbers keep their literal text, so two requests that differ only in an integer beyond float64 precision get different keys. For every number a float64 represents exactly, which is every number in the recordings written before this, the key is unchanged (TestKey_LargeIntegersDoNotCollide, TestKey_Pinned).

Recordings are private and whole. Recorder creates its directory 0700 and writes each recording through a temporary file it created itself in that directory, 0600, synced, then renamed into place. No one else can prepare the name, read a recording, or see one half written.

Because the key covers the whole request, a recording is invalidated by any change to the system prompt, the tool descriptions or schemas, or the shape of tool results. That is a feature: it makes silent drift between what was measured and what is running impossible. The key's shape itself has not changed since v0.1; what changes a key is a change in what goes on the wire. For a consumer rendering through render, a release that changes what render produces for a step says so in its notes. v0.3.0's notes name two cases: a reply with more than one tool use, whose recorded reason now names the ones not executed, and a reply with no tool use whose text is longer than 500 bytes, whose recorded reason is now capped. One more change, not listed in the README's compatibility note, can also alter the rendered request: the recent-results window now counts only rendered steps, so a run holding a step interrupted while deciding, within the last RecentResults steps, now shows one more result in full than it did. See Upgrading to v0.3.

Run replayed tests with the provider address pointed at a closed port, so a replayer that fell through to a live call would fail loudly.

Token estimation

EstimateInputTokens gives the conservative estimate used for projections: one token per three bytes of the serialized request. It overestimates on purpose, so a run stops before a limit rather than just after.

⚠️ **GitHub.com Fallback** ⚠️