Skip to content

Writing an Agent

Joey King edited this page Oct 5, 2026 · 4 revisions

Writing an Agent

This page is for a consumer that lets the runtime run its loop and supplies the agent. If you already have a loop, hand-written or inside another framework, and want the runtime's controls without replacing it, see The Gate. If the loop is an MCP host in any language, see MCP Proxy.

An agent implements one method:

type Agent interface {
    Decide(ctx context.Context, in StepInput) (Decision, error)
}

What it receives

StepInput holds the run, every prior step in order with its decision and observation, the approvals, the tool specifications, and a ModelCaller when the driver was configured with a model. That is the whole context. The agent keeps no state between calls, which means:

  • a resumed run in a new process renders exactly what a continuous one would;
  • the audit log and the agent's view are the same data;
  • tests can construct any history and ask for the next decision.

What it receives is a copy. The driver keeps the run's steps in memory between steps (see Performance), and hands the agent and the policy each their own mirror of them, so an agent that modifies a step, a decision, or a slice it was given changes nothing the driver, the policy, or the store sees, and nothing loop or failure detection reads. The one write that persists is into the bytes of a json.RawMessage it was handed, and then only into its own later views. Treat the input as read-only anyway.

What it returns

A Decision of kind tool_call with a tool name and JSON arguments, complete with a result, or fail with a message. Put the model's stated rationale in Reason; it is recorded as data and never acted on.

Return an error only for infrastructure failures; any other error ends the run as agent_error. A model that produced nothing executable is not an error either: render.Decide records it as a deliberately invalid decision kind, which the runtime counts as a step failure and the next render answers with a nudge. Errors from the ModelCaller should be returned as they are, because they are typed and the driver maps them: ErrLimit to the limit's reason, ErrModelUnavailable to model_unavailable, and ErrLeaseLost, which means this process no longer holds the run, to stopping the loop without failing a run that is no longer its to fail. If the context is cancelled while the agent decides, the step stays in flight and a later Resume treats it as interrupted.

What the runtime does with what you return

The decision is recorded verbatim, before validation, so the audit log shows what the agent asked for even when it was invalid. Arguments, and a complete decision's result, must be usable JSON: one well-formed value, valid UTF-8, nested no deeper than 256 levels, with no repeated object key and no escaped surrogate outside a pair. Anything else would be stored or hashed lossily (encoding/json accepts a repeated key and decodes an unpaired surrogate as U+FFFD, so two different documents could share a hash), so it is refused where it enters:

  • the bytes are kept verbatim in Decision.InvalidArgs or InvalidResult, base64 with InvalidBase64 when they were not UTF-8, and Args or Result are left empty, so a recorded call that could not be parsed never looks like one that could;
  • the step ends with an invalid_decision observation naming what was wrong, which counts as a failure, and the agent decides again.

The three Invalid fields are the runtime's. Whatever an agent puts in them is dropped.

Model-driven agents

A model agent is now render plus a provider adapter, and the whole of it fits on a screen:

type agent struct{}

func (a *agent) Decide(ctx context.Context, in agentrt.StepInput) (agentrt.Decision, error) {
    msgs := render.Messages(in, render.Options{Opening: opening})
    resp, err := in.Model.Generate(ctx, agentrt.ModelRequest{
        System: system, Messages: msgs, Tools: in.Tools, MaxOutputTokens: 4096,
    })
    if err != nil {
        return agentrt.Decision{}, err
    }
    return render.Decide(resp), nil
}

func opening(in agentrt.StepInput) string {
    return "The counter starts at 0. " + in.Run.Goal
}

render.Messages turns the recorded steps into an assistant turn and a tool-result turn each, with synthesized tool-use ids, a recent-results window, and a content cap; render.Decide maps the response back to a decision, failing the run on a refusal or a context-window overflow, executing only the first tool use, and naming any others in the recorded reason. The model comes from a nested module — ollama.New, openai.New, anthropic.New — and is given to the driver as ModelConfig.Model, not to the agent. See Rendering Model Context and Provider Adapters. examples/live is this code with four tools and a policy around it.

Set MaxOutputTokens on every request, or Limits.MaxOutputTokensPerCall on the run: under a token or cost limit, a request with no output cap cannot be projected and is refused before dispatch.

What is left to write is the opening message and the system prompt. Keep the system prompt constant and free of untrusted content. Untrusted content belongs in tool results, labelled as data, so that the model's requests are reproducible and recordable. Domain facts belong in the opening message, which is the one hook render requires.

Do not retry inside Decide when the model returns no tool call. render.Decide records such a reply as a deliberately invalid decision kind, the driver observes it as invalid_decision, the step counts against the consecutive-failure limit, and the next render nudges the model with what went wrong. That keeps the failure in the audit log and under the driver's limits instead of inside a loop only the agent can see.

Scripted agents

The scripted package provides an agent that returns a fixed list of decisions in order. Use it to test the runtime, tools, and policy deterministically:

ag := &scripted.Agent{Decisions: []agentrt.Decision{
    scripted.ToolCall("read_file", `{"path":"main.go"}`, "look first"),
    scripted.ToolCall("write_file", `{"path":"main.go","content":"..."}`, "fix"),
    scripted.Complete(`{"done":true}`),
}}

Scripted agents leave Model nil in the driver configuration.

Things the agent cannot do

Reach the policy, the store, or the observer. Change a tool's specification. Execute anything except through a tool call that passed validation and policy. See a tool result before the step that produced it completed. Change what the policy or a later step sees by modifying its input. Ask again, after a crash, for a side effect whose outcome is unknown, unless the consumer's reconciliation lets the run continue: without one, a call that was executing a tool that is not read-only goes to an operator, not back to the agent. See Interruption and Reconciliation.

Clone this wiki locally