v2.4 hardens three surfaces. Sessions gain optimistic locking (version + SessionConflictError) and user-defined schema versioning (schemaVersion + migrateSession). The provider layer is consolidated: every AiProvider must declare capabilities, terminal failures are normalized into ProviderError, and the new OpenAICompatibleProvider base class is exported. The type surface tightens: generic defaults move from any to unknown, and a handful of internals leave the public barrel.
If you only use the built-in providers and adapters, the upgrade is usually zero-code: the new version column is added automatically (SQLite/PostgreSQL) or needs no schema at all (Memory, Mongo, Redis, OpenSearch), and pre-2.4 rows are accepted without conflict. The sections below cover the cases that do need a change.
AiProvider.capabilities: ProviderCapabilities is now a required member. Every custom provider must declare its five static capability flags; the built-ins already do.
typescript
import type { AiProvider, ProviderCapabilities } from "@falai/agent";
class MyProvider implements AiProvider {
readonly name = "my-provider";
// New in v2.4 — required
readonly capabilities: ProviderCapabilities = {
supportsTools: true,
supportsNativeJsonSchema: true,
supportsStreaming: true,
supportsStreamingToolCalls: true,
supportsPromptCaching: false,
};
// generateMessage / generateMessageStream as before
}
If your provider wraps an OpenAI-compatible API (Groq, Together, Fireworks, …), consider subclassing the new exported OpenAICompatibleProvider base class instead of implementing AiProvider from scratch — it supplies message building, tool-call parsing, streaming, retries, backup models, and error normalization.
The capability values for the five built-in providers are documented in the providers reference.
2. Provider terminal errors are now ProviderError
Terminal provider failures — i.e. after retries and backup models (if any) are exhausted — now throw ProviderError (exported) with a normalized code instead of rethrowing the raw SDK error. The original SDK/HTTP error is preserved as cause.
// ─── v2.3: match on raw SDK error shapes ───
try {
await agent.respond(message);
} catch (err) {
if ((err as { status?: number }).status === 429) { /* rate limited */ }
}
// ─── v2.4: match on the normalized code ───
import { ProviderError } from "@falai/agent";
try {
await agent.respond(message);
} catch (err) {
if (err instanceof ProviderError) {
if (err.code === "rate_limited" || err.code === "overloaded") {
// backoff and retry
}
console.error(err.provider, err.code, err.cause); // original SDK error on cause
}
}
Note: when the failure surfaces through agent.respond(...), it is wrapped in ResponseGenerationError like every other turn failure — the ProviderError is then on details.originalError. Code calling a provider directly sees the ProviderError itself.
v2.7 update: this wrapping no longer happens — ProviderError now propagates bare out of respond() (instanceof survives). See the v2.6 → v2.7 guide.
3. Optimistic session locking
SessionState / SessionData carry a version number, incremented on every save. A save with a stale version — another writer persisted the session after this one loaded it (concurrent respond() calls, parallel webhooks, two tabs) — throws the new SessionConflictError instead of silently overwriting state.
typescript
import { SessionConflictError } from "@falai/agent";
function isSessionConflict(err: unknown): boolean {
if (err instanceof SessionConflictError) return true;
// respond() wraps turn failures in ResponseGenerationError —
// the conflict is then on details.originalError
if (err instanceof Error && err.name === "ResponseGenerationError") {
const details = (err as { details?: { originalError?: unknown } }).details;
return details?.originalError instanceof SessionConflictError;
}
return false;
}
try {
await agent.respond({ history, session });
} catch (err) {
if (isSessionConflict(err)) {
// Reload the session and retry — another writer won the race.
const fresh = await agent.session.getOrCreate(sessionId);
return agent.respond({ history, session: fresh });
}
throw err;
}
SessionConflictError carries sessionId, expectedVersion, and actualVersion. Recommended handling: reload the session, retry the operation.
v2.7 update: the unwrapping fallback below is no longer needed — SessionConflictError (like ProviderError) now propagates bare out of respond(). The direct instanceof check is sufficient. See the v2.6 → v2.7 guide.
What you need to migrate, per adapter
Adapter
Action
MemoryAdapter
Nothing.
MongoAdapter
Nothing — documents gain version on next save.
RedisAdapter
Nothing — version rides inside the JSON value.
OpenSearchAdapter
Nothing — version is a document field.
SQLiteAdapter
Nothing — initialize() auto-adds the version column.
PostgreSQLAdapter
Nothing — initialize() auto-adds the version column.
PrismaAdapter
Add version Int? to your session model (see below).
Rows written by pre-2.4 versions have no stored version and are accepted without conflict — there is no backfill to run. The first v2.4 save stamps them.
Prisma
text
model AgentSession {
id String @id
// ...
pendingDirective Json?
signals Json?
+ version Int?
}
Run npx prisma migrate dev --name v2-4-session-version. Without the column, the adapter detects its absence on the first write and degrades gracefully — everything works, but optimistic locking stays inactive until you add it.
Same-process concurrency
Concurrent saves of one session from the same process are serialized through a per-session queue and never conflict with each other. SessionConflictError only fires for genuinely independent copies — two processes, or two separately loaded sessions.
Compare-and-swap (recommended). When options.expectedVersion is provided, throw SessionConflictError if the stored version differs (rows with no stored version are accepted), and increment version by one on every successful update. See MemoryAdapter for the reference implementation.
Ignore it. Don't read options at all — your store simply opts out of optimistic locking, exactly like pre-2.4 behavior.
5. Generic defaults: any → unknown
The default type parameters on Agent, Tool, ToolContext, ToolResult, and ToolHandler changed from any to unknown. ToolHistoryItem.content is also unknown (was any).
Typed code is unaffected — if you pass explicit generics or let inference flow from schema, nothing changes. Untyped tool code that relied on implicit any may now need explicit type parameters or a type guard:
typescript
// ─── v2.3: compiled because TData defaulted to any ───
const tool: Tool = {
id: "lookup",
handler: async (ctx) => {
return ctx.data.orderId.trim(); // ctx.data was any
},
};
// ─── v2.4: declare the generics… ───
const tool: Tool<MyContext, MyData> = {
id: "lookup",
handler: async (ctx) => {
return ctx.data.orderId?.trim();
},
};
// ─── …or narrow the unknown ───
handler: async (ctx) => {
const data = ctx.data as Partial<MyData>;
return data.orderId?.trim();
},
6. SignalFiring.directive is now ResolvedSignalDirective
SignalFiring.directive was typed SignalDirective; it is now ResolvedSignalDirective (exported). The difference: replyWith has already been resolved onto reply and stripped by the time a firing reaches the response surface — which was already the runtime behavior; the type now says so.
typescript
// ─── v2.3 ───
for (const firing of response.triggeredSignals ?? []) {
firing.directive?.replyWith; // typed as present, never was at runtime
}
// ─── v2.4 ───
for (const firing of response.triggeredSignals ?? []) {
firing.directive?.reply; // resolved reply text, if any
}
7. Removed from the public barrel
These internals are no longer exported. They locked the architecture into semver and had no supported external use:
Removed export
Kind
Replacement
DirectiveChainTracker
class
Internal — remove direct imports.
DirectiveChainEntry
type
Internal — remove direct imports.
StreamingToolExecutor
class
Internal — remove direct imports.
If you imported any of these, the conversation-control surface you want is Directive, agent.dispatch(), and the documented hooks.
8. ResponsePipeline stored-state API removed
ResponsePipeline (internal, but reachable in v2.3 via subclassing tricks) no longer holds mutable turn state. Removed:
Context and session are now passed explicitly through the pipeline; determineNextStep takes a required context parameter. If you depended on these, pass state explicitly instead of reading it back from the pipeline.
9. Behavioral changes to be aware of
Not breaking in the type sense, but observable at runtime:
session.data is the single source of truth for collected data. The bidirectional sync between the Agent's internal copy and the session is gone. agent.getCollectedData() / agent.getData() read from the live session; agent.updateCollectedData() writes into it. Data set before any session exists (including initialData) is staged and seeds the first created session; loading an existing session discards staged data in favor of the stored state.
Passing an explicit session to respond() no longer merges the managed session's data into it. That was cross-session state leakage; sessions you pass in are now used as-is.
Failed-turn rollback. If respond() / stream() throws mid-turn, the in-memory session is restored to its pre-turn snapshot (the user message added by chat() / stream() before the turn is retained). Persisted state is from the previous turn — a failed turn no longer leaves a partially mutated session.
Deterministic compaction. When compaction is configured, it now runs at end-of-turn finalize on every respond() / chat() / stream(). Previously it only ran inside session.addMessage(), so respond-only integrations grew history unboundedly.
Verification
After migrating, confirm no legacy references remain. Run from your repo root:
Expected output: zero matches. Then run the type checker — it will flag missing capabilities on custom providers, the new update() signature on custom repositories, and any implicit-any tool code:
bash
npx tsc --noEmit
# or
bun run typecheck
For Prisma users: confirm the version Int? column exists if you want locking active.