Decisions
Let the model choose one currently-legal machine event, validated and retried by the machine before it is taken.
Alpha:
@statelyai/agent2.0 is in alpha. APIs can change between releases; pin an exact version. Feedback: github.com/statelyai/agent.
Overview
A decision lets the model choose an event to send to the running machine. It chooses based on which events are enabled in the current state: the machine declares candidate events, guards decide which are legal, and the model picks among the survivors. Not free text, not an arbitrary tool call; an out-of-bounds choice is impossible, not merely discouraged by a prompt.
The snippets below are from Twenty Questions, where each turn the model chooses ASK or GUESS.
Invoking an agent decision
Author a decision inline with the builtin agent.decide actor source, on the invoke that needs one. Its input takes:
model: which model to use (a key from your models map).system(optional): system prompt.prompt(optional): user prompt, usually built fromcontext.allowedEvents(optional): the candidate events (exact types or patterns). Defaults to all currently-legal events.maxRetries(optional): retries after an invalid choice. Default 2.
// ...
deciding: {
invoke: {
id: 'chooseAction',
src: 'agent.decide',
input: ({ context }) => ({
model: 'quick',
system: 'Ask one yes/no question at a time, but guess on the final turn.',
prompt: `Questions remaining: ${context.questionsRemaining}`,
allowedEvents: ['ASK', 'GUESS'],
}),
// The model never made a valid choice (retries exhausted).
onError: { target: 'failed' },
},
on: {
ASK: ({ context, event }) =>
context.questionsRemaining > 1 ? { target: 'awaitingAnswer', context: { /* ... */ } } : undefined,
GUESS: ({ context, event }) => ({ target: 'revealing', context: { guess: event.guess } }),
},
}
// ...The allowedEvents list is strongly typed against the machine's event schema, so a typo is a compile error. Listing events explicitly also makes the candidate set reviewable in the machine.
Note:
agent.decideneeds a snapshot-aware host (runAgentor the step path) to know which events are currently legal. Under a barecreateActor(...), listallowedEventsexplicitly; wildcards and the omitted default cannot expand there.
Note:
allowedEventsnarrows the declared candidates; guards then decide what is actually legal from the current snapshot. A declared-but-currently-illegal choice does not get through.
allowedEvents patterns
The allowedEvents option accepts a single string or an array. Entries are exact event types or wildcard patterns, and the two can mix (['todo.*', 'reset']):
['ASK', 'GUESS']: exact types, typed against the event-schema keys (typo = compile error).'ASK': a single string, shorthand for a one-entry array.'*': every currently-legal event.'todo.*': a dotted namespace, every declared event undertodo.(todo.add,todo.toggle, …). Typed against declared dotted types, so'nope.*'(matching nothing) is a compile error.
Delivering the chosen event
Delivery is automatic: when the decision resolves, the agent.decide actor sends the chosen event to the machine, and the matching on: transition runs. You handle the outcome with ordinary transitions, no special decision plumbing.
Note: The chosen event's transition typically exits the invoking state, cancelling the invoke, so
onDonenormally never fires. DeclareonDoneonly when the chosen event's transition stays in-state; the invoke then completes with the chosen event as output.onError(retries exhausted,AgentDecisionExhaustedError) is unaffected.
Guard enforcement
Guards are transition functions returning undefined (see transitions). Because a guard may read the event payload, candidates cannot be filtered upfront: a decision offers the full allowedEvents set (intersected with what the state statically accepts), and snapshot.can(event) is checked after the model picks. A chosen ASK on the final turn is rejected and the model asked again.
runAgent and the step path do this for you. When calling resolveDecision directly (uncontrolled mode), thread the check via canTake:
import { resolveDecision } from "@statelyai/agent";
const event = await resolveDecision(request, executors.decide, {
canTake: (e) => snapshot.can(e),
});Validation and retries
Each attempt runs three checks in order. Each failure is typed and fed back to the model on the next attempt:
unknown-event: the type is not among the candidate events.invalid-payload: the payload does not match that event's schema.rejected-by-guard: type and payload are fine, butsnapshot.can(event)returnedfalse.
Retry behavior:
- Default 2 retries, so up to 3 attempts. Set
maxRetrieson the decide input to change it. - Prior failed attempts ride on
request.attempts, so the host can render "your last choice failed because X" into the next call. Core never rewrites the prompt itself; see Hosts. - Exhausting retries throws
AgentDecisionExhaustedError(carrying the attempts list), caught by the invoke'sonError.
Coercion
Core validates and retries; it never talks to a model. Coercing the model into choosing exactly one option (tool-per-event with forced tool choice, structured output over an event union, etc.) is the host's responsibility. The shipped createAiSdkExecutors provides a decide executor for the Vercel AI SDK; the raw-SDK examples force the choice with tool_choice. See Hosts.
Note: Decisions are state-local: author them inline on the invoke. There is no reusable decision-logic object, because a decision's candidates and legality depend on the state it runs in.
Multi-event commands: the decide loop
A decision is one event. When one command needs several ("add X and Y" → two ADD_TODO), loop the decision in the machine. The loop, its exit, and the applied trail stay visible in the statechart:
- A
planningstate invokesagent.decidefor one event. - Applying that event targets a turnaround state that immediately re-enters
planning, starting the next step. - An explicit machine event (e.g.
DONE) is amongallowedEventsand targets somewhere outside the loop, so the model can end it. - The trail of applied events lives in context and is appended to each step's prompt.
planning: {
invoke: {
src: 'agent.decide',
input: ({ context }) => ({
model: 'quick',
prompt: `${context.command}\n\nAlready applied: ${context.applied.join(', ')}`,
allowedEvents: ['ADD_TODO', 'TOGGLE_TODO', 'DONE'],
}),
},
on: {
ADD_TODO: ({ context, event }) => ({
target: 'applying',
context: { applied: [...context.applied, `ADD_TODO ${event.title}`] },
}),
DONE: { target: 'awaitingCommand' },
},
},
applying: { always: { target: 'planning' } },Every step gets this page's validation/retry loop. Full example: examples/todo-nl/index.ts.
Related
- Agent machines: transitions, guards, and event schemas.
- Hosts: the decide executor and how the model is coerced into one event.
- Machines as data: authoring decisions from JSON.