Components

In Tardigrade, a component is the smallest unit of an actor's behavior.

More specifically, a component is a state machine that takes events as input and produces an output based on its current state. Its current state is derived as a pure function of an append only event log. This builds on ideas from automata theory and event sourcing.

ComponentCall modelPureEffectfulEventderive statedescribe next actionEffect valuedescriptionruntime executesI/OWorldresult recorded as an event

The first few sections would go deeper into the underlying concepts. I would recommend fully going through them to build better intuitions. However, jump straight to authoring components if you want to quickly get started.

Let's start with state machines.

State machines

A simple state machine

State machines is a concept from computational mathematics. They describe a thing that can exist in a finite set of states, transitioning from one state to another in response to inputs. A traffic light is a simple example of a state machine.

At any given point, a traffic light can exist only in one of three possible states: green, yellow, red. And there are specific rules that define how it transitions from one state to another.

Traffic light
State machinetimer expiredtimer expiredtimer expiredGreenYellowRed
The light is green. The timer expired: click Yellow.

A simple traffic light could have the following state transition rules:

  1. Green, timer expired → Yellow
  2. Yellow, timer expired → Red
  3. Red, timer expired → Green

Mathematically, it could be described as:

next state=δ(current state, input)\text{next state} = \delta(\text{current state},\ \text{input})

For a traffic light, one of the transition could be written as:

yellow=δ(green, timer expired)\text{yellow} = \delta(\text{green},\ \text{timer expired})

Here, δ (delta) is the transition function: it takes the current state and an input, then returns the next state. For our traffic light, giving it green and “timer expires” returns yellow.

Agents as state machines

Just like traffic lights, we could also view agents as state machines. Take an example of a simple tool calling agent. It can exist in one of the following possible states: calling a model, calling a tool, settled.

Tool-calling agentTardie thinking during a model call
State machinemessagereceivedtool calledtool returnedturn completedCallingmodelRunningtoolDone
The model responds with a tool call or a final answer. Click the next state.

It has the following transition rules:

  1. Settled, message received → Calling model
  2. Calling model, tool called → Calling tool
  3. Calling tool, tool returned → Calling model
  4. Calling model, turn completed → Settled

Mathematically, it could be described as:

Calling model=δ(Settled, message received)\text{Calling model} = \delta(\text{Settled},\ \text{message received})

If we can write the states and the transition rules between states, we can describe an agent as a state machine.

A brief note on side effects

I described components as pure functions over the event log. However, as you may have noticed in the example above, the output of a component is "calling a model". This is a side effect involving I/O. How can it be pure?

The Haskell community addressed this with the IO monad, which represents I/O actions as values to compose and execute later. Effect follows the same idea.

Each Tardigrade component produces transitions that describe actions as Effect values. The runtime executes those actions. The Effect<Success, Error, Requirements> type makes the result, expected errors, and required services explicit. Type the world!

TardigradecomponentEffect valueRuntimeproducesdescription of a model callcalls the modelWorld inWorld outResponserun1. DescribePure: build and composevalues. No I/O.2. ExecuteRunning the description performs I/O.

State explosion!

A tool calling agent is relatively simple to describe as a state machine. As our agents get more complex, we'd soon realise that it isn't practically possible to keep up with the current state and the transition rules anymore. A situation frontend developers know well as state explosion. Consider the same tool calling agent with a budget. Its next action now depends on both its execution phase and whether its tool budget is available or exhausted.

messagereceivedtool called[budget allowed]permission grantedtool returnedturn completedtool called[budget exhausted]permission deniedCallingmodelRequestingpermissionRunningtoolDoneBudgetexhaustedPermissiondenied
The model responds. Click the next state.
A tool call needs budget before it can ask for permission, and permission before the tool runs. A final answer needs neither. Click a highlighted state to move.

Add retries, permissions, and more tools, and the states and transitions soon multiply. How do we deal with this complexity?

Enter components

The beauty of state machines is that they can compose with one another.

Instead of viewing the agent or actor as a single state machine, we can compose it into multiple state machines. In Tardigrade, these units are called components. Each component is a state machine that responds to a particular event based on the current state.

AgentMessageReceived

Inference

tool calledtool returnedturn completedModelToolDone

Budget

budget spentbudget resetAvailableExhausted

Permission

tool calledgranteddeniedtoolreturneddenialhandledNotrequestedRequestedGrantedDenied
Combined outputCall model
Each component tracks its own state. The agent combines their outputs to decide what can run next. Click a highlighted state to move.

We can compose whole agents this way too. One agent can use another through a tool. The called agent runs its own state machines, and its answer returns to the calling agent as a tool result.

PARENT AGENTtool calledtool returnedturn completedModelToolDoneAgentmachineIdletool inputagent result
The parent calls its model. Click Tool to call the agent tool.

The state in the state machine

If you've noticed, each component is at a particular state at any given point. To pause and resume an agent, we could simply take a snapshot of the individual component state at a given point and hydrate the components with it. However, this method is very lossy.

We know the final position of the agent but we lose information about how it got there. How do we retain the complete history of an agent?

How we got here
  1. ToolCalledLook up the weather
  2. PermissionGrantedAllow this tool call
  3. ToolReturnedSunny, 24°C
  4. TurnCompletedAnswer delivered
Where we are now
Inference
Done
Budget
Available
Permission
Not requested

Enough to restore these states.
Their history is absent.

State as a projection of the event log

A state machine transitions to another state after receiving an event as an input. So we can store the entire history of an agent by storing every event in an append only log. The current state at any given point could then be derived using a projection over the log. This concept is called event sourcing.

Take an example of a ship that's delivering goods to islands. To track different aspects of the ship, one way would be to maintain a separate database for each aspect and updating its state as the ship moves across islands. This is the typical approach that loses the history of how we arrived at a particular state.

For example, we could keep three tables in the same database, each storing one aspect of the ship's current state:

Location
ShipCurrent island
TardiePalm Island
Cargo
ShipCrates on board
Tardie40
Deliveries
ShipCrates delivered
Tardie60

When the ship delivers another 10 crates, we update cargo to 30 and deliveries to 70. If we only keep these latest values, we cannot reconstruct the earlier stops or individual deliveries from them.

With event sourcing, we would instead write events into a log as the ship moves across islands. For example, we can stamp an event called PositionUpdated recording location and time whenever the ship reaches an island. I can get the exact path of the ship by replaying the events, and I can get the state at specific points in the journey by folding the logs.

Event log4 / 4
Drag to replay the log
Current location"North Bay"x: 9, y: 12Total distance21.2 kmElapsed time55 min
The log records the journey. Its projection tells us where the ship is.

The same log can yield location, distance, or elapsed time. Each projection is pure: the same events produce the same result.

EVENT LOGSHIP STATEPositionUpdatedspot: "Harbor"x: 4, y: 308:00ZPositionUpdatedspot: "Palm Cove"x: 10, y: 608:15ZPositionUpdatedspot: "Gull Island"x: 16, y: 1008:35ZPositionUpdatedspot: "North Bay"x: 9, y: 1208:55ZLocationNorth BayLatest position: x: 9, y: 12Distance21.2 kmSum the straight segmentsTime55 minSubtract the first timestamp

Agent state as projection

Each component derives its state from the same event log. Inference tracks messages and tool calls, budget tracks usage, and permission tracks requests and decisions. The same history yields three different states. Here, the turn has completed, the tool call has spent the last of the budget, and its permission has been cleared.

EVENT LOGAGENT STATE01MessageReceived02ToolCalled03PermissionGranted04BudgetSpent05ToolReturned06TurnCompletedInferenceDoneThe turn has completedBudgetExhaustedThe last available budget was spentPermissionNot requestedThe tool returned; its approval is cleared

Putting it together

Summing it all up, a component reads the event log, derives its current state, and describes what to do next as an Effect. The runtime executes that Effect and records its result as another event. That event becomes part of the history from which the component derives its next state.

Event logDerived stateEffect valueRuntimePureEffectfulComponentevent 1event 2…new resultprojectoutputdescribe next actionsame history, same stateexecute descriptionI/OWorldrecord result as an event; repeat

Supplying inputs to children

A parent can supply typed input constructors when building its children. The infer builder exposes message, the stable constructor also available as agent.input.message on the component returned by infer. It describes an agent turn without evaluating infer output. The alarm package chooses its response through onFired:

ts
infer(({ message }) => [
  tools([
    alarm({
      onFired: alarm => message({ text: alarm.note }),
    }),
  ]),
  nativeOutput,
])

The same alarm package works inside codeMode. Its set method takes { wakeAt, note }, where wakeAt is a Unix timestamp in milliseconds, and returns an alarm ID. Its cancel method takes { id }. The host records requests in the log and arms the earliest outstanding wake.

Calling message constructs a request. When an alarm fires, its component binds that request to the recorded firing and proposes a transition. The runtime commits the message before inference starts the turn. Replaying the firing preserves its transition identity. The package recognizes the committed response through that identity, independently of the response event type.

Builder capabilities pass through wrappers such as budget and tools. Scopes allow binding in the declaring component, its descendants, and its direct parent. Unrelated subtrees cannot bind a captured capability unless it is explicitly re-exposed through a component's input field. Callbacks and event constructors must be pure because projection and replay may evaluate them repeatedly. Returning undefined from onFired proposes no response.

Authoring components

Now that we've learnt the concepts, it's time to get our hands dirty. In this section we'll author a Tardigrade component from scratch. We'll build with a custom tool that lets a model search its own event log and those of its child agents.

The shape of a Tardigrade component looks like this:

COMPONENTcurrent stateEventstep(...)Stateinitial()output(state)Viewexposed stateTransitionsintent / effect

For our search tool, the behavior looks like this:

SEARCH TOOLinitialToolCalledadd a pending requestToolReturnedremove the requestIdleno requestsSearchingrequest pending
While a request is pending, the component enables an effect to search the agent's own log and its child agents' logs. This diagram follows one request.

Initial state

initial defines the component's state before it has processed any events. Our search tool starts with no pending requests, so its initial state is an empty list.

ts
initial: () => [],

Updating state

step receives the current state, an event, and a context supplied by the runtime. It returns the next state. For our search tool, a ToolCalled event adds a pending search request. When that request completes, we remove it. Unrelated events leave the state unchanged.

The context links work to the event that caused it. We save it with each request, use context.effect("search", ...) to declare its search, and use context.matches("search", event) to recognize its completion and remove the right request.

ts
step: (pending, event, context) => {
  if (
    event.type === "ToolCalled" &&
    event.name === "search_logs" &&
    typeof event.callId === "string"
  ) {
    return [...pending, {
      context,
      call: {
        callId: event.callId,
        name: event.name,
        arguments: event.arguments,
        ...(typeof event.turn === "string" ? { turn: event.turn } : {})
      }
    }]
  }

  const remaining = pending.filter(
    (request) => !request.context.matches("search", event)
  )
  return remaining.length === pending.length ? pending : remaining
},

step only updates state; the search itself is declared through output, which we'll define next.

Output

output reads the current state and returns a view and transitions. The view shares information with other components. Transitions describe work the runtime can perform, either proposing events through an intent or running external work through an effect.

Our search tool exposes the number of pending requests and declares a search effect for each one.

ts
output: (pending) => ({
  view: { pendingCalls: pending.length },
  transitions: pending.map(({ call, context }) =>
    context.effect("search", {
      input: call,
      act: (input, { signal }) => Effect.gen(function* () {
        const result = yield* searchLogs(input, signal)
        const at = yield* Clock.currentTimeMillis
        return { type: "ToolReturned", ...input, result, at }
      })
    })
  )
}),

Here, searchLogs is a function we supply to search the agent's own log and its child agents' logs. output declares the effect; the runtime runs act and records its ToolReturned event. That event flows back through step, which removes the completed request. With no pending requests, the component outputs no search effects.

Complete example

The pieces together, with Clock and Effect for the external action:

ts
import { Clock, Effect } from "effect"
import { component, type TransitionContext } from "tardie/core"

type Call = {
  callId: string
  name: string
  arguments: unknown
  turn?: string
}

type Pending = {
  call: Call
  context: TransitionContext
}

const searchTool = (
  searchLogs: (call: Call, signal: AbortSignal) => Effect.Effect<unknown>
) => component({
  name: "search_logs",
  initial: (): ReadonlyArray<Pending> => [],
  step: (pending, event, context) => {
    if (
      event.type === "ToolCalled" &&
      typeof event.callId === "string" &&
      event.name === "search_logs"
    ) {
      return [...pending, {
        context,
        call: {
          callId: event.callId,
          name: event.name,
          arguments: event.arguments,
          ...(typeof event.turn === "string" ? { turn: event.turn } : {})
        }
      }]
    }

    const remaining = pending.filter(
      (item) => !item.context.matches("search", event)
    )
    return remaining.length === pending.length ? pending : remaining
  },
  output: (pending) => ({
    view: { pendingCalls: pending.length },
    transitions: pending.map(({ call, context }) =>
      context.effect("search", {
        input: call,
        act: (input, { signal }) => Effect.gen(function* () {
          const result = yield* searchLogs(input, signal)
          const at = yield* Clock.currentTimeMillis
          return { type: "ToolReturned", ...input, result, at }
        })
      })
    )
  })
})