agentkit

package module
v0.3.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 10, 2026 License: Apache-2.0 Imports: 12 Imported by: 0

README

agentkit

A durable runtime for long-running LLM agents in Go.

An agent loop is easy to write and hard to operate. The usual one keeps the whole run — conversation, tool results, "waiting for the user to click approve" — in the memory of one process, so a deploy, a crash, or a scale-in ends it. There is nothing to resume, because nothing was ever written down.

agentkit stores each run as a durable Process. A worker claims it and executes transitions one at a time, committing each before starting the next, then releases the run; any worker can continue from the last committed transition — after a crash, a deploy, or a wait that lasted a day. It is built on gollem for the LLM client and tool abstractions, and stays deliberately small: the kernel is a state machine, a lease, and a wait queue — nothing more.

Is agentkit for you?

Use it when:

  • a run must survive a deploy, a crash, or a scale-in;
  • a run may wait minutes or days for a human, a timer, or a child process;
  • several worker processes, on several hosts, execute from the same store;
  • token and tool usage must be measured and bounded per run.

You probably do not need it when the agent finishes inside one request and losing an in-progress run is acceptable. An in-memory loop is less machinery, and it is the right answer until a run outlives the process holding it.

What it provides

  • Crash recovery — every transition is committed to your store before the next one starts, and any worker resumes from the last checkpoint.
  • Durable waits — a run parks on a question, a timer, or a set of children without holding a goroutine, a connection, or a worker.
  • Multi-worker execution — workers claim persisted runs under a lease, and a worker that lost its lease cannot commit, so any number of workers on any number of hosts can share one store.
  • Child processes — a strategy spawns children and waits for their results; the children and the parent's new state commit in one atomic write.
  • Usage metering and limits — token, tool, step and spawn usage accumulates on the Process, and a strategy's Limit decides when a run has had enough — or tells the agent the budget is nearly gone and lets it finish on its own terms.
  • Middleware — one registration wraps every agent's transitions and effects: audit, tracing, redaction, retry, tool policy.

How it works

agentkit architecture

The shape of a run, not every edge of it: retries, cancellation from pending and the rest of the lifecycle are in docs/concepts.md.

Term What it is
Process one agent run — its state, status, metrics and lease, all in the store
Strategy your code: what one transition does
Kernel creates and reads Processes, and runs the workers that execute them
Repository the storage behind all of it: state, awaits, events, leases

A worker does the same five things on every transition:

  1. Claim a pending Process — or a waiting one whose timer is due.
  2. Decode the state the previous transition committed.
  3. Run one Step. It reaches the model, the tools and its children through Syscalls (Generate, CallTool, SpawnChild, Await, Emit, Metrics, Now), which is where metering and limits are applied.
  4. Commit the new state, the decision, emitted events, declared awaits and spawned children in a single atomic write.
  5. Continue, suspend, succeed or fail. A Continue runs the next transition under the same claim, up to WithMaxStepsPerClaim of them (16 by default); then the run goes back to pending for any worker to pick up.

A Strategy[S, I, O] names the three types that cross that boundary:

  • S — the state persisted after each transition;
  • I — the typed input Spawn accepts;
  • O — the output persisted when the run succeeds.

Because a Step is the unit that gets checkpointed, "how much work per Step" is the main design decision you make. Serialization stays yours: the kernel stores the bytes your EncodeState and EncodeOutput produce, and never looks inside them. Concepts in full: docs/concepts.md. Writing your own strategy: docs/writing-strategies.md.

Execution guarantees

Durability is not exactly-once. An LLM is non-deterministic, so agentkit refuses to pretend replay is.

Guaranteed

  • a committed transition is never lost;
  • one transition commits atomically — state, awaits, events, spawned children and metrics land in a single write, all of it or none.

That is the whole list.

At-least-once, with a non-deterministic replay

  • a transition that crashes before committing is re-run from the last committed state;
  • the LLM and tool calls it already made may run again (an LLM re-charge is accepted), and the re-run may take a different path;
  • once a lease expires, the run can be claimed again while the original worker is still alive, so two workers may execute the same transition. Only one of them can commit: a fresh LeaseToken per claim and a Rev compare-and-set fence out the worker that lost its lease.

Your responsibility

  • a side-effecting tool must be idempotent;
  • authorization is enforced inside the tool, never by asking a human first;
  • a Repository you implement must satisfy the contract the kernel relies on.

There is no effect journal, no operation label, and no deterministic clock — a worker just re-executes Step from the checkpoint. For exactly-once effects, commit the decision to state first and execute it in the next transition. Read docs/execution-model.md before writing a tool that touches the outside world; it is short, and it is the part people get wrong.

Try it

The bundled quickstart needs no credentials: with no model configured, the LLM is a stub replaying a script.

git clone https://github.com/gollem-dev/agentkit
cd agentkit/examples
go run ./quickstart
model:   scripted stub (set GEMINI_PROJECT_ID and GEMINI_LOCATION to run against Vertex AI)
spawned: 01a050c2-c09b-7918-bca4-1e468f5c1fdf
status:  succeeded
answer:  A durable agent runtime checkpoints an agent after every step, so a crash resumes the work instead of restarting it. The state lives in a store rather than in one process's memory, so any worker can pick it up.
metrics: llm_calls=1 input_tokens=64 output_tokens=16

It registers an agent, writes a Process, runs a worker until that Process finishes, and prints the persisted result with its usage. Six more programs in examples/ cover tools, human input, crash recovery, parallel children, middleware and tracing.

Minimal integration

Every agentkit application has the same four parts: register → construct → spawn → serve.

go get github.com/gollem-dev/agentkit
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"os"
	"time"

	"github.com/gollem-dev/agentkit"
	"github.com/gollem-dev/agentkit/repository/memory"
	"github.com/gollem-dev/agentkit/strategy/simple"
	"github.com/gollem-dev/gollem/llm/claude"
)

func main() {
	ctx := context.Background()

	client, err := claude.New(ctx, os.Getenv("ANTHROPIC_API_KEY")) // any gollem LLM client
	if err != nil {
		log.Fatal(err)
	}

	// 1. Register. The typed handle it returns is the only way to spawn this
	//    agent, so the input type is checked at compile time.
	reg := agentkit.NewRegistry()
	assistant, err := simple.Register(reg, "assistant", 1)
	if err != nil {
		log.Fatal(err)
	}

	// 2. Construct the kernel: repository, default model, registry. memory.New()
	//    keeps runs in this process — see "Persistence" below for the rest.
	kernel, err := agentkit.New(memory.New(), client, reg)
	if err != nil {
		log.Fatal(err)
	}

	// 3. Spawn. This writes a pending Process and returns its id; nothing has
	//    executed yet.
	pid, err := assistant.Spawn(ctx, kernel, simple.Input{Prompt: "Summarize the news"})
	if err != nil {
		log.Fatal(err)
	}

	// 4. Serve. A deployment runs this in its own process, and in as many of them
	//    as it likes. Here it runs in the background just long enough to finish
	//    this one Process.
	serveCtx, stop := context.WithCancel(ctx)
	defer stop()
	go func() {
		if err := kernel.Serve(serveCtx); err != nil {
			log.Print(err)
		}
	}()

	for {
		proc, err := kernel.GetProcess(ctx, pid)
		if err != nil {
			log.Fatal(err)
		}
		if !proc.Status.Terminal() {
			time.Sleep(100 * time.Millisecond)
			continue
		}
		if proc.Status != agentkit.ProcessSucceeded {
			log.Fatalf("run %s: %+v", proc.Status, proc.Failure)
		}

		var out simple.Output // the strategy owns this format; the kernel stored bytes
		if err := json.Unmarshal(proc.Output, &out); err != nil {
			log.Fatal(err)
		}
		fmt.Println(out.Texts)
		return
	}
}
ANTHROPIC_API_KEY=... go run .

This program prints the answer and exits. A real deployment keeps the same four parts but stops running them in one process: Serve moves into its own worker deployment, and the polling loop becomes whatever your application already uses to report on a job.

Running behind an HTTP API

Agents are slower than a request, so keep the HTTP tier stateless. It only creates and reads persisted Processes; workers execute them separately, through the same Repository.

HTTP API (stateless)                    Worker (separate deployment)
  POST /jobs      -> Agent.Spawn          Kernel.Serve
  GET  /jobs/{id} -> Kernel.GetProcess

Spawn runs the strategy's Init and writes a pending row, then returns its id: no request is held open while the agent runs, and nothing has executed yet. GetProcess can be answered by any replica, because the state is in the Repository rather than in the replica that accepted the POST. Pass agentkit.WithIdempotencyKey(...) on Spawn so a retried POST does not start a second run.

Working code: examples/durable-worker submits and executes in separate processes, and resumes a run whose worker was killed mid-transition.

Waiting for a human

This is the case a plain loop handles worst. The strategy suspends on a question instead of blocking:

if !st.Confirmed {
	return st, agentkit.Suspend(agentkit.Question("confirm", []byte("run X? (yes/no)"))), nil
}

The Process is now waiting and consumes nothing — no goroutine, no connection, no worker. Any instance of your application can deliver the answer, whenever it arrives:

awaits, _ := kernel.ListAwaits(ctx, pid)          // what is this run waiting for?
err := kernel.Respond(ctx, pid, "confirm", []byte("yes"), agentkit.WithRespondedBy("alice"))

Respond commits the answer and returns the Process to pending; the next worker to claim it re-enters Step with Confirmed set. The human may take an hour, and the process that asked may be long gone.

This is confirmation, not enforcement. A strategy that is buggy — or steered by a prompt injection — can call a tool without ever asking. A hard allow/deny gate belongs inside the tool: see docs/tools.md and ADR-0008.

Bundled strategies

  • strategy/simple — an ordinary tool-calling loop: generate, run the tool calls it asked for, feed the results back, repeat until the model answers. One Generate per transition. Start here unless a run has to divide its work into independent parts.
  • strategy/planexec — plan, run the tasks as parallel child processes, wait for them, replan, finalize. Use it when one run must decompose the work and outlive the wait for its parts.

Details in docs/bundled-strategies.md.

Persistence

Repository Intended use
repository/memory tests, development, one-shot runs
repository/filesystem one local process that must survive a restart

Neither runs on more than one host. A deployment with several workers supplies its own Repository: a small SPI over your store, which the application itself never calls. It needs no transaction mechanism — only an atomic Apply and conditional writes.

Verify that implementation with repository/repotest, which is the contract as a runnable test suite:

func TestMyRepo(t *testing.T) {
	repotest.Run(t, func(t *testing.T) agentkit.Repository { return mystore.New() })
}

What it checks — atomic apply, Rev compare-and-set, read-only guards, uniqueness, claiming and lease tokens, event order, deep copies, field round-trips — is spelled out in docs/persistence.md.

Middleware

One registration on the Kernel wraps every agent at six points (Claim, Init, Step, Generate, CallTool, SpawnChild). That makes it the place for a concern that spans every agent: tracing and metrics, audit logging, redaction, retry, tool policy. A middleware can also refuse a call by returning without calling next.

Two things to know before relying on it. A middleware runs again whenever a transition is replayed, and agentkit persists nothing it records — an audit that must be durable before the action belongs inside the tool's Run. And refusing a call is not an authorization gate: it is a chokepoint for calls made through Syscalls.CallTool, while a strategy holding a gollem.Tool value can call Run on it directly.

Middleware points, typed access to a request's payload, and tracing recipes: docs/observability.md. Why this replaced the observation-only hooks: ADR-0012.

Where to go next

Requirements

  • Go 1.26+
  • github.com/gollem-dev/gollem

License

See LICENSE.

Documentation

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrInvalidConfig is returned by New when a required dependency is nil.
	ErrInvalidConfig = goerr.New("invalid config")
	// ErrInvalidRequest is returned for a malformed request (wrong await kind,
	// nil payload, unknown pending child, spawn input type mismatch, ...).
	ErrInvalidRequest = goerr.New("invalid request")
	// ErrInvalidAgentDef is returned by Register for a bad agent definition
	// (empty name, version < 1, duplicate name, nil strategy).
	ErrInvalidAgentDef = goerr.New("invalid agent definition")
	// ErrUnknownAgent is returned by Spawn/SpawnChild for an unregistered agent.
	ErrUnknownAgent = goerr.New("unknown agent")
	// ErrSubjectBusy is returned by Spawn when an open Process holds the subject.
	ErrSubjectBusy = goerr.New("subject busy")
	// ErrProcessNotFound is returned when a Process does not exist.
	ErrProcessNotFound = goerr.New("process not found")
	// ErrProcessFinished is returned when acting on a terminal Process.
	ErrProcessFinished = goerr.New("process finished")
	// ErrAwaitNotFound is returned when an await row does not exist.
	ErrAwaitNotFound = goerr.New("await not found")
	// ErrAwaitClosed is returned when responding to an await that is not open
	// (first-writer-wins; the second Respond always gets this).
	ErrAwaitClosed = goerr.New("await closed")
	// ErrEventNotFound is returned when a ListEvents cursor names an event the
	// Process does not have. It is the signal that a stored cursor went stale,
	// which is why it is not silently treated as "start from the beginning".
	ErrEventNotFound = goerr.New("event not found")
	// ErrToolNotFound is returned by CallTool for an unknown tool name.
	ErrToolNotFound = goerr.New("tool not found")
	// ErrLimitExceeded is returned to a strategy when Limit stops execution
	// before an effect runs.
	ErrLimitExceeded = goerr.New("limit exceeded")
	// ErrConflict is returned by Repository.Apply when a precondition (Rev CAS,
	// Guard, or uniqueness) is not met; nothing is written.
	ErrConflict = goerr.New("conflict")
	// ErrSuspendWithoutAwait is a transition error: a Suspend produced no open
	// await and no WaitChildren elision (prevents a permanent hang).
	ErrSuspendWithoutAwait = goerr.New("suspend without await")
	// ErrRepositoryIndeterminate is returned by the filesystem reference
	// implementation after a post-rename I/O failure leaves persisted state and
	// in-memory state diverged; the Repository is fail-stopped until reopened.
	ErrRepositoryIndeterminate = goerr.New("repository indeterminate")
	// ErrServeActive is returned by Serve when another Serve is already running on
	// the same Kernel. One Kernel drives at most one active Serve, because the
	// eager dispatcher and its concurrency semaphore are installed per-Serve.
	ErrServeActive = goerr.New("serve already active")
	// ErrHistoryNotConfigured is returned by every Session method when the agent
	// was not registered with WithHistoryStore. The managed conversation needs a
	// store; rather than silently run without persistence, the call fails so the
	// misconfiguration surfaces (ADR-0017).
	ErrHistoryNotConfigured = goerr.New("history store not configured")
	// ErrHistoryVersionMissing is what a HistoryStore returns when Load is given a
	// ref it cannot resolve. A referenced version that is gone is data loss, not
	// an empty conversation, so it fails the transition instead of silently
	// restarting the conversation from nothing.
	ErrHistoryVersionMissing = goerr.New("history version missing")
)

Sentinel errors. Callers discriminate with errors.Is. Handlers wrap these with goerr.Wrap to add context.

Functions

func InitInput

func InitInput[I any](req *InitRequest) (I, bool)

InitInput reads the launch input as I. A middleware runs for every agent, so ok == false simply means "not an agent this middleware knows about" — pass the request to next unchanged rather than treating it as an error.

func InitState

func InitState[S any](res *InitResult) (S, bool)

InitState reads the produced initial state as S.

func ResultState

func ResultState[S any](res *StepResult) (S, bool)

ResultState reads the post-transition state as S.

func SpawnInput

func SpawnInput[I any](req *SpawnRequest) (I, bool)

SpawnInput reads the child's launch input as I.

func StepState

func StepState[S any](req *StepRequest) (S, bool)

StepState reads the transition's input state as S. As with InitInput, ok == false means the request belongs to another agent.

Types

type Agent

type Agent[I any] struct {
	// contains filtered or unexported fields
}

Agent is an opaque handle carrying the launch input type I (the return of Register). It is the only entry point for spawning a Process; the any-typed type erasure is confined to unexported methods (D43).

func Register

func Register[S, I, O any](r *Registry, name AgentName, version int, s Strategy[S, I, O], opts ...RegisterOption[O]) (Agent[I], error)

Register registers a typed strategy and returns a typed handle carrying the input type I (D26/D43: required args are positional; the old AgentDef struct is gone). Empty name, version < 1, nil strategy, a nil completion handler, or a duplicate name yields ErrInvalidAgentDef. Contract: complete all Register calls before Spawn/Serve.

The handle stays Agent[I]: O is consumed only by the completion handler, so carrying it on the handle would make it a phantom type parameter.

func (Agent[I]) Name

func (a Agent[I]) Name() AgentName

Name returns the agent name stored in Process.Agent.

func (Agent[I]) Spawn

func (a Agent[I]) Spawn(ctx context.Context, k *Kernel, input I, opts ...SpawnOption) (ProcessID, error)

Spawn launches a Process from the application (typed; input is checked at compile time). It creates the Process pending and returns its ID immediately (asynchronous launch; execution starts at a worker's claim).

func (Agent[I]) SpawnChild

func (a Agent[I]) SpawnChild(ctx context.Context, sys Syscalls, input I, opts ...SpawnOption) (ProcessID, error)

SpawnChild launches a child Process from a strategy (journaled through sys, typed). The child insert is buffered into the transition commit (D48).

type AgentName

type AgentName string

AgentName is the registry key of an agent definition (name + version + strategy). Process.Agent stores this wire value; a typo is detected at Spawn time as ErrUnknownAgent.

type AttemptInfo

type AttemptInfo struct {
	// Errors is the number of previous attempts that returned an error.
	// Effects up to the error point may have fired.
	Errors int
	// UncleanReclaims is the number of previous claims that died
	// mid-transition. Nothing is known about how far they got: the transition
	// may have completed every effect and died before commit, and a
	// lease-expiry reclaim may overlap a still-running predecessor.
	UncleanReclaims int
}

AttemptInfo reports prior attempts at the current transition that did not commit. A zero value means this is the first attempt.

func (AttemptInfo) IsReplay

func (a AttemptInfo) IsReplay() bool

IsReplay reports whether a previous attempt at this transition may have executed effects.

type Await

type Await struct {
	ProcessID ProcessID
	Key       AwaitKey
	Kind      AwaitKind
	Status    AwaitStatus
	Deadline  *time.Time

	// Kind-specific fields (only the matching kind is non-zero). The kernel puts
	// them on the row typed; row->bytes is the Repository implementation's job.
	Question []byte        // question: the Question() payload verbatim (encoding is the caller's).
	Response []byte        // response to a question (whatever Respond received; e.g. "yes"/"no").
	Children []ProcessID   // children: the children being waited on.
	Results  []ChildResult // children: the response (assembled by the kernel when all children finish).
	Fired    bool          // timer: fired.

	RespondedBy string
	CreatedAt   time.Time
	RespondedAt *time.Time
}

Await is the persisted representation of a wait. Response acceptance upholds three invariants: only open is accepted, first-writer-wins, and the responder is recorded (optionally, via WithRespondedBy).

type AwaitKey

type AwaitKey string

AwaitKey is a Process-local unique key for a "wait". Strategies name it.

type AwaitKind

type AwaitKind string

AwaitKind is the kind of a "wait". Human confirmation (formerly approval) is NOT a distinct kind — a go/no-go confirmation is just a question.

const (
	AwaitQuestion AwaitKind = "question" // question to a human (confirmation is a yes/no question).
	AwaitTimer    AwaitKind = "timer"
	AwaitChildren AwaitKind = "children"
)

type AwaitOption

type AwaitOption func(*awaitConfig)

AwaitOption configures an AwaitSpec.

func WithDeadline

func WithDeadline(t time.Time) AwaitOption

WithDeadline sets a Question deadline. Reaching it makes the await expired.

type AwaitSpec

type AwaitSpec struct {
	// contains filtered or unexported fields
}

AwaitSpec is a declared wait. It can only be built via the constructors below (three kinds only; confirmation is expressed as a question).

func Question

func Question(key AwaitKey, payload []byte, opts ...AwaitOption) AwaitSpec

Question declares a wait for a human answer. Encoding of the payload is the caller's (a confirmation sends e.g. []byte("...question...") and the answer is []byte("yes"|"no")).

func Timer

func Timer(key AwaitKey, until time.Time) AwaitSpec

Timer declares a wait until a time.

func WaitChildren

func WaitChildren(key AwaitKey, children ...ProcessID) AwaitSpec

WaitChildren declares a wait for all the given child Processes to finish.

type AwaitStatus

type AwaitStatus string

AwaitStatus is the state of a wait.

const (
	AwaitOpen      AwaitStatus = "open"
	AwaitResponded AwaitStatus = "responded"
	AwaitExpired   AwaitStatus = "expired"   // deadline reached (question).
	AwaitCancelled AwaitStatus = "cancelled" // closed by the Process finishing/cancelling.
)

type ChangeSet

type ChangeSet struct {
	Guards    []ProcessGuard // write-free Process preconditions (read-set; for WaitChildren).
	Processes []*Process     // Rev-CAS upserts (write-set). May be several rows (child creation + parent wake).
	Awaits    []*Await       // upserts keyed by (ProcessID, Key).
	Events    []*Event       // appends (per-Process append order preserved).
}

ChangeSet is the unit of atomic persistence.

type ChildResult

type ChildResult struct {
	ProcessID ProcessID
	Status    ProcessStatus
	Output    []byte // the child's Output verbatim (format/type is known to the spawner).
	Failure   *Failure
	// Metrics is the child's cumulative usage at the moment it finished, which
	// already includes any children it folded in. The kernel adds these into the
	// parent when the await resolves, so a Limit high in the tree sees what the
	// tree below it spent rather than only the row it was called for.
	Metrics Metrics
}

ChildResult is one child's outcome, delivered to the parent's children Await.

type ClaimHandler

type ClaimHandler func(ctx context.Context, req *ClaimRequest) (ClaimOutcome, error)

ClaimHandler drives one claim to its end.

type ClaimMiddleware

type ClaimMiddleware func(next ClaimHandler) ClaimHandler

ClaimMiddleware wraps a ClaimHandler.

The ctx it passes to next reaches the ToolFactory and every transition in the claim, which is what makes it the place to install a per-claim trace handler or logger. Three properties to design around:

It must call next AT MOST ONCE, for the same reason a StepMiddleware must: the first call settles the row, so a second would find it no longer running and achieve nothing. The second call returns ErrInvalidRequest.

It must call next SYNCHRONOUSLY and must not return before next has. This is not a convention: the claim holds a lease for as long as next runs, and how the row is settled is decided after the chain returns. A middleware that hands next to a goroutine and returns early — a timeout wrapper is the tempting shape — leaves the kernel asked to settle a row that is still being driven. The kernel does not do it: it logs, abandons the frame and reports ClaimAbandoned, letting the claim finish on its own. Bound a claim's work with WithMaxStepsPerClaim and the ctx, not by outrunning next.

Returning without calling next REFUSES the claim. The Process is put back to pending with a retry backoff and is claimed again once that elapses — it is a way to hold work off, not to drop it, and a middleware that refuses forever re-refuses on every backoff. The step attempt counter is not charged, since no Step ran.

The ClaimOutcome a middleware returns is what an outer middleware sees, but NOT what the scheduler acts on: the kernel reports what the claim actually did to the row. A middleware cannot make eager dispatch re-submit a Process by claiming the run was released.

A panic — here, or in anything the claim calls that is not already covered by the transition's own recovery — is turned into a claim failure and the Process is put back. It does not reach serveLoop, which has no recovery and would take the process down with the poll goroutine.

type ClaimOutcome

type ClaimOutcome string

ClaimOutcome reports how one claim ended. It is not persisted anywhere; it exists so a ClaimMiddleware learns what became of the Process it wrapped.

const (
	// ClaimRefused is the zero value: a middleware returned without calling next,
	// so the claim never ran. The kernel never reports it — a refused claim is put
	// back with a retry backoff and reported as ClaimRequeued, or as
	// ClaimAbandoned if the store would not take the write. It is what an OUTER
	// middleware sees returned from next.
	ClaimRefused ClaimOutcome = ""
	// ClaimFinished: the Process reached a terminal status during this claim.
	ClaimFinished ClaimOutcome = "finished"
	// ClaimSuspended: the Process committed as waiting.
	ClaimSuspended ClaimOutcome = "suspended"
	// ClaimRequeued: put back to pending with a backoff after a fault. The write
	// is confirmed — a requeue the store refused is ClaimAbandoned instead.
	ClaimRequeued ClaimOutcome = "requeued"
	// ClaimReleased: the step budget was spent, so the Process went back to
	// pending and is runnable now. This is the one outcome eager dispatch
	// re-submits on, and like ClaimRequeued it is confirmed rather than attempted.
	ClaimReleased ClaimOutcome = "released"
	// ClaimAbandoned: this worker walked away without moving the row, because the
	// lease was gone or a Repository call failed. A later claim recovers it —
	// through the lease expiring, which the next claim counts as an unclean
	// reclaim (ADR-0015), so a run of these is worth alerting on.
	ClaimAbandoned ClaimOutcome = "abandoned"
)

type ClaimRequest

type ClaimRequest struct {
	// Process is a copy taken at claim time, so writing to it changes nothing that
	// gets committed — the commit is built from the original. LeaseOwner names the
	// worker and LeaseToken is this claim's fence identity, unique per claim.
	Process *Process
}

ClaimRequest is one claim: a worker holding one lease on one Process, about to run a bounded run of transitions on it.

A claim is the outermost scope the kernel owns, and the only one that brackets the whole of a worker's work on a Process — ToolFactory construction, every transition in the run, and the write that settles the row. It is therefore where a per-claim resource is opened and closed, and where a ctx that must reach all of that is installed.

type Decision

type Decision[O any] struct {
	// contains filtered or unexported fields
}

Decision is the result of one transition, carrying the strategy's output type O. Its fields are unexported: it can only be built by Continue, Suspend, Fail or Done, so a Done without an output cannot be constructed.

Go infers a type argument from a call's arguments only, never from the return or assignment context. Done therefore infers O from its argument, while Continue/Suspend/Fail need it written out: agentkit.Continue[MyOut]().

func Continue

func Continue[O any]() Decision[O]

Continue advances to the next transition.

func Done

func Done[O any](output O) Decision[O]

Done finalizes the Process as succeeded with the given output. The value is turned into the persisted bytes by the strategy's EncodeOutput, and the same value is handed to a completion handler without a round trip (ADR-0014).

func Fail

func Fail[O any](code FailureCode, message string) Decision[O]

Fail finalizes the Process as failed.

func ResultDecision

func ResultDecision[O any](res *StepResult) (Decision[O], bool)

ResultDecision reads the Decision as Decision[O]. ok == false means the result belongs to an agent whose output type is not O — for every kind, not only for a Done.

Unlike StepState and friends, O must be the agent's exact output type: Decision carries its own type witness, so ResultDecision[any] does NOT match an agent whose O is something else. To branch on what a transition decided without naming O, use DecisionKindOf.

func Suspend

func Suspend[O any](specs ...AwaitSpec) Decision[O]

Suspend is a checkpoint that declares waits. specs are upserted on the transition commit. If an open wait already exists (e.g. declared in a prior transition), specs-less Suspend() is legal. If no open wait exists at commit time and no WaitChildren elision applies, the transition errors with ErrSuspendWithoutAwait. Re-declaring a non-open key is a no-op (idempotent re-execution after a restart).

type DecisionKind

type DecisionKind string

DecisionKind is the outcome of one transition.

const (
	DecisionContinue DecisionKind = "continue"
	DecisionSuspend  DecisionKind = "suspend"
	DecisionDone     DecisionKind = "done"
	DecisionFail     DecisionKind = "fail"
)

func DecisionKindOf

func DecisionKindOf(res *StepResult) DecisionKind

DecisionKindOf reports what a result decided without naming O, for a middleware that only needs to branch on continue/suspend/done/fail.

type EffectContext

type EffectContext struct {
	ProcessID ProcessID
	RootID    ProcessID // correlation id for the whole tree (reaches down to children).
	Agent     AgentName
	StateSeq  int // transition number.
	// Attempt reports prior attempts at this transition that did not commit, so
	// a middleware can tell a replayed effect from a first one.
	Attempt AttemptInfo

	// Metadata is a COPY of Process.Metadata — the same kernel-opaque map
	// WithMetadata set at spawn and a ToolFactory reads. It is here so a
	// cross-cutting concern (per-tenant rate limiting, an audit field) can be
	// written as middleware, which holds no Repository and could otherwise only
	// get at it by reading the Process back per effect. Nil when the Process has
	// none.
	//
	// It is the one field that is copied rather than shared: writing to it
	// affects neither the Process nor any other effect. On InitRequest.Parent it
	// is the PARENT's metadata — the child's is SpawnRequest.Metadata. Those two
	// hold equal content whenever the spawner named no metadata of its own, since
	// the child inherits (ADR-0011); they remain separate copies, so a
	// SpawnMiddleware editing SpawnRequest.Metadata to strip a key changes what
	// the child gets without touching what this field reports about the parent.
	//
	// Metadata is caller-supplied DATA, not a credential. A middleware branching
	// on Metadata["tenant"] is trusting whoever called Spawn; that value must
	// have been derived server-side from an already-validated principal before
	// the spawn (ADR-0011).
	Metadata map[string]string

	// Limit is the Strategy's most recent Limit verdict, starting with the one
	// taken at the transition boundary. Read it through Kind() and Message().
	//
	// A middleware wraps its own syscall's Limit check, so this carries the
	// verdict from BEFORE this call: the transition boundary's for a
	// transition's first effect, the previous effect's after that. That is the
	// useful reading — "the budget looked like this going into this call".
	//
	// It does not change while the handler runs. This is a copy taken when the
	// syscall started, so a middleware cannot see the post-effect re-evaluation
	// by reading it again after calling next; a strategy reads that through
	// Syscalls.LimitStatus().
	//
	// agentkit never acts on it. A middleware that wants a budget warning to
	// reach the model appends Message() to GenerateRequest.SystemPrompt itself.
	Limit LimitDecision
}

EffectContext identifies "which Process's which transition produced this call". It can key an audit row.

type Event

type Event struct {
	ID        EventID // minted by the kernel; the Repository stores it verbatim.
	ProcessID ProcessID
	Type      EventType
	Key       AwaitKey // target key for await.created (typed; the kernel builds no payload).
	Payload   []byte   // the sys.Emit payload verbatim (nil for kernel-emitted events).
	At        time.Time
}

Event is an append-only record of an observable Process occurrence. Channel delivery (Slack, etc.) is done by the caller subscribing to these; this package only provides per-Process reads.

type EventID

type EventID string

EventID identifies an Event. It is a uuid v7 (time-ordered) generated by the Kernel via uuid.NewV7(), like ProcessID. A Repository stores it verbatim and never mints one of its own — it is the handle a caller holds to resume a read (Kernel.ListEvents with WithAfterEvent).

type EventQuery

type EventQuery struct {
	// After is a cursor. "" starts from the first event; otherwise the result is
	// the events appended strictly after the one with this ID. An ID this Process
	// has no event for is ErrEventNotFound and returns no events — returning the
	// whole list instead would reach the caller as a burst of new events, which
	// it has no way to tell from the real thing.
	After EventID
	// Limit caps how many events are returned. <= 0 means no cap.
	Limit int
}

EventQuery narrows a ListEvents read. Every field is optional, so the zero value means "all of this Process's events" — it is a struct rather than options so an implementation reads fields instead of resolving closures (ADR-0019).

type EventType

type EventType string

EventType is the type of an observable Process event. Strategies may emit arbitrary types via sys.Emit (names that avoid the reserved three are recommended, not enforced).

const (
	EventProcessCreated  EventType = "process.created"
	EventProcessFinished EventType = "process.finished" // succeeded / failed / cancelled.
	EventAwaitCreated    EventType = "await.created"    // question only (timer/children are internal).
)

type Failure

type Failure struct {
	Code    FailureCode `json:"code"`
	Message string      `json:"message"`
}

Failure describes a failed Process.

type FailureCode

type FailureCode string

FailureCode categorizes why a Process failed.

const (
	// FailureStrategyError: Decision=Fail or an unrecoverable Step error.
	FailureStrategyError FailureCode = "strategy_error"
	// FailureLimitExceeded: stopped by the Strategy's Limit.
	FailureLimitExceeded FailureCode = "limit_exceeded"
	// FailureRetryExhausted: step retry limit exceeded.
	FailureRetryExhausted FailureCode = "retry_exhausted"
	// FailureUncleanReclaim: too many claims died mid-transition. This is a
	// worker-health signal rather than a strategy bug, which is why it is
	// distinct from FailureRetryExhausted — the cause and the remedy differ.
	FailureUncleanReclaim FailureCode = "unclean_reclaim"
)

type FinishHandler

type FinishHandler[O any] func(ctx context.Context, pid ProcessID, res FinishResult[O]) error

FinishHandler runs after a Process reaches a terminal state and that state has been committed. Delivery is best-effort: it never fires twice, but a crash between the commit and the call loses it entirely (ADR-0014).

type FinishResult

type FinishResult[O any] struct {
	// Status is the committed terminal status: ProcessSucceeded, ProcessFailed
	// or ProcessCancelled. Never a non-terminal status.
	Status ProcessStatus
	// Output is the value passed to Done. Non-nil if and only if
	// Status == ProcessSucceeded.
	Output *O
	// Failure is the recorded failure. Non-nil if and only if
	// Status == ProcessFailed.
	Failure *Failure
}

FinishResult is the terminal outcome handed to a completion handler. Exactly one of Output / Failure is non-nil, or neither when cancelled.

type GenerateHandler

type GenerateHandler func(ctx context.Context, req *GenerateRequest) (*GenerateResult, error)

GenerateHandler performs one LLM call.

type GenerateMiddleware

type GenerateMiddleware func(next GenerateHandler) GenerateHandler

GenerateMiddleware wraps a GenerateHandler.

type GenerateOption

type GenerateOption func(*GenerateRequest)

GenerateOption configures a Generate. Only input is required (D26). The options fill in a GenerateRequest, which is also what Generate middleware sees and may rewrite.

func WithHistory

func WithHistory(h *gollem.History) GenerateOption

WithHistory passes prior conversation history (to gollem.WithSessionHistory).

func WithLLMOptions

func WithLLMOptions(o ...gollem.GenerateOption) GenerateOption

WithLLMOptions passes gollem generate options (temperature, etc.) straight through. Note agentkit.GenerateOption and gollem.GenerateOption are distinct types (package-qualified); pass-through is via this option.

func WithLLMSessionOptions

func WithLLMSessionOptions(o ...gollem.SessionOption) GenerateOption

WithLLMSessionOptions passes gollem session options straight through to LLMClient.NewSession. It reaches the session-scoped settings agentkit does not model as typed fields — gollem.WithSessionPromptCache and the content-block/stream middlewares — without agentkit growing a field per setting.

These are appended AFTER the options derived from Role/History/SystemPrompt/ Tools/Schema, and gollem applies session options in order, so the effect depends on which kind the option is: a scalar one (history, system prompt, content type, response schema, prompt cache) OVERRIDES what the typed field set, while a slice one (tools, content-block/stream middleware) ADDS to it. The order is fixed this way so a GenerateMiddleware can impose a setting on every agent; reversing it would let any strategy silently opt out.

The override reaches SessionGenerate's managed conversation too: passing gollem.WithSessionHistory here replaces the History the runtime is carrying. That is the same "extra options win" rule SessionGenerate already documents, not a separate hazard, but it is the one worth naming.

func WithRole

func WithRole(r ModelRole) GenerateOption

WithRole selects the model role. Omitting it (or nil) means the default model.

func WithSchema

func WithSchema(p *gollem.Parameter) GenerateOption

WithSchema requests JSON output against a schema (gollem WithSessionContentType(JSON) + WithSessionResponseSchema).

func WithSystemPrompt

func WithSystemPrompt(p string) GenerateOption

WithSystemPrompt sets the system prompt (to gollem.WithSessionSystemPrompt).

func WithTools

func WithTools(tools ...gollem.Tool) GenerateOption

WithTools declares tools to the LLM (execution goes through CallTool).

type GenerateRequest

type GenerateRequest struct {
	Effect       EffectContext
	Input        []gollem.Input
	Role         ModelRole
	History      *gollem.History
	SystemPrompt string
	Tools        []gollem.Tool
	Schema       *gollem.Parameter
	LLMOptions   []gollem.GenerateOption

	// LLMSessionOptions is passed verbatim to LLMClient.NewSession, after the
	// options derived from the typed fields above. It is the escape hatch for
	// session-scoped settings agentkit does not model itself — prompt cache,
	// content-block middleware — so a new gollem SessionOption needs no change
	// here to be reachable.
	//
	// A GenerateMiddleware appending to it applies one session setting across
	// every agent from a single Kernel registration. See WithLLMSessionOptions
	// for what "after" means when an option collides with a typed field.
	LLMSessionOptions []gollem.SessionOption
}

GenerateRequest carries everything one Generate needs. Every field is a concrete type, so a middleware assigns to them directly. Effect is filled in by the kernel and is not read back from here.

type GenerateResult

type GenerateResult struct {
	Texts                    []string               `json:"texts"`
	Thoughts                 []string               `json:"thoughts,omitempty"`
	FunctionCalls            []*gollem.FunctionCall `json:"function_calls,omitempty"`
	InputTokens              int                    `json:"input_tokens"`
	OutputTokens             int                    `json:"output_tokens"`
	CacheReadInputTokens     int                    `json:"cache_read_input_tokens,omitempty"`
	CacheCreationInputTokens int                    `json:"cache_creation_input_tokens,omitempty"`
	// Model is what the client this generate's role resolved to reports itself
	// as, empty when that client reports none — it does not implement
	// gollem.ModelNamer, or it reports an empty name. It lets a middleware
	// record what a generation ran against without mirroring the caller's own
	// role-to-client configuration, which is invisible when it drifts.
	//
	// It is the name the client was CONFIGURED with, not a model id an API
	// response may report: a caller pricing a call keys its table by the name it
	// configured, and an alias resolving to a dated snapshot would turn a
	// startup-time check into a mid-run lookup failure.
	Model   string          `json:"model,omitempty"`
	History *gollem.History `json:"history"` // session history after the call (save it, pass it next time).
}

GenerateResult is the journalable/checkpointable result of a Generate. It is used instead of *gollem.Response because Response.Error is not JSON round-trippable; History is included so the strategy can fold it into its checkpointed state and pass it to the next Generate.

type HistoryRef

type HistoryRef string

HistoryRef names one stored version of a Process's conversation History. It is opaque to the kernel: the store mints it in Save, and the kernel only records it on the Process record and hands it back to Load. The zero value means "no version has been committed yet", so a store must never return it.

type HistoryStore

type HistoryStore interface {
	// Save stores h as a NEW version and returns the ref naming it. It must not
	// modify or replace any previously returned version. Returning the same ref
	// for byte-identical content is allowed (a content-addressed implementation);
	// returning a ref that later names DIFFERENT content is a contract violation,
	// and it is the one that breaks the rollback guarantee. The returned ref must
	// not be empty.
	//
	// A ref that sorts by creation time is preferred, so an operator can read the
	// order of versions off the store, but it is not required.
	Save(ctx context.Context, pid ProcessID, h *gollem.History) (HistoryRef, error)

	// Load returns the version named by ref. The kernel never calls it with an
	// empty ref. An unknown ref must be an error (ErrHistoryVersionMissing), not a
	// nil result: a referenced version that is gone is data loss, not "nothing
	// saved yet".
	Load(ctx context.Context, pid ProcessID, ref HistoryRef) (*gollem.History, error)

	// Discard reports that ref is no longer referenced. It is a NOTIFICATION, not
	// a deletion order: reclaiming immediately, deferring to a sweep, or ignoring
	// it are all conforming, and an unknown or already-discarded ref is not a
	// failure. It returns nothing on purpose — the kernel would only log and carry
	// on, so reporting a failed reclaim is the implementation's own business.
	//
	// Versions leak without it too: a crash between Save and the commit leaves one
	// nobody will ever discard. A store is expected to have its own reclamation
	// policy for those.
	Discard(ctx context.Context, pid ProcessID, ref HistoryRef)
}

HistoryStore persists a Process's conversation History as immutable versions.

Which version is current is decided by Process.HistoryRef, which commits in the same Apply as State, so a save whose transition never committed is simply never referenced and the next attempt re-seeds from the committed one. That is what makes History roll back together with State, and it is why a stored version must never be rewritten in place (ADR-0017).

It is a port separate from Repository on purpose: History grows unbounded and does not belong in the transactional store, which carries only the ref. The implementation serializes *gollem.History; the kernel marshals nothing (ADR-0007).

type InheritedHistory

type InheritedHistory struct {
	Process ProcessID  `json:"process"` // whose store key holds Ref.
	Ref     HistoryRef `json:"ref"`
}

InheritedHistory names a History version another Process committed, which a new Process starts its conversation from (WithInheritedHistory). Both fields are required: a HistoryStore addresses a version by (pid, ref), so the ref alone does not resolve.

It is resolved once, at Spawn, from the issuing Process's record, and never rewritten. The kernel only ever READS the version it names — the issuing Process's record may still name it too, so it is never Discarded (ADR-0017).

type InitHandler

type InitHandler func(ctx context.Context, req *InitRequest) (*InitResult, error)

InitHandler builds the initial state for a Process.

type InitMiddleware

type InitMiddleware func(next InitHandler) InitHandler

InitMiddleware wraps an InitHandler.

type InitRequest

type InitRequest struct {
	ProcessID ProcessID
	Agent     AgentName
	Parent    *EffectContext
	// contains filtered or unexported fields
}

InitRequest is one call to a strategy's Init. ProcessID is already minted when Init runs, so an audit record can be correlated with the Process that the call is about to create.

An Init middleware fires for both entry points: Agent[I].Spawn (Parent is nil) and Agent[I].SpawnChild (Parent is the spawning transition). It also fires on an idempotent Spawn that ends up returning an existing Process — the initial state is built before the idempotency key is looked up — and in that case the ProcessID here is discarded.

func NewInitRequest

func NewInitRequest[I any](req *InitRequest, input I) *InitRequest

NewInitRequest returns a shallow copy of req with the input replaced. req is left untouched, so an outer middleware never sees its own request change.

I is not constrained to the agent's input type — a kernel middleware spans every agent and cannot know it. Passing the wrong type compiles and surfaces as ErrInvalidRequest when the binding runs. Reading with InitInput first and writing the value back is the form that keeps the types aligned.

type InitResult

type InitResult struct {
	// contains filtered or unexported fields
}

InitResult is the initial strategy state an Init call produced.

func NewInitResult

func NewInitResult[S any](state S) *InitResult

NewInitResult builds a result without calling next, i.e. it replaces the strategy's own Init. As with NewInitRequest, S is unchecked here and a mismatch surfaces as ErrInvalidRequest when the state is encoded.

type Kernel

type Kernel struct {
	// contains filtered or unexported fields
}

Kernel is the Process lifecycle API and worker loop (the proposal's Engine + Worker merged into one type, D5). All held state is immutable injected references — no cross-request state in process memory.

func New

func New(repo Repository, model gollem.LLMClient, agents *Registry, opts ...KernelOption) (*Kernel, error)

New constructs a Kernel. model is the default model (what a nil ModelRole resolves to); an agent runtime needs at least one model, so it is positional (D26/D27). Static validation (ErrInvalidConfig): repo / model / agents non-nil.

func (*Kernel) Cancel

func (k *Kernel) Cancel(ctx context.Context, pid ProcessID, reason string) error

Cancel requests cancellation. pending/waiting -> immediately cancelled (open awaits cancelled, process.finished event). running -> only sets cancel_requested (the worker finalizes at the next transition boundary). terminal -> ErrProcessFinished.

func (*Kernel) GetProcess

func (k *Kernel) GetProcess(ctx context.Context, pid ProcessID) (*Process, error)

GetProcess returns the Process (read-through to the Repository).

func (*Kernel) ListAwaits

func (k *Kernel) ListAwaits(ctx context.Context, pid ProcessID) ([]*Await, error)

ListAwaits returns the Process's awaits.

func (*Kernel) ListEvents

func (k *Kernel) ListEvents(ctx context.Context, pid ProcessID, opts ...ListEventsOption) ([]*Event, error)

ListEvents returns the Process's events in append order. With no options it returns all of them; see WithAfterEvent and WithEventLimit to read incrementally.

func (*Kernel) Respond

func (k *Kernel) Respond(ctx context.Context, pid ProcessID, key AwaitKey, response []byte, opts ...RespondOption) error

Respond delivers a response to a question await (a confirmation's yes/no is sent this way too). Responding to a non-question await is ErrInvalidRequest. Only open is accepted (ErrAwaitClosed); first-writer-wins. The kernel only nil-checks the payload.

func (*Kernel) Serve

func (k *Kernel) Serve(ctx context.Context, opts ...ServeOption) error

Serve runs claim loops until ctx is done (blocking). WithPollConcurrency loops share a workerID; the per-claim LeaseToken is the fence identity (D50). It also installs the eager dispatcher for this Kernel: a Process becoming runnable here (Spawn/Respond/child/parent) is driven immediately rather than at the next poll (ADR-0016). Only one Serve may be active per Kernel; a second returns ErrServeActive, because the dispatcher and its concurrency semaphore are per-Serve state that a second Serve would silently clobber.

type KernelOption

type KernelOption func(*kernelConfig)

KernelOption configures a Kernel.

func WithClaimMiddleware

func WithClaimMiddleware(mw ...ClaimMiddleware) KernelOption

WithClaimMiddleware adds Claim middleware. Repeatable; the first registered is the outermost. A nil element makes New return ErrInvalidConfig.

func WithClock

func WithClock(fn func() time.Time) KernelOption

WithClock sets the clock that Now() returns (a test seam). Default: time.Now. Determinism is not provided (D44).

func WithGenerateMiddleware

func WithGenerateMiddleware(mw ...GenerateMiddleware) KernelOption

WithGenerateMiddleware adds Generate middleware (same ordering and nil rules).

func WithInitMiddleware

func WithInitMiddleware(mw ...InitMiddleware) KernelOption

WithInitMiddleware adds Init middleware. Repeatable; the first registered is the outermost. A nil element makes New return ErrInvalidConfig.

func WithLogger

func WithLogger(l *slog.Logger) KernelOption

WithLogger sets the logger. Default: slog.Default().

func WithModelRole

func WithModelRole(role ModelRole, client gollem.LLMClient) KernelOption

WithModelRole assigns a client to a role (repeatable). A nil role is ErrInvalidConfig (the default is passed positionally to New).

func WithSpawnMiddleware

func WithSpawnMiddleware(mw ...SpawnMiddleware) KernelOption

WithSpawnMiddleware adds SpawnChild middleware (same ordering and nil rules).

func WithStepMiddleware

func WithStepMiddleware(mw ...StepMiddleware) KernelOption

WithStepMiddleware adds Step middleware (same ordering and nil rules).

func WithToolCallMiddleware

func WithToolCallMiddleware(mw ...ToolCallMiddleware) KernelOption

WithToolCallMiddleware adds CallTool middleware (same ordering and nil rules).

func WithToolFactory

func WithToolFactory(f ToolFactory) KernelOption

WithToolFactory sets the tool factory. Default: none (no tools).

type LimitDecision

type LimitDecision struct {
	// contains filtered or unexported fields
}

LimitDecision is a Limit verdict: one kind, plus the message that goes with it. Build it with LimitPass, LimitNotice or LimitStop.

It is both what Strategy.Limit returns and what a strategy observes through Syscalls.LimitStatus(), so a field added here reaches readers without changing any signature. The message is one field rather than a separate reason and notice because a decision is exactly one kind: "stopped, and also here is an unrelated notice" is not a state that exists.

The kernel never interprets the message. It moves it to whoever asked for it (ADR-0011).

func LimitNotice

func LimitNotice(msg string) LimitDecision

LimitNotice continues but attaches a message for the strategy, which may read it through Syscalls.LimitStatus() and act on it — put it in a prompt, drop expensive tools, wrap up early. agentkit itself does none of that: a decision nobody reads costs nothing beyond the Limit call.

An empty message is a LimitPass, so a LimitKindNotice always carries text and a reader never has to test for both.

func LimitPass

func LimitPass() LimitDecision

LimitPass continues with nothing to report.

func LimitStop

func LimitStop(reason string) LimitDecision

LimitStop refuses to continue. Before an effect the reason reaches the strategy wrapped in ErrLimitExceeded; at a transition boundary it becomes the Failure message of a failed(limit_exceeded) Process.

An empty reason is replaced, because Failure.Message must say something.

func (LimitDecision) Kind

func (d LimitDecision) Kind() LimitKind

Kind reports which verdict this is. The zero LimitDecision reads as LimitKindPass, so a reader holding one taken before any verdict was recorded needs no special case.

func (LimitDecision) Message

func (d LimitDecision) Message() string

Message is the text Limit attached: the notice for LimitKindNotice, the reason for LimitKindStop, and "" for LimitKindPass.

type LimitKind

type LimitKind string

LimitKind is which of the three verdicts a LimitDecision carries.

The constants carry the full type name as their prefix, unlike AwaitKind's AwaitQuestion or DecisionKind's DecisionContinue. Those get away with the short form because their constructors are named differently (Question, Continue); here LimitPass / LimitNotice / LimitStop are the constructors.

const (
	// LimitKindPass continues with nothing to report.
	LimitKindPass LimitKind = "pass"
	// LimitKindNotice continues and carries a message for the strategy.
	LimitKindNotice LimitKind = "notice"
	// LimitKindStop refuses to continue and carries the reason.
	LimitKindStop LimitKind = "stop"
)

type Limiter

type Limiter func(ctx context.Context, proc *Process, metrics Metrics) LimitDecision

Limiter decides whether a Process may continue. Measurement (Metrics) is the Kernel's job; the decision is the strategy's, expressed as Strategy.Limit, whose shape this type is (ADR-0010). It is also the argument type the bundled strategies take to build that method from a caller's closure.

It runs at three points: at each transition boundary, before every Generate, CallTool and SpawnChild, and again after each of those has been metered. The first two refuse the work when the verdict says stop; the third cannot — the effect has run — and only updates what Syscalls.LimitStatus() reports.

metrics is a snapshot of "committed cumulative (proc.Metrics) plus what this run has accumulated so far", so an effect cannot consume budget without the next call seeing it.

Two obligations follow from being called that often, roughly 1 + 2×effects times per attempt:

  • It must be READ-ONLY with respect to whatever it consults. A call is an enquiry, not an acquisition: the same effect is asked about more than once, and once with the work already done. A Limiter that draws a token from a rate limiter, or charges a quota, charges several times per effect and refuses work nobody performed. Consult the current state instead and leave the accounting to whatever owns it.
  • It must be cheap and NON-BLOCKING. It is on the transition hot path and the claim holds its lease throughout, so waiting here turns a throttle into a lease expiry and an unclean reclaim (ADR-0010, ADR-0015). Work that has to wait belongs behind a timer await.

type ListEventsOption

type ListEventsOption func(*EventQuery)

ListEventsOption configures a ListEvents read. It resolves into the EventQuery the Repository receives: options at the entry point (ADR-0005), plain fields at the SPI.

func WithAfterEvent

func WithAfterEvent(id EventID) ListEventsOption

WithAfterEvent returns only the events appended after id, so a caller that stored the last id it saw resumes there instead of re-reading the whole list. An id this Process has no event for is ErrEventNotFound rather than a silent restart from the beginning.

func WithEventLimit

func WithEventLimit(n int) ListEventsOption

WithEventLimit caps how many events are returned. n <= 0 means no cap.

type Metrics

type Metrics struct {
	InputTokens  int64 `json:"input_tokens,omitempty"`
	OutputTokens int64 `json:"output_tokens,omitempty"`

	// CacheReadInputTokens and CacheCreationInputTokens are COMPONENTS OF
	// InputTokens, not additions to it — the same relation gollem.Response
	// defines, where InputToken is the true total of all three:
	// InputTokens = uncached input + CacheCreationInputTokens + CacheReadInputTokens.
	//
	// InputTokens - CacheReadInputTokens is the input NOT served from cache —
	// uncached input plus any cache write, not a single price tier: a cache
	// write is commonly billed at a premium over uncached input, and a cache
	// read at a discount. Pricing is the caller's to know (model, contract,
	// date); agentkit only carries the three counts, not a rate.
	//
	// Only Claude reports cache writes; the field is 0 for providers that do
	// not, which is indistinguishable from "caching was not used" and is
	// intentionally not corrected here.
	CacheReadInputTokens     int64 `json:"cache_read_input_tokens,omitempty"`
	CacheCreationInputTokens int64 `json:"cache_creation_input_tokens,omitempty"`

	LLMCalls  int64 `json:"llm_calls,omitempty"`
	ToolCalls int64 `json:"tool_calls,omitempty"`
	Steps     int64 `json:"steps,omitempty"`
	Spawns    int64 `json:"spawns,omitempty"`
}

Metrics is the fixed set of counters the Kernel maintains. The set is closed (ADR-0010): a caller cannot add one, which is why this is a struct rather than a map — a map would advertise a key space that does not exist.

Every field is cumulative and never decreases. The zero value means "nothing consumed" and is a valid Metrics.

Process.Metrics counts a Process's own effects plus every child that has terminated, once each, so a Limit high in a tree sees what the subtree spent rather than only the row it was called for.

The json tags match the keys the previous map form produced, so a snapshot written before this became a struct still reads back: known counters keep their values, and a null (a nil map) becomes the zero value. No migration is needed for that.

The wire form is not byte-identical, though. Zero metrics used to marshal as null and now marshal as {}, and a key outside this set — which the old map type could hold, even though the kernel never wrote one — is dropped on read and gone on the next write.

type ModelRole

type ModelRole interface {
	// String returns the diagnostic display name.
	String() string
	// contains filtered or unexported methods
}

ModelRole denotes the intended use of a model (e.g. "planner"). It is a sealed interface: the unexported marker method modelRole() makes it unimplementable outside this package, so only values returned by DefineModelRole can exist. Resolution is by pointer identity — two Define calls with the same name are NOT equal. A nil ModelRole means "the default model" (the required argument of New). name is for diagnostics only.

func DefineModelRole

func DefineModelRole(name string) ModelRole

DefineModelRole returns a fresh ModelRole. Callers keep the result in a package variable and share that value; every call returns a new identity.

type Process

type Process struct {
	ID       ProcessID
	Agent    AgentName
	Status   ProcessStatus
	Metadata map[string]string // optional Spawn WithMetadata. Infrastructure-facing
	// process scope (e.g. "tenant"->"acme") for ToolFactory. The kernel does not
	// interpret it, and it is NOT strategy input. It is not a credential
	// (WithMetadata callers must derive it server-side from a validated principal).
	Output       []byte   // non-nil when succeeded. Consumed by the parent's children Await and GetProcess.
	Failure      *Failure // non-nil when failed.
	State        []byte   // EncodeState output, stored verbatim (the kernel never converts it).
	StateVersion int      // strategy version that wrote State (the first arg to DecodeState).
	StateSeq     int      // number of committed transitions. 0 = first Step not yet committed.
	// HistoryRef names the committed version of this Process's conversation
	// History in its HistoryStore, or "" when none has been committed. The worker
	// saves a NEW version before the commit and records its ref here inside the
	// same Apply, so History rolls back together with State (ADR-0017).
	HistoryRef HistoryRef
	// InheritedHistory names the version another Process committed that this one
	// starts its conversation from, or nil. Set once at Spawn and never written
	// again: this Process saves its own versions under its OWN id, and HistoryRef
	// takes precedence as soon as it commits one. What is left behind is a record
	// of where the conversation came from. The kernel never Discards the version
	// named here (ADR-0017).
	InheritedHistory *InheritedHistory
	StepAttempts     int // failure count of the current transition (reset to 0 on a successful commit).
	// UncleanReclaims counts claims that took over this Process after its
	// previous claim died mid-transition. Same reset scope as StepAttempts.
	// Maintained by ClaimNextProcess (see the Repository contract), never by the
	// worker — the worker only reads it to bound re-execution. Eager dispatch
	// (claimSpecific) claims only pending rows, so it never increments this;
	// reclaiming an expired-lease running row is ClaimNextProcess's job alone.
	UncleanReclaims int
	Metrics         Metrics // committed cumulative usage.
	ParentID        *ProcessID
	RootID          ProcessID // self if no parent.
	Subject         *SubjectRef
	IdempotencyKey  string
	CancelRequested bool
	CancelReason    string
	WakeAt          *time.Time // wake time while waiting (min of open await deadlines).
	LeaseOwner      string     // diagnostic (hostname/uuid). Not used for fencing (shared across WithPollConcurrency claims).
	LeaseToken      string     // unique per claim (ClaimNextProcess and eager claimSpecific each mint a uuid v7). The fence identity: a worker
	// keeps its claim's LeaseToken and, on conflict, checks "stored LeaseToken == mine" to tell "still hold the
	// lease (rebase ok)" from "re-claimed by another worker (abandon)".
	LeaseUntil *time.Time
	Rev        int64 // optimistic concurrency token. Incremented on every Apply/claim write. Also used to detect lease expiry.
	CreatedAt  time.Time
	UpdatedAt  time.Time
}

Process is the complete persisted representation of an execution unit. It is the aggregate the Repository stores.

type ProcessGuard

type ProcessGuard struct {
	ProcessID ProcessID
	Rev       int64 // must equal the stored Rev.
}

ProcessGuard is a write-free precondition on a Process row (a read-set Rev CAS). Its main use is making WaitChildren check-then-act atomic (the parent reads child states to decide elision, then guards on the children's Revs).

type ProcessID

type ProcessID string

ProcessID identifies a Process. It is a uuid v7 (time-ordered) generated by the Kernel via uuid.NewV7().

type ProcessStatus

type ProcessStatus string

ProcessStatus is the lifecycle state of a Process. The proposal's limit_exceeded status is dropped; it is expressed as failed + FailureCode.

const (
	ProcessPending   ProcessStatus = "pending"
	ProcessRunning   ProcessStatus = "running"
	ProcessWaiting   ProcessStatus = "waiting"
	ProcessSucceeded ProcessStatus = "succeeded"
	ProcessFailed    ProcessStatus = "failed"
	ProcessCancelled ProcessStatus = "cancelled"
)

func (ProcessStatus) Terminal

func (s ProcessStatus) Terminal() bool

Terminal reports whether the status is a final state (no further transitions).

type RegisterOption

type RegisterOption[O any] func(*registerConfig[O])

RegisterOption configures Register.

func WithHistoryStore

func WithHistoryStore[O any](hs HistoryStore) RegisterOption[O]

WithHistoryStore opts this agent into runtime-managed conversation History persistence, enabling sys.Session(). When set, the worker lazily loads the version named by Process.HistoryRef on first use, and before each transition commit — including terminal commits, so a later restart/handoff can read the final transcript — saves a new version and records its ref in the same Apply (ADR-0017). Without it, the Session methods return ErrHistoryNotConfigured (the managed conversation is not silently run without persistence); a strategy that manages History itself uses the primitive Generate instead. The store is a SEPARATE port (blob storage) from the Kernel's Repository, injected here per agent rather than on the Kernel.

func WithOnFinish

func WithOnFinish[O any](h FinishHandler[O]) RegisterOption[O]

WithOnFinish wires a completion handler for this agent. The handler runs synchronously on whichever instance committed the terminal transition, after the commit succeeded; its error and panic are logged and change nothing. A nil handler yields ErrInvalidAgentDef.

type Registry

type Registry struct {
	// contains filtered or unexported fields
}

Registry maps agent name -> type-erased binding. Register completes before Spawn/Serve start; thereafter the Registry is read-only.

func NewRegistry

func NewRegistry() *Registry

NewRegistry creates an empty Registry.

type Repository

type Repository interface {
	// GetProcess returns the Process. Absent -> ErrProcessNotFound.
	GetProcess(ctx context.Context, pid ProcessID) (*Process, error)
	// FindProcessByIdempotencyKey finds a Process by its idempotency key. Absent -> ErrProcessNotFound.
	FindProcessByIdempotencyKey(ctx context.Context, key string) (*Process, error)
	// FindOpenProcessBySubject finds an open (pending/running/waiting) Process holding subject. Absent -> ErrProcessNotFound.
	FindOpenProcessBySubject(ctx context.Context, subject SubjectRef) (*Process, error)

	// ClaimNextProcess atomically claims one runnable Process. Targets:
	// status=pending with wake_at unset or <=now, or status=waiting with
	// wake_at<=now, or status=running with lease_until<now (lease expired). No
	// target -> (nil, nil) (not an error). A claim from status=running also
	// increments unclean_reclaims (contract 4).
	//
	// The two wake_at conditions differ on purpose: a pending row without one is
	// runnable now, whereas a waiting row without one is waiting for a response
	// and must never wake by itself.
	ClaimNextProcess(ctx context.Context, workerID string, leaseUntil time.Time, now time.Time) (*Process, error)

	// ListAwaits returns all awaits of a Process.
	ListAwaits(ctx context.Context, pid ProcessID) ([]*Await, error)
	// ListEvents returns a Process's events in append order, narrowed by q. A
	// zero EventQuery returns all of them.
	ListEvents(ctx context.Context, pid ProcessID, q EventQuery) ([]*Event, error)

	// Apply applies a ChangeSet atomically (see the contract above). On any
	// precondition failure it writes nothing and returns ErrConflict.
	Apply(ctx context.Context, cs ChangeSet) error
}

Repository is the kernel's persistence contract. It is an SPI implemented and injected by the caller; the application never calls it directly (reads go through Kernel.GetProcess / ListAwaits / ListEvents). It requires no transaction mechanism — only atomic application of a change set and conditional writes (Rev-based optimistic concurrency). The realization (RDB TX / Firestore TX / conditional write / mutex) is the implementation's choice.

Contract (also verified by repository/repotest):

  1. Apply applies the whole ChangeSet atomically — all or nothing.
  2. Apply checks each Processes row's stored Rev against the row's Rev (CAS); a single mismatch writes nothing and returns ErrConflict. On success each row's Rev is +1'd (a new insert of a Rev=0 row is stored as Rev=1). This fences stale-worker commits.
  3. Apply checks each Guards ProcessGuard's Rev (a read-only precondition; no write, so Rev is not advanced) — used for WaitChildren check-then-act.
  4. ClaimNextProcess never double-claims a Process across concurrent workers (atomic claim, +1 Rev). It writes status=running / lease_owner=workerID / lease_token=new-uuid-v7 / lease_until, and mints a fresh lease_token every claim (even a re-claim by the same workerID) — the fence identity. When the target was status=running (an expired or absent lease) — i.e. the previous claim died mid-transition — it also increments unclean_reclaims. A claim from pending or waiting leaves it unchanged, and ClaimNextProcess never writes step_attempts. This is what bounds re-execution after a crash; an implementation that skips it degrades to unbounded replay. A pending row whose wake_at is still in the future is NOT a target: that is the retry backoff the worker writes when it puts a failed transition back. An implementation that claims it anyway still runs correctly, but retries as fast as it polls. ClaimNextProcess and Apply are mutually linearizable on the same Process row: a claim and a Rev-CAS Apply that both read the row at Rev N cannot both succeed — exactly one advances it to N+1 and the other observes the new Rev (a claim finds nothing to claim; an Apply returns ErrConflict). This is what lets eager dispatch claim a specific pending row via Apply without a dedicated SPI method, racing a poller's ClaimNextProcess safely.
  5. Uniqueness is maintained: idempotency_key / open Process subject / (process_id, await_key). An insert violation writes nothing and returns ErrConflict.
  6. ListEvents preserves per-Process append order, and round-trips each Event's kernel-assigned ID verbatim. An implementation never mints or rewrites one: the ID is what a caller holds as a cursor, so a value that changed between write and read would resume from the wrong place.

type RespondOption

type RespondOption func(*respondConfig)

RespondOption configures a Respond.

func WithRespondedBy

func WithRespondedBy(id string) RespondOption

WithRespondedBy records the responder (Await.RespondedBy) for audit. Optional.

type RetryBackoff

type RetryBackoff func(attempts int) time.Duration

RetryBackoff decides how long a requeued Process waits before it is claimable again. attempts is the error count the requeue is about to store, so the first failure of a transition asks for attempts=1.

A fault that is not the strategy's — a ToolFactory error, a refused or failing Claim middleware — does not charge an attempt, so it asks with the count unchanged (0 unless an earlier transition already failed). A middleware that refuses every claim therefore keeps asking with the same number: return a constant there rather than expecting the curve to climb.

It runs on the requeue path while the claim still holds its lease. Keep it pure and cheap — do not block, and do not reach for a store.

type ServeOption

type ServeOption func(*serveConfig)

ServeOption configures Serve.

func WithLease

func WithLease(d time.Duration) ServeOption

WithLease sets the lease duration. Default: 60s.

func WithMaxCancelDeferrals added in v0.3.0

func WithMaxCancelDeferrals(n int) ServeOption

WithMaxCancelDeferrals bounds how many transitions a claim may run after it observes a cancel request while the managed conversation still holds a tool call nobody answered. Default: one less than WithMaxStepsPerClaim, i.e. the rest of the claim. Zero finalizes at the first boundary, which is the behaviour from before this option existed. It is clamped below WithMaxStepsPerClaim so a cancel always lands inside the claim that observed it rather than surviving a release and starting the count over.

Raising it does not make a cancel land on a usable transcript by itself: only a strategy that answers its tool calls produces a boundary worth waiting for. See docs/writing-strategies.md.

func WithMaxConcurrent

func WithMaxConcurrent(n int) ServeOption

WithMaxConcurrent sets the hard limit: the maximum number of claims this Serve drives at once, counting both poll loops and eager dispatch. Default: 64. It is the capacity of a single semaphore shared by both; eager dispatch may burst up to it, and WithPollConcurrency is clamped to it. This bounds concurrent drivers, not `running` rows (a driver that panics frees its slot while the row stays running until its lease expires; other instances are not counted).

func WithMaxStepAttempts

func WithMaxStepAttempts(n int) ServeOption

WithMaxStepAttempts sets the step retry limit. Default: 3. This bounds attempts that ended in an ERROR; a claim that died mid-transition is bounded separately by WithMaxUncleanReclaims.

func WithMaxStepsPerClaim

func WithMaxStepsPerClaim(n int) ServeOption

WithMaxStepsPerClaim sets how many transitions one claim runs. Default: 16. A value < 1 is treated as the default (0 would run no transition and release, which under eager dispatch re-submits in a tight loop).

func WithMaxUncleanReclaims

func WithMaxUncleanReclaims(n int) ServeOption

WithMaxUncleanReclaims bounds how many times a Process may be taken over after a claim died mid-transition. Default: 3. Exceeding it terminates the Process as failed with FailureUncleanReclaim.

This is deliberately separate from WithMaxStepAttempts: an error tells the strategy how far the previous attempt got, whereas a vanished claim tells it nothing — the transition may have run every effect and died before its commit, and a lease-expiry reclaim may overlap a predecessor that is still running. Callers that cannot tolerate duplicated side effects set this to 0.

The bound is compared as `UncleanReclaims > n`, matching the `StepAttempts+1 > n` convention: n permits n further attempts after the first. n=0 therefore finalizes the Process on the first unclean reclaim, without running Step at all.

func WithPollConcurrency

func WithPollConcurrency(n int) ServeOption

WithPollConcurrency sets the number of parallel poll (claim) loops — the soft limit on polling-driven concurrency. Default: 1. It is sub-capped by the hard limit (WithMaxConcurrent).

func WithPollInterval

func WithPollInterval(d time.Duration) ServeOption

WithPollInterval sets the claim poll interval. Default: 500ms.

func WithRetryBackoff

func WithRetryBackoff(fn RetryBackoff) ServeOption

WithRetryBackoff sets the wait a requeued Process serves before it becomes claimable again. Default: 2^attempts seconds, capped at a minute. A nil function restores that default, and a negative duration is treated as zero.

The kernel decides *whether* to retry (WithMaxStepAttempts) and you decide *how soon*. Two things the fixed default cannot express are the usual reasons to set it: jitter, so a fleet that failed together does not retry together; and a shorter curve in tests, which otherwise spend the real seconds.

agentkit.WithRetryBackoff(func(attempts int) time.Duration {
	base := min(time.Duration(1<<min(attempts, 6))*time.Second, time.Minute)
	return base + time.Duration(rand.Int64N(int64(base/4)))
})

The wait is written to the Process as its wake time, and a pending Process is not claimable until it passes. A Repository that does not honour that (see the ClaimNextProcess contract) will retry as fast as it polls whatever this says.

func WithSettleTimeout added in v0.3.3

func WithSettleTimeout(d time.Duration) ServeOption

WithSettleTimeout bounds one settle — every store call that moves a claimed row out of `running`, including a read the settle makes to decide what to write. It runs on a context the caller cannot cancel, so this is what stops it, and it must fit inside whatever grace period the host allows between asking the process to stop and killing it. Default: 5s. A value <= 0 restores the default.

A settle is what moves a claimed row out of `running` — a requeue, a release, or an external finalize. It deliberately does not inherit the Serve context's cancellation, because that cancellation is frequently the very reason the row has to be settled; this timeout is what keeps a worker on its way out from being held open by a store that stopped answering.

func WithWorkerID

func WithWorkerID(id string) ServeOption

WithWorkerID sets the worker id (diagnostic). Default: hostname + "/" + uuid v7.

type Session

type Session interface {
	// Generate runs one LLM turn in the managed conversation. Tools are bound
	// from Syscalls.Tools() (gollem fixes tools at session construction, and a
	// stable tool set is also what prompt caching wants). Extra GenerateOption
	// values are applied after the injected History/Tools and so can override
	// them.
	//
	// Passing no input at all is meaningful: it continues from the History as it
	// stands, which is what follows a CallTool that already appended its result.
	Generate(ctx context.Context, input []gollem.Input, opts ...GenerateOption) (*GenerateResult, error)

	// CallTool runs a tool call like Syscalls.CallTool and appends its result to
	// the conversation, so the History a Step boundary commits can end on a closed
	// tool_use/tool_result pair without spending another LLM turn.
	//
	// Use it for a call the MODEL asked for. For a call the strategy makes on its
	// own initiative, use Syscalls.CallTool: appending a tool_response with no
	// matching tool_use would corrupt the conversation, and the kernel cannot tell
	// the two apart.
	//
	// Exactly one tool_response is appended per call, whatever the outcome. On an
	// error (unknown tool, invalid arguments, a failing Run, a middleware that
	// refused, or a result the conversation format cannot encode) the appended
	// result carries IsError with the error text, and the error is ALSO returned.
	// Leaving the pair open is not an option — the model asked for this call and
	// the next request has to answer it.
	//
	// Results that answer the SAME model turn are appended to one tool message,
	// not one message each: a provider counts tool results per turn, so calling
	// this once per call of a parallel tool round still produces a conversation
	// the next request can send.
	//
	// It needs the conversation to already hold a History (a Generate earlier in
	// this transition, or a committed one), because a History carries the provider
	// identity and the kernel cannot invent it; otherwise ErrInvalidRequest.
	CallTool(ctx context.Context, call gollem.FunctionCall) (map[string]any, error)

	// History returns the conversation's current history: the working copy once
	// this transition has advanced it, otherwise the committed version (loaded on
	// first use, so it reflects stored History even before the first Generate of a
	// fresh claim).
	History(ctx context.Context) (*gollem.History, error)
	// contains filtered or unexported methods
}

Session is the Process's managed conversation: the runtime carries History across calls and, once committed, across steps and workers, so a strategy threads neither History nor tools by hand. History is persisted by the worker before the next commit (ADR-0017).

It is scoped to one transition, like the Syscalls it came from. Do not keep it in strategy state.

Every method requires the agent to have been registered with WithHistoryStore and returns ErrHistoryNotConfigured otherwise, so the managed conversation is never silently run without persistence. For a strategy that manages History itself, the primitive Syscalls.Generate with WithHistory is still there.

type SpawnHandler

type SpawnHandler func(ctx context.Context, req *SpawnRequest) (ProcessID, error)

SpawnHandler buffers one child Process into the transition commit and returns its freshly minted id. The child is not persisted until the transition commits.

type SpawnMiddleware

type SpawnMiddleware func(next SpawnHandler) SpawnHandler

SpawnMiddleware wraps a SpawnHandler.

type SpawnOption

type SpawnOption func(*spawnConfig)

SpawnOption configures a Spawn / SpawnChild.

func WithIdempotencyKey

func WithIdempotencyKey(key string) SpawnOption

WithIdempotencyKey makes Spawn return the existing Process's ID if one already matches (not an error). Not usable on SpawnChild (child creation is buffered into the transition commit and has no dedup); specifying it there is ErrInvalidRequest.

func WithInheritedHistory

func WithInheritedHistory(from ProcessID) SpawnOption

WithInheritedHistory starts the new Process's conversation from the version `from` has committed, instead of from an empty one. Use it to continue a finished Process's conversation in a Process of its own — one whose Metrics, limits and cancellation are its own (ADR-0017).

The version is resolved from `from`'s record at Spawn and pinned on the new Process, so a later turn of `from` does not change what this one starts from. The new Process saves its own versions under its own id, and the kernel never Discards the inherited one.

`from` must exist and must have committed a conversation, or Spawn fails with ErrProcessNotFound / ErrInvalidRequest; the agent must be registered with WithHistoryStore, or ErrHistoryNotConfigured. What the kernel does NOT promise is that the inherited version survives: if `from` is still running, its next commit releases the version this Process was pointed at (Discard is a notification, so whether it is really reclaimed is the store's call). Inherit from a finished Process, or accept that.

Not usable on SpawnChild: a strategy has no way to obtain a version to inherit (Syscalls hands out no HistoryRef), so the option would only be reachable with a ref smuggled in from outside. Specifying it there is ErrInvalidRequest.

func WithMetadata

func WithMetadata(m map[string]string) SpawnOption

WithMetadata sets Process.Metadata (infrastructure-facing scope for ToolFactory; not a credential — derive it server-side from a validated principal).

On SpawnChild it REPLACES the parent's map rather than merging into it, and omitting it inherits the parent's. Merging would leave a caller no way to drop a key the parent carries; replacing makes both outcomes reachable, with an empty map meaning "this child gets none".

func WithSubject

func WithSubject(ref SubjectRef) SpawnOption

WithSubject sets a turn-lock subject. An open Process holding the same subject makes Spawn return ErrSubjectBusy.

type SpawnRequest

type SpawnRequest struct {
	Effect   EffectContext
	Agent    AgentName
	Metadata map[string]string
	Subject  *SubjectRef

	// OnCommit registers fn to be called exactly once with this TRANSITION's
	// commit outcome: nil when the transition committed, non-nil when it did
	// not.
	//
	// The scope is the transition, not the child, and the distinction is not
	// pedantic: registering before calling next means fn still fires with nil if
	// next failed but the transition went on to commit anyway — no child exists
	// in that case. Register after a successful next to bind fn to a child that
	// was really buffered.
	//
	// fn runs after the commit, outside the transition, so a panic in it cannot
	// become a transition error; it is recovered and logged. A nil fn is
	// ignored.
	OnCommit func(fn func(err error))
	// contains filtered or unexported fields
}

SpawnRequest is one SpawnChild. The launch options are resolved into fields so a middleware can read them; WithIdempotencyKey is rejected before the chain runs because it is never valid on a child.

func NewSpawnRequest

func NewSpawnRequest[I any](req *SpawnRequest, input I) *SpawnRequest

NewSpawnRequest returns a shallow copy of req with the input replaced. req is left untouched. I is unchecked here (see NewInitRequest); a mismatch surfaces as ErrInvalidRequest when the child's Init runs.

type StepHandler

type StepHandler func(ctx context.Context, req *StepRequest) (*StepResult, error)

StepHandler runs one transition of a strategy.

type StepMiddleware

type StepMiddleware func(next StepHandler) StepHandler

StepMiddleware wraps a StepHandler.

Unlike the effect middleware, it must call next AT MOST ONCE. A Step's side effects — spawned children, emitted events, metrics — accumulate per transition rather than per call, so a second attempt would commit the first attempt's effects together with its own state and Decision. The second call returns ErrInvalidRequest rather than doing that quietly.

To decide a transition without running the strategy, do not call next at all and return a StepResult built with NewStepResult.

type StepRequest

type StepRequest struct {
	Effect EffectContext

	// Process is a copy taken for this transition, so writing to it changes
	// nothing that gets committed. It is a snapshot for deciding and recording,
	// not a way to edit the row.
	Process *Process

	// Sys is the syscall surface handed to Step. It may be replaced; Syscalls is
	// sealed against outside implementations but can be wrapped by embedding it
	// (type counting struct{ agentkit.Syscalls }) and overriding what you need.
	Sys Syscalls
	// contains filtered or unexported fields
}

StepRequest is one call to a strategy's Step, between DecodeState and EncodeState.

What a Step middleware observes is the Step CALL, not the transition's commit: the commit happens after the handler returns, outside this chain. A transition that fails to commit is re-run from the last checkpoint, so the middleware is called again — the at-least-once execution model is visible here exactly as it is.

func NewStepRequest

func NewStepRequest[S any](req *StepRequest, state S) *StepRequest

NewStepRequest returns a shallow copy of req with the state replaced. req is left untouched. S is unchecked here (see NewInitRequest); a mismatch surfaces as ErrInvalidRequest when the strategy's Step is called.

type StepResult

type StepResult struct {
	// contains filtered or unexported fields
}

StepResult is what one Step produced: the next state and the Decision.

Both are type-erased for the same reason: a kernel middleware runs across every agent and knows neither S nor the strategy's output type O. Read them with ResultState and ResultDecision, and build a replacement with NewStepResult.

func NewStepResult

func NewStepResult[S, O any](state S, dec Decision[O]) *StepResult

NewStepResult builds a result without calling next, i.e. it decides the transition in place of the strategy. S and O are unchecked here (see NewInitRequest); a mismatch surfaces as ErrInvalidRequest when the state is encoded or the Done output is.

type Strategy

type Strategy[S, I, O any] interface {
	// Version is the current state schema version. Reading older versions is
	// DecodeState's job (it absorbed the old Migrate).
	Version() int
	// Init builds the initial state, purely. Input is received typed (Spawn
	// passes the typed value through as any and BindStrategy's closure
	// type-checks it — no serialization runs). Init receives no Syscalls and no
	// ctx, so a STRATEGY AUTHOR has structurally no path to effects here.
	//
	// That guarantee is about this signature, not about the surrounding call.
	// Whoever configures the Kernel can wrap Init with InitMiddleware, which does
	// receive a ctx and can perform effects around it. Init still runs inside
	// Agent[I].Spawn / SpawnChild and never on the transition machine, so unlike
	// Step it is free of at-least-once re-execution — which makes it the safer
	// of the two places for such an effect. Its error is returned synchronously
	// by Spawn.
	Init(input I) (S, error)
	// Step runs one transition. It always receives the DecodeState-restored
	// state (the first transition too, since Init's result was persisted at
	// insert). The input I is folded into State and does not appear here.
	Step(ctx context.Context, sys Syscalls, state S) (S, Decision[O], error)
	// Limit decides whether this Process may continue. The kernel measures
	// (Metrics); this method is where the budget policy lives (ADR-0010). It runs
	// at each transition boundary, before every Generate, CallTool and SpawnChild,
	// and again after each of those has been metered.
	//
	// It takes no S: the boundary evaluation happens before the state is decoded,
	// and a limit that has to read the algorithm's own state is a branch in Step,
	// not a budget. What it must be is cheap, read-only and non-blocking — see the
	// Limiter type, whose shape this method has, for what that rules out.
	//
	// Returning LimitPass() means unlimited. There is no way to opt out of
	// answering, which is the point: a budget nobody configured used to read as no
	// budget at all.
	Limit(ctx context.Context, proc *Process, metrics Metrics) LimitDecision
	// EncodeState / DecodeState fully own state serialization — the format is
	// free (JSON/gob/protobuf/...). agentkit only stores the bytes.
	EncodeState(state S) ([]byte, error)
	DecodeState(version int, raw []byte) (S, error)
	// EncodeOutput turns the value passed to Done into the bytes stored on
	// Process.Output. The output must be persisted because a parent reads it
	// through a children await (await.go), which crosses instance and time
	// boundaries. There is deliberately no DecodeOutput: nothing reads those
	// bytes back as O — a completion handler receives the value Done was given
	// (no round trip), and a parent treats a child's Output as opaque bytes.
	EncodeOutput(out O) ([]byte, error)
}

Strategy is a checkpointable typed state machine. S is the strategy state type, I is the launch input type, O is the output type passed to Done.

type StrategyBinding

type StrategyBinding struct {
	// contains filtered or unexported fields
}

StrategyBinding is the type-erased form of a Strategy, storable in a Registry. agentkit itself never touches S, I or O.

func BindStrategy

func BindStrategy[S, I, O any](s Strategy[S, I, O], opts ...RegisterOption[O]) StrategyBinding

BindStrategy erases the type of a Strategy by folding Init/Step/EncodeState/ DecodeState/EncodeOutput into closures, plus the completion handler when one was registered. Limit is carried as the method value itself, having no typed argument to erase. Exported for building fake strategies in tests.

type SubjectRef

type SubjectRef struct {
	Kind string `json:"kind"`
	ID   string `json:"id"`
}

SubjectRef is the target of turn-lock (single-flight) suppression.

type Syscalls

type Syscalls interface {
	// --- execution context ---
	ProcessID() ProcessID
	RootID() ProcessID
	ParentID() (ProcessID, bool)
	Agent() AgentName
	Now() time.Time // current time (the Kernel's clock; testable via WithClock). Not deterministic.
	// Attempt reports prior attempts at THIS transition that did not commit, so
	// a strategy can tell a replay from a first run before acting. A zero value
	// means this is the first attempt.
	Attempt() AttemptInfo
	// Metadata returns a COPY of Process.Metadata (what WithMetadata set at
	// spawn), so a strategy can read the process-scoped map without being handed
	// the Process. Nil when there is none; writing to the returned map affects
	// nothing. It is data, not a credential — the value was trusted when Spawn
	// was called, not verified here (ADR-0011).
	Metadata() map[string]string

	// --- LLM (via gollem; Limit before, Metrics after) ---
	Tools() []gollem.Tool // the tools the ToolFactory built (to declare to the LLM).
	Generate(ctx context.Context, input []gollem.Input, opts ...GenerateOption) (*GenerateResult, error)
	// Session returns the Process's managed conversation, where the runtime
	// carries History across calls (and, once committed, across steps and
	// workers) and injects the claim's tools. It always returns a usable handle;
	// the handle's methods report ErrHistoryNotConfigured when the agent was
	// registered without WithHistoryStore. To manage History yourself, use the
	// primitive Generate with WithHistory. See ADR-0017.
	Session() Session

	// --- tool execution (Limit before, Metrics after; no approval gate) ---
	CallTool(ctx context.Context, call gollem.FunctionCall) (map[string]any, error)

	// --- waits ---
	Await(key AwaitKey) (*Await, bool) // reads from the snapshot loaded at transition start. Declaration is via Decision.

	// --- observation ---
	Emit(ctx context.Context, typ EventType, payload []byte) error // flushed on commit. Encoding is the caller's.
	Metrics() Metrics                                              // proc.Metrics (committed) + this run's accumulation.
	// LimitStatus reports this Strategy's most recent Limit verdict, starting
	// with the one taken at the transition boundary. Switch on Kind() and read
	// Message().
	//
	// It moves in lockstep with Metrics(): Limit runs again before and
	// after every Generate, CallTool and SpawnChild, so reading this right after
	// a Generate reflects the tokens that Generate just spent — including a
	// refusal the Generate itself provoked by crossing the cap.
	//
	// A LimitKindStop here means Limit is refusing, NOT that this Process
	// has stopped. An effect that already ran is not undone, and a strategy is
	// free to finish with what it has; enforcement happens at the next effect's
	// check or the next transition boundary.
	//
	// Do NOT fold it into checkpointed state. Like Now() and Metrics(), it
	// depends on how far this attempt got and does not reproduce across a replay
	// (ADR-0003).
	LimitStatus() LimitDecision
	// contains filtered or unexported methods
}

Syscalls is the path by which a strategy (a user program) touches the outside world. It runs metering (Metrics) and Limit checks and offers spawn, wait reads, and event emission. The implementation (a private struct) is assembled by the worker per claim. The naming is an OS metaphor: ProcessID()=getpid, Now()=clock, SpawnChild=fork.

There is no effect journal and no approval gate. Generate/CallTool simply call gollem and accumulate Metrics. There is no operation label either — nothing is journaled, so no key is needed to identify a call.

Generate, CallTool and SpawnChild each run through their middleware chain first, if the Kernel was given one. The chain is the outermost layer: it wraps the Limit check, tool resolution and argument validation, and a middleware that returns without calling next stops the call before any of them.

type ToolCallHandler

type ToolCallHandler func(ctx context.Context, req *ToolCallRequest) (map[string]any, error)

ToolCallHandler executes one tool call.

type ToolCallMiddleware

type ToolCallMiddleware func(next ToolCallHandler) ToolCallHandler

ToolCallMiddleware wraps a ToolCallHandler.

It can refuse a call fail-closed, and unlike an observation hook it runs before the tool does. It is a real chokepoint for calls made through Syscalls.CallTool — but not the only path to a tool: a strategy holding a gollem.Tool value can call Run on it directly. Enforcement still belongs inside Run; this is not an authorization gate.

type ToolCallRequest

type ToolCallRequest struct {
	Effect EffectContext
	Call   gollem.FunctionCall
}

ToolCallRequest carries one tool call, before the tool is resolved. Rewriting Call.Name selects a different tool; rewriting Call.Arguments changes what is validated and executed.

type ToolFactory

type ToolFactory func(ctx context.Context, proc *Process) ([]gollem.Tool, error)

ToolFactory is called once per claim to build the set of tools (gollem.Tool) a Process may use. It is a function type (not an interface) because per-claim construction is the main use and a closure is the most natural form; stateful implementations pass a method value. The implementation decides which tools to give based on proc.Agent / proc.Metadata (the agent kind itself is the selector — the kernel has no selection vocabulary). Process-independent dependencies can be injected via the ctx passed to Serve. The kernel does not interpret proc.

Tools are used as-is: agentkit has no Tool wrapper and no SideEffect class. Side-effect idempotency and any fail-closed authorization are the tool author's responsibility (see the human confirmation pattern; the kernel has no approval gate).

Directories

Path Synopsis
historystore
filesystem
Package filesystem is a single-process reference implementation of agentkit.HistoryStore backed by one JSON file per History version.
Package filesystem is a single-process reference implementation of agentkit.HistoryStore backed by one JSON file per History version.
historytest
Package historytest is a contract conformance suite for agentkit.HistoryStore implementations.
Package historytest is a contract conformance suite for agentkit.HistoryStore implementations.
memory
Package memory is an in-process, non-persistent reference implementation of agentkit.HistoryStore.
Package memory is an in-process, non-persistent reference implementation of agentkit.HistoryStore.
repository
filesystem
Package filesystem is a single-process, crash-atomic reference implementation of agentkit.Repository backed by a single JSON snapshot file.
Package filesystem is a single-process, crash-atomic reference implementation of agentkit.Repository backed by a single JSON snapshot file.
internal/store
Package store holds the shared in-memory state machine behind the memory and filesystem reference Repository implementations.
Package store holds the shared in-memory state machine behind the memory and filesystem reference Repository implementations.
memory
Package memory is an in-process, non-persistent reference implementation of agentkit.Repository.
Package memory is an in-process, non-persistent reference implementation of agentkit.Repository.
repotest
Package repotest is a contract conformance suite for agentkit.Repository implementations.
Package repotest is a contract conformance suite for agentkit.Repository implementations.
strategy
planexec
Package planexec provides a plan/execute/replan strategy: an LLM planner decomposes the prompt into parallel tasks, each task runs as a child Process (any Agent[T], adapted via makeInput), the results are collected, and the planner decides to iterate or finalize.
Package planexec provides a plan/execute/replan strategy: an LLM planner decomposes the prompt into parallel tasks, each task runs as a child Process (any Agent[T], adapted via makeInput), the results are collected, and the planner decides to iterate or finalize.
simple
Package simple provides a general LLM-loop strategy: generate, run any tool calls, feed the results back, repeat until the model answers with no more tool calls.
Package simple provides a general LLM-loop strategy: generate, run any tool calls, feed the results back, repeat until the model answers with no more tool calls.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL