What an agent workload is
These are properties of the environment an agent runs in, not design opinions:- A model call is a network request measured in seconds. The time goes to waiting on a remote service, not to computing locally.
- Providers rate-limit and fail transiently. Rejected and failed calls are part of normal operation, and clients are expected to retry them after a delay.
- A conversation is state that outlives any single request. History, working memory, and partial plans must exist between turns, and turns can be minutes or days apart.
- Tool calls have side effects and unbounded latency. A tool is a child process, an HTTP backend, or a database. It can hang, crash, or answer late, and it may already have acted when it does.
- Output is non-deterministic. The same input does not produce the same output, so the next step is decided at runtime from the data, not from a fixed call graph.
- One process hosts many agents. Sessions are numerous and mostly idle; a few are active at any instant.
- Agents talk to agents. Planning, execution, evaluation, and memory are commonly split across specialized components that exchange results.
Why the actor model fits
In the model’s original formulation, an actor may do three things in response to one message: send messages to other actors, create new actors, and designate how it will handle the next message. Write down what an agent does in one step and the same three appear:- Send messages. A tool call, a request to another agent, and a reply to the user are all messages to something outside the agent. The model call itself is a request to a remote service whose result comes back later.
- Create actors. An orchestrator that breaks a task into subtasks and hands them to workers is spawning children with a bounded lifetime.
- Designate the next behavior. After each result the agent updates its memory and plan and decides what the next input means. That is a behavior change, and it is why an agent is stateful across steps rather than a function of its last input.
- Private state, one message at a time keeps memory and plans consistent without locks, so a concurrency bug cannot corrupt what the model reasons over.
- Asynchronous message passing is how a component waits seconds for a model or a tool without holding a thread; the reply is just a later message.
- Supervision hierarchies give every failure an owner other than the code that failed, which matters when most failures come from services the agent does not control.
- Location transparency means the same agent code runs in one process or across a cluster, and an agent addresses another by name without knowing where it runs.
- The mailbox is an ordered record of every event the agent saw, in the order it saw them, which is the trace you need to explain a non-deterministic decision afterward.
Why GoAkt
The actor model is a specification. Whether an agent built on it holds up is decided by the runtime, and GoAkt is positioned for agents by what it already ships:- An agent costs its state, not a goroutine. Actors and grains do not own goroutines; they run on a fixed dispatcher pool, so thousands of idle sessions cost their fields, not a thread each.
- Sessions the runtime manages. Grains give one activation per identity: activated on the first message, passivated when idle, claimed once across the cluster, relocated on node loss. The session lifecycle every agent product otherwise writes by hand is the framework’s job.
- Model calls that never block a worker. PipeTo runs the call off the message loop,
reentrancy lets a grain keep serving while it waits, and
DeferResponsecompletes the caller’s request when the answer arrives. - Failure handling as configuration. Supervision with a restart budget and exponential backoff, a circuit breaker on outbound calls, and dead letters for what could not be delivered.
- Delivery guarantees when a job must not be lost. Point-to-point and work-pulling flows deliver each message at least once under a stable deduplication key, optionally through a durable queue, so a long-running agent task survives the worker that was running it.
- One binary from laptop to cluster. Clustering, remoting, placement, relocation, singletons, and multi-datacenter routing are in the library, and membership and the cluster registry run inside the process; there is no broker or coordination service to operate beside it.
- Backpressure for fan-out. Streams bound how many model calls run at once and slow producers down when consumers fall behind.
- Time as messages. The scheduler fires once, on an interval, or on a cron expression, and can fire exactly once cluster-wide.
- Observability built in. OpenTelemetry metrics and traces, per-actor instruments, an event stream, and dead letters, so a decision can be traced back to the message that triggered it.
- Testable without a cluster. The TestKit drives actors and grains with probes, and its multi-node helper runs cluster scenarios in one test process.
- Pure Go, in production since 2022. One module to import, no sidecar, no CGO.
Isolation: state cannot be modified accidentally
An agent’s memory, context window, partial plan, and tool results are the kind of state that concurrent code corrupts when two components reach it at once. Inside an actor that cannot happen:- One goroutine at a time. The dispatcher moves an actor from
ScheduledtoProcessingwith a compare-and-swap, so at most one worker is insideReceivefor a given actor at any instant. A grain has the same guarantee forOnReceive. - Sequential delivery. Messages drain from the mailbox in FIFO order; handler N returns before handler N+1 starts.
- No shared memory. State lives in unexported fields, and the only way in is a message.
Event-driven: the reasoning loop is Receive
An agent loop reads as: receive an event, update internal state, decide the next action, communicate with other agents or external systems. That is the shape ofReceive, and every input an agent reacts to arrives there as a message:
- A user turn: a
TellorAskfrom the caller. - A model completion or a tool result: the value a PipeTo task returned, delivered to the mailbox
when the task completes, or the reply to a non-blocking
Requestagainst a tool actor. - A failed call: a
*StatusFailureon a grain, a dead letter on an actor. - Time: a scheduled message or a grain timer tick.
- A collaborator stopping:
Terminated, through death watch.
Become swaps the handler, and Stash holds input
that arrived for the wrong phase until the actor is ready for it.
Failure is normal: the runtime recovers, not the handler
Two kinds of failure reach an agent, and GoAkt handles them with two different mechanisms. A model or tool call fails. The call ran inside aPipeTo task, off the message loop. When the task returns an
error or exceeds WithTimeout, the actor does not crash: a grain receives a *StatusFailure message and an actor’s
failure goes to the dead-letter queue. The handler decides what happens next on an ordinary turn: retry after a delay
with ScheduleOnce, fall back to another model, or answer the caller with the error. WithCircuitBreaker drops
outcomes while a breaker is open, so a provider that is down is not hammered from every session at once.
The handler itself fails. A panic or an error reported from Receive goes to the parent, which applies a
supervision directive: Resume, Restart, Stop, or Escalate. WithRetry bounds consecutive
restarts and WithExponentialBackoff spaces them out, so a crash loop against a broken dependency slows down instead of
tightening. Because the failing actor’s state was private, no other agent observed a half-finished mutation.
A restart discards the actor’s in-memory state and its mailbox. Keep the durable copy of a conversation in a store,
load it in
PreStart or OnActivate, and write it back in PostStop or OnDeactivate, so a restart is a reload
rather than a loss.Long-running and numerous: sessions as grains
A conversation is state keyed by an identifier, active for a moment, then idle for a long time. That is the profile grains are built for:- Identity-addressed. Send to
GrainOf[*SessionGrain](ctx, system, sessionID); there is no spawn step and no registry to keep in sync. - Activated on the first message.
OnActivateruns once, loads the conversation, and receives the store and model client throughWithGrainDependencies. - Passivated when idle. After
WithGrainDeactivateAfterelapses with no traffic,OnDeactivateruns and the grain leaves memory. The next message activates it again under the same identity. - Placed by the cluster. In cluster mode the grain is claimed once in the cluster registry, requests from any node are forwarded to the owner, and the grain is relocated when its node leaves.
Multi-agent: messages and supervision trees
Splitting an agent into a planner, executors, an evaluator, and a memory component turns each into a named actor or grain with one responsibility, and turns the collaboration into messages:- Delegation is
Tell,Ask, or a non-blockingRequestto a name. The API is the same whether the target is in-process, on another node, or placed by the cluster; see location transparency. - Hierarchy is
ctx.Spawn: an orchestrator spawns a child per subtask, supervises it, and learns throughTerminatedwhen it stops. - Bounded fan-out is
ParallelMap(n)in a stream, or a router when the first good answer among several should win. - Durable job distribution across nodes is work-pulling: each job is delivered at least once to exactly one worker and is re-dispatched if that worker dies before confirming.
Agent topologies
Anthropic’s Building effective agents (December 2024) separates workflows, where models and tools run through predefined code paths, from agents, where the model directs its own process and tool use, and names five workflow patterns. Each is an actor topology:- Prompt chaining. A sequence of steps where each output feeds the next. A chain of actors passing messages by name,
or a stream of
Mapstages; a gate between steps is acaseinReceivethat decides whether to forward. - Routing. Classify the input, then hand it to a specialized handler. A classifier actor that
Tells the specialist by name, or aConsistentHashrouter when one conversation must keep hitting the same routee. - Parallelization. Run subtasks concurrently and aggregate.
ParallelMap(n)when concurrency must be bounded; children that eachPipeTotheir result to the parent when the subtasks differ; a scatter-gather router when the first success is enough. - Orchestrator-workers. A central model decomposes work at runtime, delegates, and synthesizes. A parent actor spawns a child per subtask, watches it, and collects results as messages. Work-pulling when workers live on other nodes and a job must survive a worker’s death.
- Evaluator-optimizer. A generator and an evaluator loop until the output is accepted. Two actors exchanging
messages, with the iteration cap kept as a counter in the generator’s state and
Becomeswitching it from drafting to finalizing. - Autonomous agent. The model chooses tools until a stop condition. The agent grain issues each tool call with
PipeToorRequestActor, folds the result into its state, and decides the next step on the next turn. Each tool is an actor with its own supervisor and circuit breaker, so a broken tool degrades one capability, not the agent.
Orchestrator and workers
Bounded fan-out
A complete example
One grain per conversation. The caller sends a turn withAskGrain and receives the model’s answer, yet the grain never
blocks: the model call runs in a PipeToSelf task, the caller’s reply is deferred with DeferResponse, and the answer
completes it on a later turn. Failed calls are retried from a timer, turns that arrive while one is in flight wait in the
grain’s own queue, and the grain leaves memory after ten idle minutes.
main.go
DeferResponseneeds reentrancy. AnAskagainst a grain spawned withWithGrainReentrancyarrives as a correlated request, so the turn can hand its reply to aGrainReplyand complete it later. Without reentrancyDeferResponsereturns nil, which is safe to complete but answers nobody. See Grain Reentrancy.- The model call never touches grain state. The task receives copies of the model handle and the history; the result
comes back as a
*completionmessage and state changes on that turn. See Grain PipeTo. - Two timeouts, two jobs.
WithTimeouton the pipe bounds how long the grain waits for an outcome; the context inside the task bounds the call itself, so a slow provider does not leak goroutines. - Retries are messages. A
*StatusFailureschedules a*retrytick through the grain timer; the tick is processed in order with everything else. Timers are cancelled on deactivation, so a turn in flight across passivation is answered by the caller’s own timeout, not by a stale tick. See Grain Timers. - Ordering is the grain’s own decision. With
AllowAllreentrancy the grain keeps processing while a call is in flight, and the queue in its state serializes turns within one session without blocking the message loop. - Piped results are acknowledged. A piped message is delivered like a
Tell, so the handler callsNoErronce it has processed it.
What GoAkt does not provide
- Model clients:
Modelabove is your interface over whichever SDK you use. - Prompt templates, token accounting, and context-window management.
- Vector stores and retrieval.
- Tool schemas and protocol handling for tool servers.
See also
- Grains: identity-addressed sessions with activation and passivation
- Grain PipeTo and PipeTo: model and tool calls off the message loop
- Reentrancy and Grain Reentrancy: non-blocking requests and deferred replies
- Supervision: directives, restart budget, and backoff
- Passivation: idle and capacity-based deactivation
- Work-Pulling: durable job distribution across workers
- Reactive Streams: bounded parallel processing
- Observability: per-actor metrics and OpenTelemetry
- Coordinated Shutdown: draining live sessions on exit