v2.3 → v2.4 migration

Version: 2.4.0 — Architecture hardening (concurrency safety, consolidated provider layer, stricter types)

Summary

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.


Table of Contents

  1. Custom providers must declare capabilities
  2. Provider terminal errors are now ProviderError
  3. Optimistic session locking
  4. Custom SessionRepository: update() gains expectedVersion
  5. Generic defaults: any → unknown
  6. SignalFiring.directive is now ResolvedSignalDirective
  7. Removed from the public barrel
  8. ResponsePipeline stored-state API removed
  9. Behavioral changes to be aware of
  10. Verification

1. Custom providers must declare capabilities

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.

typescript
type ProviderErrorCode =
  | 'rate_limited'
  | 'overloaded'
  | 'auth'
  | 'invalid_request'
  | 'schema_rejected'
  | 'timeout'
  | 'network'
  | 'unknown';

class ProviderError extends Error {
  readonly code: ProviderErrorCode;
  readonly provider: string;   // e.g. "openai"
  readonly cause?: unknown;    // original SDK error
}

Before / After

typescript
// ─── 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

AdapterAction
MemoryAdapterNothing.
MongoAdapterNothing — documents gain version on next save.
RedisAdapterNothing — version rides inside the JSON value.
OpenSearchAdapterNothing — version is a document field.
SQLiteAdapterNothing — initialize() auto-adds the version column.
PostgreSQLAdapterNothing — initialize() auto-adds the version column.
PrismaAdapterAdd 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.


4. Custom SessionRepository: update() gains expectedVersion

The SessionRepository.update() signature gained an optional third parameter:

typescript
// ─── v2.3 ───
update(id: string, data: Partial<...>): Promise<SessionData<TData> | null>;

// ─── v2.4 ───
update(
  id: string,
  data: Partial<Omit<SessionData<TData>, "id" | "createdAt">>,
  options?: { expectedVersion?: number }  // SessionUpdateOptions
): Promise<SessionData<TData> | null>;

Two valid implementations:

  • 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 exportKindReplacement
DirectiveChainTrackerclassInternal — remove direct imports.
DirectiveChainEntrytypeInternal — remove direct imports.
StreamingToolExecutorclassInternal — 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:

  • setContext() / setCurrentSession() / getStoredContext() / getCurrentSession()
  • updateDataFlow()

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:

bash
rg -n '\b(DirectiveChainTracker|DirectiveChainEntry|StreamingToolExecutor|updateDataFlow|getStoredContext)\b' \
  --glob '**/*.ts' \
  --glob '!node_modules/**' \
  --glob '!dist/**'

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.

Cross-References

  • CHANGELOG — full v2.4 release notes
  • Persistence — locking and schema-versioning recipes
  • Providers — capabilities matrix and OpenAICompatibleProvider
  • Errors — ProviderError and SessionConflictError