State-scoped tool availability and tool budgets for TypeScript AI agents. Define which tools an LLM can see in each conversation state, with a small state machine and a per-step tool budget. Zero runtime dependencies.
Docs · Examples · Migration · Issues
LLMs call tools in loops, in the wrong order, or before the data exists. reactive-fsm gates which tools are advertised in each state and gives you a small, explicit budget for repeated tool calls.
It is a policy layer, not an agent runtime:
- Your application still executes tools and authorizes side effects.
- Your application still decides when to transition state.
- The library keeps the state, the tool policy and the budget in one small place.
- State-scoped tools.
config.tools[state]is the authority. A registry entry is only advertised if it is allowed by the state, itsgatesand itscondition. - Tool budget. Counts consecutive tool calls between steps. It is an admission budget: multiple calls inside one step can still exceed it.
- Async guard.
transitionToAsync()accepts aPromise<boolean>guard and rejects if the state changed while the guard was pending. - Vercel AI SDK 7 adapter. Uses
prepareStep+activeToolswith a stableToolSet. Other adapters are experimental.
- It doesn't execute tools or authorize side effects.
- It doesn't persist conversation context;
snapshot()stores only the current state. - It doesn't detect semantic loops; the budget counts calls between steps.
- It isn't a full statechart engine or an agent runtime.
- TypeScript teams building agents with Vercel AI SDK 7.
- Flows where the valid tool set changes by conversation state.
- Agents where the model calls tools out of order or repeats the same tool.
If you only need a per-step tool list for one provider, AI SDK's activeTools may be enough. If you need durable orchestration, persistence or human-in-the-loop, use LangGraph or Mastra. If you need a full statechart, use XState.
npm install reactive-fsmRequires Node 20+. The package is ESM-only and has no runtime dependencies.
import { createFSM } from 'reactive-fsm'
const fsm = createFSM({
initialState: 'IDENTITY',
states: ['IDENTITY', 'BOOKING', 'PAYMENT'] as const,
tools: {
IDENTITY: ['validate_dni'],
BOOKING: ['check_availability', 'reserve_slot'],
PAYMENT: ['process_payment'],
},
loopShield: { enabled: true, maxConsecutiveTools: 3 },
})
fsm.currentState // 'IDENTITY'
fsm.allowedTools // ['validate_dni']
fsm.transitionTo('BOOKING')
fsm.allowedTools // ['check_availability', 'reserve_slot']Use as const on states to preserve the literal state union. Without it, S widens to string.
The Vercel adapter is maintained and contract-tested by this project. It is a community adapter, not affiliated with or endorsed by Vercel.
import { generateText, isStepCount, jsonSchema } from 'ai'
import { openai } from '@ai-sdk/openai'
import { createFSM, type ToolEntry } from 'reactive-fsm'
import { createVercelAdapter } from 'reactive-fsm/adapters/vercel-ai'
interface Ctx {
contactId: string
}
const fsm = createFSM({
initialState: 'IDENTITY',
states: ['IDENTITY', 'BOOKING'] as const,
tools: {
IDENTITY: ['validate_dni'],
BOOKING: ['check_availability'],
},
})
const registry: ToolEntry<Ctx>[] = [
{
name: 'validate_dni',
gates: ['IDENTITY'],
build: () => ({
description: 'Validate the customer DNI',
inputSchema: jsonSchema({
type: 'object',
properties: { dni: { type: 'string' } },
required: ['dni'],
}),
execute: async ({ dni }: { dni: string }) => {
if (!dni) throw new Error('DNI is required')
fsm.transitionTo('BOOKING')
return { valid: true }
},
}),
},
{
name: 'check_availability',
gates: ['BOOKING'],
build: () => ({
description: 'Check available slots',
inputSchema: jsonSchema({ type: 'object', properties: {} }),
execute: async () => ({ slots: ['10:00', '11:00'] }),
}),
},
]
const adapter = createVercelAdapter(fsm, registry, { contactId: 'c-1' })
const response = await generateText({
model: openai('gpt-4o'),
prompt: 'Hola, quiero reservar una cita',
...adapter.inject(), // { tools, prepareStep }
stopWhen: isStepCount(5),
})inject().tools contains the full ToolSet; prepareStep returns activeTools with only the tools allowed by the current state. The adapter does not mutate the tool set.
See the runnable example: examples/ai-sdk-7.
For the current state, a tool is advertised only if all of these are true:
- It appears in
config.tools[currentState]. - Its
registry.gatesincludes the current state. - Its
condition, if present, returnstrue.
The Vercel adapter then passes that set as activeTools to prepareStep. The model never sees a tool outside the policy.
This is advisory gating: the provider may still emit an unexpected call, and your application must validate it. The library does not wrap or authorize execute.
loopShield: {
enabled: true,
maxConsecutiveTools: 3,
mode: 'consecutive', // or 'repeated'
fallbackState: 'ESCALATION',
onLoop: ({ consecutiveTools, maxAllowed }) => {
console.warn(`tool budget reached: ${consecutiveTools}/${maxAllowed}`)
},
}consecutivecounts any consecutive tool calls.repeatedcounts consecutive calls to the same tool and requires atoolName.onLoopfires once per episode.- The budget resets when the state changes.
fallbackStatemoves to a safe state and resets the budget.- Multiple tool calls inside one step can exceed the budget before the next
prepareStep. It is not a strict per-call limit.
If you need a strict limit, enforce it in your tool execute or in your execution layer.
| Integration | Status | Notes |
|---|---|---|
| Vercel AI SDK 7 | Maintained, contract-tested | prepareStep + activeTools; community adapter |
| OpenAI | Experimental | Formats tools; no runtime loop |
| Anthropic | Experimental | Formats tools; no runtime loop |
| LangChain | Experimental | Returns a tool map; not a bindTools integration |
| Google Gemini | Experimental | Targets the legacy SDK |
- Node 20+.
- ESM-only.
- No runtime dependencies.
ai@7is only needed if you use the Vercel adapter.
interface FSMConfig<S extends string = string> {
initialState: S
states: readonly S[]
tools: Partial<Record<S, string[]>>
loopShield?: {
enabled: boolean
maxConsecutiveTools: number
mode?: 'consecutive' | 'repeated'
fallbackState?: S
onLoop?: (info: { consecutiveTools: number; maxAllowed: number }) => void
}
onTransition?: (from: S, to: S) => void
guard?: (from: S, to: S, context: Record<string, unknown>) => boolean | Promise<boolean>
prompts?: Partial<Record<S, string>>
snapshot?: { state: S }
context?: Record<string, unknown>
}
interface FSMPublic<S extends string = string> {
currentState: S
currentStateGroup: string
allowedTools: string[]
isLooping: boolean
transitionTo(state: S): void
transitionToAsync(state: S): Promise<void>
snapshot(): { state: S }
toMermaid(): string
context: Record<string, unknown>
}prompts is stored metadata. The runtime does not inject it automatically; your application decides how to use it.
onTransition runs after the state is committed and is synchronous. If it throws, the error propagates but the new state remains. Use guard to veto a transition. Do not use an async function for persistence; the runtime does not await it.
validateWith() only validates schemas with safeParse (Zod/Valibot). A schema without safeParse returns { ok: true } without validating. allowedTools returns the configured array; treat it as read-only.
ToolEntry:
interface ToolEntry<TContext = any> {
name: string
gates: string[]
condition?: (ctx: TContext) => boolean
build: (ctx: TContext) => unknown // provider-specific tool shape
}For AI SDK 7, build() should return a Tool with inputSchema and execute. You can type it with satisfies Tool in your own code.
examples/ai-sdk-7—generateText+MockLanguageModelV3, state change and active tools.examples/native-ai-sdk— the same flow using only AI SDK 7prepareStep+activeTools.
Both run without provider credentials.
No. It controls which tools are advertised to the model. Your application must validate arguments and enforce permissions.
It's a minimal state container with guards, not a statechart engine. Any valid state can transition to any other state unless a guard blocks it.
No. The maintained adapter targets AI SDK 7.
No. It is an admission budget between steps. Multiple calls inside one step can exceed it.
The contract test uses generateText; streamText is not covered yet.
No. snapshot() stores only the current state. Context and budget are reconstructed by your application.
pnpm install
pnpm build
pnpm test
pnpm typecheck
pnpm validate:package
pnpm docs:buildMIT