Upgrading to v0.3 - joeylking/agent-runtime GitHub Wiki

Upgrading to v0.3

v0.3.0 of the core module, with providers/ollama, providers/anthropic, providers/openai, and mcp at v0.2.0, follows an audit and a four-position security review of v0.2.1. Most of what changed is invisible on the ordinary path: the same run produces the same events, the same approval hashes, and, with two stated exceptions, the same replay keys. But several changes are deliberately visible, because the old behaviour was unsafe in a way only a behaviour change could fix, and a consumer or an operator meets some of them on the very first run.

This page is the upgrade in the order you will do it: the twelve actions from the changelog, each with why it exists and exactly what to do; then what changes in a consumer's code; then what an operator does differently; then what does not change, which matters as much, because it is what tells you that your recordings and pending approvals survive. CHANGELOG.md in the repository is the authoritative list.

The versions

The nested modules each require core v0.3.0 in their go.mod, so upgrading any one of them raises the core with it. Upgrade them together:

go get github.com/joeylking/[email protected]
go get github.com/joeylking/agent-runtime/providers/[email protected]
go get github.com/joeylking/agent-runtime/providers/[email protected]
go get github.com/joeylking/agent-runtime/providers/[email protected]
go get github.com/joeylking/agent-runtime/[email protected]
go mod tidy

Only the lines for modules you import are needed. Build the operator command from the same release, because agentrt now refuses a database whose schema is not its own build's:

go install github.com/joeylking/agent-runtime/cmd/[email protected]

The twelve actions

1. Stop every older process and operator binary before v0.3.0 first opens a database

Why. OpenStore applies migration 5, the run lease, on first open. A process of an older version ignores leases entirely: it would resume a run that a current process is executing, and nothing in the file can stop it, because the older code never reads the columns that say the run is taken. The lease protects only against processes that know about it.

What to do. On every host that shares the database, stop every consumer process and every agentrt binary that has it open. Then start them on v0.3.0. The order matters only in one direction: no old process may be running once a new one has opened the file.

The migration gives one safety margin for a process you missed: every run that is RUNNING when the migration applies is given a lease DefaultLeaseTTL (30 seconds) long under the placeholder owner agentrt:pre-lease-version, so an older loop still inside a tool call has that long before a current process may take the run over. Thirty seconds is a grace, not a plan. Stop the old processes.

2. Fix the database file's mode if group or others can write it

Why. An approval's hash has no secret in it, so anyone who can write the database can forge an approval and have the runtime execute it. Earlier releases created the file readable by others and opened one that others could write.

What to do. Both openers now refuse a database, or a WAL file beside it, that group or others can write, with agentrt.ErrInsecureMode. The error names the file, its mode, and the fix:

chmod 600 runs.db

A database that group or others can only read is not refused: the first writer that owns it (OpenStore, or OpenExisting opened for writing) tightens it, and its WAL files, to owner-only. A read-only open changes nothing. A WAL file owned by a different user than the database, and a WAL path that is not a regular file, are refused too. None of these rules apply on Windows, where file modes do not mean owner, group, and others. Persistence and Inspection has the full set.

3. Without Config.Reconcile, an interrupted side effect now pauses for an operator

Why. Before v0.3, a run without a reconciliation that crashed mid-tool marked the step interrupted and went back to the agent. The agent saw its last call had no result and, reasonably, asked for it again in a new step. The runtime never re-ran the step itself, but the effect was the same: a payment, a push, or a message sent twice, with nothing in the record saying the second was a repeat of a call whose outcome nobody knew. ADR 6 records the decision.

What to do. Decide which you want:

  • Do nothing, and handle the new pause like any other approval. After a crash, Resume finds a step that was executing a tool that is not ReadOnly and pauses the run on an approval of kind interrupted_side_effect (agentrt.InterruptedSideEffect). Its presentation says the call started and its outcome is unknown. Approving and resuming runs the same request again, once, on the same step; rejecting ends the run. Your policy is re-evaluated on that resume as for any grant, so a policy that switches on Approval.Kind should expect the new kind.
  • Supply Config.Reconcile, and decide from your own journals, as before.

A step interrupted while executing a ReadOnly tool, or while the agent was deciding, continues as it always did. See Interruption and Reconciliation.

4. MCP stdio servers get only the variables you name

Why. A stdio server inherited this process's whole environment, provider keys included, and an MCP server is third-party code.

What to do. A stdio server now gets PATH, HOME, and TMPDIR (the Windows equivalents on Windows), then each variable named in Server.InheritEnv, taken from this process when set, then Server.Env entries written as NAME=value. Put every variable your server needs in one of the two:

server := mcp.Server{
    Name:       "tickets",
    Command:    []string{"tickets-mcp-server"},
    InheritEnv: []string{"TICKETS_TOKEN"},        // taken from this process
    Env:        []string{"TICKETS_REGION=eu"},    // set as written
}

HTTP_PROXY, HTTPS_PROXY, NO_PROXY, and MCPGODEBUG are not passed unless named. A server that needed a variable you did not name fails to start, or fails later; with Server.Stderr nil the last 4 KiB of its stderr are added to a failed connect or tool listing, which is usually where it says what it was missing. See MCP Tools.

5. The Anthropic adapter needs a key and a priced model when it is built, and refuses plain http:// except to loopback

Why. An adapter built without a key failed on its first request, after the run had started; one built for a model the price table does not list charged nothing against a cost limit, which silently disarms it. And a key sent over plain http to a remote host leaves the machine readable.

What to do. Set Config.APIKey, or ANTHROPIC_API_KEY in the environment. For a model anthropic.Prices() does not list, set Config.Name and price that name in your own PriceTable:

m, err := anthropic.New(anthropic.Config{Model: "claude-new-model", Name: "anthropic:claude-new-model"})
prices := providers.Merge(anthropic.Prices(), agentrt.PriceTable{
    "anthropic:claude-new-model": {InputPerMTok: 3_000_000, OutputPerMTok: 15_000_000, CachedInputPerMTok: 300_000},
})

A BaseURL with http:// is accepted only for exactly localhost or a literal loopback address, and then each connection's dialled address must be loopback. New also refuses an output cap too large for a non-streaming request. Provider Adapters lists every refusal.

6. Redirects are not followed

Why. net/http forwards headers to the host a redirect names, and the key rides in a header. A followed redirect could hand the key, and the whole prompt, to whatever host the endpoint chose.

What to do. Point BaseURL (or the MCP server's Endpoint) at the final address. A 3xx is now a permanent error naming only the host it pointed to. If you genuinely need redirects, supply your own http.Client with a CheckRedirect; the adapters keep it, and where it sends your key is then your responsibility.

7. examples/mcp needs npm ci --ignore-scripts once

Why. The example ran npx, which fetched whatever version of the filesystem server the registry served that day. It now runs the server pinned by version and integrity hash in examples/mcp/package-lock.json, started with node from a local install.

What to do. Once, from the repository root, before pin or run:

(cd examples/mcp && npm ci --ignore-scripts)

8. Read InvalidArgs, not Args, for arguments that were not JSON

Why. A decision whose arguments did not parse was stored with {"invalid_json": ...} in Args, so the record of a call that could not be parsed looked like one that could.

What to do. Code that reads a stored Decision and looked for invalid_json inside Args reads Decision.InvalidArgs instead (and InvalidResult for a result). When the bytes were not UTF-8 both are base64 and InvalidBase64 is set. Args and Result are then empty. render.Args reproduces the old rendering exactly, so a rendered conversation is unchanged; see Rendering Model Context.

9. Check DecodeError on Step and Approval

Why. A row that would not decode made its whole run unreadable: every list failed, and the run could not even be cancelled.

What to do. ListSteps and ListApprovals now return such a row with DecodeError set and the undecodable fields empty. Code that reads either should check it before trusting the fields. The run can be listed, shown, and cancelled; a driver asked to continue it fails it as internal_error without rewriting the row; an approval with DecodeError cannot be decided.

10. repo-steward's TestDecide_MapsFirstToolUse must be updated when it re-pins

Why. render.Decide now names, in the recorded reason, the tool uses a reply asked for that were not executed, so the model is told what did not run. The test pins the old reason.

What to do. Update the expected reason when repo-steward moves to v0.3.0. Any consumer with a similar assertion on Decision.Reason for a multi-tool reply does the same.

11. Parsers of trace or agentrt text must accept a date and a zone

Why. A bare clock reading is ambiguous across midnight, and a time with no zone is ambiguous between the operator's local time and whatever produced the record.

What to do. trace.Writer lines now begin 2006-01-02 15:04:05.000 -0700 and agentrt's text output uses 2006-01-02 15:04:05 -0700. Anything that parses either, a log shipper or a test, accepts the longer form. -json output and trace.JSONL already used RFC 3339 and are unchanged.

12. Expect each step to take longer on disk

Why. A store opened for writing now syncs every commit (synchronous=FULL). In WAL mode NORMAL can lose the last commits on power loss, and the last one may be the step.tool_started of a tool that ran; the next owner would then treat a tool that ran as one that never started.

What to do. Nothing, unless step latency matters to you. Measured on an Apple SSD the cost is about a third more per step (roughly 220 to 300 µs); on a disk whose fsync waits for the medium it is milliseconds per commit, four commits per step. Performance has the table.

What changes in a consumer's code

Removed. Store.CreateRun, StatusCreated, and EventRunInterrupted. Neither consumer used them. A run is created by Start or StartWithID; there is no CREATED status; an interruption is recorded per step as step.interrupted. StatusInterrupted still exists, and Resume and Cancel accept a run in it, but the runtime does not write it: an interrupted run stays RUNNING until Resume finds it by its lease.

New Decision fields. InvalidArgs, InvalidResult, and InvalidBase64 (action 8). They are set only by the runtime, on a recorded decision; whatever an agent puts there is dropped.

DecodeError on Step and on Approval (decode_error in approval JSON) (action 9).

ErrRunLeased on resume. Resume of a run whose loop is alive in another process, or that this driver is executing in another call, returns agentrt.ErrRunLeased, which names the owner and the expiry and matches ErrRunState under errors.Is. After a real crash, Resume returns it until the dead process's lease expires, 30 seconds by default. A consumer whose crash recovery calls Resume on startup must treat it as "not yet", not as a failure of the run; Run Lease shows how. A loop that loses its lease returns ErrLeaseLost.

anthropic.New refusals (action 5): no key, an unpriced model without Config.Name, an http:// base URL to a host that is not loopback, a base URL that is not http or https or carries user information, and an output cap too large for a non-streaming request.

MCP InheritEnv and Env (action 4). Also new on mcp.Server: Stderr, and ConnectTimeout (default mcp.DefaultConnectTimeout, 30 seconds), which bounds connecting, initializing, the first tool listing, and Load's own checks.

Smaller changes worth knowing:

  • ModelConfig.MaxRetries zero means no retries, as it always has; set it to retry transient failures. Retries now honour Retry-After up to ModelConfig.MaxRetryAfter (default DefaultMaxRetryAfter, one minute). See Limits.
  • Under a token or cost limit, a request with no output cap is refused before dispatch, because it cannot be projected.
  • A new limit, Limits.GrantTTL, bounds how long an approved grant may wait for Resume. It is zero, no expiry, by default, and a run stored before it existed reads it as zero.
  • Config.LeaseTTL and Config.LeaseOwner configure the lease. A fast resume after a crash comes from a lower LeaseTTL, not from a stable LeaseOwner: the stored owner always carries a random suffix per driver.
  • render.Decide fails the run on a context-window overflow (model_context_window_exceeded) rather than treating it as a truncation.
  • trace.WriterWithErr and JSONLWithErr report write failures; trace.JSONL no longer drops an event it cannot encode.
  • ApproveShown and RejectShown, in the core and in view, bind a decision to the hash a front end displayed. A consumer with its own approval surface should use them.
  • Operator errors are typed: agentrt.ErrRunState and ErrNotPending, and in view ErrRunNotWaiting, ErrApprovalDecided, and ErrDatabaseLocked.
  • The page reads (Store.ListRunsPage, GetRunCapped, ListStepsPage, ListApprovalsPage, ListEventsPage, PendingApprovalIDsOf, and view.RunsPage, DetailPage) are for any front end reading a database it does not fully trust.
  • Consumer JSON nested deeper than 256 levels, or carrying a repeated key, invalid UTF-8, or an unpaired escaped surrogate, is refused where it enters. A tool schema that refers to anything outside itself fails registration. See Writing Tools.

What an operator does

  1. Stop older processes and binaries first (action 1), on every host that shares the database.
  2. Check file modes (action 2): chmod 600 any database group or others can write.
  3. Use the agentrt from the same release. It never creates or migrates a database: a mistyped -db is an error, not a new empty file, and a database on another schema version is refused with a message saying which side is newer.
  4. Expect approve and reject to ask. They print the approval first, then decide only with -approval <id>, -yes, or y typed at an interactive prompt. Run from a script with none of those, they refuse with exit 2. The decision is bound to the hash of what was printed: if the stored approval changed in between, the command exits 1 and asks you to read it again. -by is a label recorded as given; an empty -by with $USER unset is refused.
  5. Expect paged output. runs, show, and events read only the page they print, take -limit and -offset, and say how many more there are.
  6. Expect the new pause kind interrupted_side_effect (action 3) after a crash, with a presentation saying the outcome of the call is unknown. Approving it may run the call a second time; that is the decision you are being asked to make.

The Operator Command has every flag and message.

What does not change

Approval hashes. The canonical encoder behind every content and approval hash is now written out in hash.go and pinned byte for byte to the hashes already stored, by golden tests (TestCanonicalJSON_MatchesEncodingJSON, TestContentHash_Golden). An approval pending before the upgrade can be approved and resumed after it.

Replay keys, with two stated exceptions. The key is still the SHA-256 of the model name and the canonical JSON of the request (replay.Key), and numbers now keep their literal text, which changes nothing for any number a float64 holds exactly (TestKey_Pinned). What can change a key is what render produces for a step, and the release notes name two cases in which v0.3.0 changes it: 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. Recordings that contain none of these replay unchanged.

Manifest hashes for ordinary schemas. The MCP pin is now computed from the raw tools/list bytes instead of a float64 round trip, and a number a float64 holds without loss is written exactly as before, so a manifest pinned before the upgrade still loads. Only a schema carrying a number a float64 cannot represent, such as a bound past 2^53, hashes differently, and that tool is refused once at the first Load and pinned again.

The ordinary path's events and clock readings. A run that is not interrupted, not leased away, and not cancelled writes the same events in the same order, and reads Config.Now the same number of times in the same order (TestDriver_ClockReadingsAreStable, TestDriver_CachedViewEqualsReloadedView). Acquiring, renewing, and releasing a lease write no events; only a takeover writes lease.taken_over.

Your database's runs. Migration 5 adds two columns and changes no row except the grace lease on RUNNING runs. A v0.2.1 database migrates with its runs intact (TestStore_V021DatabaseMigratesWithItsRuns). There are no down migrations: once v0.3.0 has opened a database, an older version is not pointed at it again.

Next

Run Lease and Security Model explain the two largest changes. Status and Roadmap summarizes the release.