@genkit-ai/ai
Version:
Genkit AI framework generative AI APIs.
1,375 lines (1,266 loc) • 62.9 kB
text/typescript
/**
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
GenkitError,
deepEqual,
defineAction,
defineBidiAction,
getContext,
run,
z,
type Action,
type ActionContext,
type ActionFnArg,
type BidiAction,
} from '@genkit-ai/core';
import { Channel } from '@genkit-ai/core/async';
import type { Registry } from '@genkit-ai/core/registry';
import {
createAgentAPI,
type AgentAPI,
type AgentTransport,
type SnapshotLookup,
} from './agent-core.js';
import { parseSchema, toJsonSchema } from '@genkit-ai/core/schema';
import {
setCustomMetadataAttribute,
setCustomMetadataAttributes,
} from '@genkit-ai/core/tracing';
import {
AgentAbortRequestSchema,
AgentAbortResponseSchema,
AgentInitSchema,
AgentInputSchema,
AgentOutputSchema,
AgentStreamChunkSchema,
GetSnapshotRequestSchema,
type AgentInit,
type AgentInput,
type AgentResult,
type AgentStreamChunk,
} from './agent-types.js';
import { generateStream } from './generate.js';
import { diff, type JsonPatch } from './json-patch.js';
import { MessageData } from './model-types.js';
import { type ToolRequestPart, type ToolResponsePart } from './parts.js';
import {
definePrompt,
type PromptAction,
type PromptConfig,
} from './prompt.js';
import { InMemorySessionStore } from './session-stores.js';
import {
Session,
SessionSnapshot,
SessionSnapshotSchema,
SessionState,
SessionStore,
reserveSnapshotId,
runWithSession,
type AgentFinishReason,
type Artifact,
type SessionSnapshotInput,
type SessionStoreOptions,
} from './session.js';
// Re-export the shared agent/session wire schemas + types from their canonical
// home (./agent-types.ts) so existing imports from './agent.js' (and the
// package barrel) keep working.
export {
AgentAbortRequestSchema,
AgentAbortResponseSchema,
AgentInitSchema,
AgentInputSchema,
AgentOutputSchema,
AgentResultSchema,
AgentStreamChunkSchema,
GetSnapshotRequestSchema,
JsonPatchOperationSchema,
JsonPatchSchema,
TurnEndSchema,
type AgentInit,
type AgentInput,
type AgentResult,
type AgentStreamChunk,
type TurnEnd,
} from './agent-types.js';
/**
* Default interval (ms) at which a detached (background) turn refreshes its
* pending snapshot's heartbeat. Each beat is a write to the session store.
*/
const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
/**
* Default staleness threshold (ms) after which a `pending` snapshot whose
* heartbeat has not advanced is reported as `expired` on read. Should be
* comfortably larger than {@link DEFAULT_HEARTBEAT_INTERVAL_MS} so a single
* missed beat does not trip expiry.
*/
const DEFAULT_HEARTBEAT_TIMEOUT_MS = 60_000;
/**
* Returns `true` when a snapshot is a `pending` (detached, in-flight) snapshot
* whose heartbeat is older than `timeoutMs` - i.e. its background worker is
* presumed dead. A pending snapshot that has not yet written a first heartbeat
* is not considered expired (the beat may simply not have fired yet).
*/
function isHeartbeatExpired(
snapshot: SessionSnapshot,
timeoutMs: number = DEFAULT_HEARTBEAT_TIMEOUT_MS
): boolean {
if (snapshot.status !== 'pending' || !snapshot.heartbeatAt) {
return false;
}
const last = Date.parse(snapshot.heartbeatAt);
if (Number.isNaN(last)) {
return false;
}
return Date.now() - last > timeoutMs;
}
/**
* Result returned by a single turn handler passed to {@link SessionRunner.run}.
*
* Returning a `finishReason` lets a custom agent explicitly state why the turn
* ended (e.g. `interrupted`, `length`). When omitted, no per-turn reason is
* reported.
*/
export interface TurnResult {
finishReason?: AgentFinishReason;
}
/**
* Per-turn context handed to the handler passed to {@link SessionRunner.run}.
*
* The `snapshotId` is *reserved at turn start* (before the handler runs) and is
* the id the snapshot persisted at turn end will reuse. This lets a handler
* name external, snapshot-correlated resources - e.g. a git branch / worktree
* named after the snapshot - up front, then commit them under that id, so a
* later rollback to the snapshot can restore the external state too.
*/
export interface TurnContext {
/**
* The id the snapshot produced by this turn will be saved under (reserved
* ahead of time so it is known before the turn runs).
*/
snapshotId: string;
/**
* The id of the parent snapshot this turn continues from, or `undefined` on
* the first turn of a fresh session.
*/
parentSnapshotId?: string;
/** Zero-based index of this turn within the current invocation. */
turnIndex: number;
}
/**
* Output returned at turn completion.
*/
export interface AgentOutput<S = unknown> {
sessionId?: string;
artifacts?: Artifact[];
message?: MessageData;
snapshotId?: string;
state?: SessionState<S>;
finishReason?: AgentFinishReason;
/**
* Present when `finishReason` is `failed`. Carries the original error
* details (RuntimeError shape); `state`/`snapshotId` hold the last-good state.
*/
error?: {
status?: string;
message: string;
details?: any;
};
}
/**
* Structured error details surfaced on the failure path.
*/
interface AgentErrorDetails {
status: string;
message: string;
details?: any;
}
/**
* Normalizes a thrown value into the structured error shape used across the
* agent (in `AgentOutput.error` and `SessionRunner.lastTurnError`).
*/
function toErrorDetails(e: any): AgentErrorDetails {
return {
status: e?.status || 'INTERNAL',
message: e?.message || 'Internal failure',
// Only surface explicitly-provided structured details. Never fall back to
// the raw thrown value: it is serialized over the wire (AgentOutput.error)
// and persisted into snapshots, so leaking it could expose stack traces /
// internal state, or break JSON.stringify on circular error objects.
details: e?.detail ?? e?.details,
};
}
/**
* Builds an abort-aware `saveSnapshot` mutator: it skips the write (returns
* `null`) when the current snapshot was concurrently aborted, otherwise writes
* `input`. This prevents a "done"/"failed" write from clobbering an "aborted"
* status set by a concurrent abort.
*/
function abortAwareMutator<S>(input: SessionSnapshotInput<S>) {
return (current: SessionSnapshot<S> | undefined) =>
current?.status === 'aborted' ? null : input;
}
/**
* Asserts that an operation requiring a persistent store is not being invoked
* on a store-less (client-managed) agent.
*/
function requireStore<S>(
store: SessionStore<S> | undefined,
operation: string,
agentName: string
): asserts store is SessionStore<S> {
if (!store) {
throw new GenkitError({
status: 'FAILED_PRECONDITION',
message: `${operation} requires a persistent store. Provide a 'store' when defining '${agentName}'.`,
});
}
}
/**
* Sets a snapshot's status to `aborted` (unless it already reached a terminal
* state) and returns its previous status, or `undefined` when the snapshot
* does not exist.
*/
async function abortSnapshotInStore<S>(
store: SessionStore<S>,
snapshotId: string,
options?: SessionStoreOptions
): Promise<SessionSnapshot['status'] | undefined> {
let previousStatus: SessionSnapshot['status'] | undefined;
await store.saveSnapshot(
snapshotId,
(current) => {
if (!current) return null;
previousStatus = current.status;
if (
current.status === 'completed' ||
current.status === 'failed' ||
current.status === 'aborted'
) {
return null; // Already terminal - don't override.
}
return { ...current, status: 'aborted' };
},
options
);
return previousStatus;
}
/**
* Executor responsible for running turns over input streams and persisting state.
*/
export class SessionRunner<State = unknown> {
readonly session: Session<State>;
readonly inputCh: AsyncIterable<AgentInput>;
turnIndex: number = 0;
public onEndTurn?: (
snapshotId?: string,
finishReason?: AgentFinishReason
) => void;
public onDetach?: (snapshotId: string) => void;
public newSnapshotId?: string;
/** The finish reason of the most recently completed turn. */
public lastTurnFinishReason?: AgentFinishReason;
/**
* Error details of the most recent failed turn. Set when a turn throws and
* the runner resolves gracefully instead of propagating the exception.
*/
public lastTurnError?: AgentErrorDetails;
/**
* The state the most recently *successful* turn left behind. On a failed
* turn this is the state the failed turn started with - the last-good state
* returned to the caller (for client-managed agents).
*/
public lastGoodState?: SessionState<State>;
/**
* The snapshotId of the most recently *successful* (persisted, `done`) turn.
* On a failed turn this is the last-good snapshot the caller resumes from;
* `undefined` when no turn has succeeded yet (e.g. a first-turn failure).
*/
public lastGoodSnapshotId?: string;
private lastSnapshot?: SessionSnapshot<State>;
private lastSnapshotVersion: number = 0;
private store?: SessionStore<State>;
public isDetached: boolean = false;
/**
* Aborts in-flight turns. When set and aborted, a turn that rejects out of
* `generate` is reported as `aborted` (not `failed`) and its failed snapshot
* write is skipped (the abort path already persisted the `aborted` status).
*/
private abortSignal?: AbortSignal;
/**
* True until the first `customPatch` chunk of the current turn has been
* emitted. The first patch of every turn is a whole-document replace
* (re-basing clients that may not share the server's baseline); reset to
* `true` at the start of each turn.
*/
public firstCustomPatchInTurn: boolean = true;
constructor(
session: Session<State>,
inputCh: AsyncIterable<AgentInput>,
options?: {
lastSnapshot?: SessionSnapshot<State>;
store?: SessionStore<State>;
abortSignal?: AbortSignal;
onEndTurn?: (
snapshotId?: string,
finishReason?: AgentFinishReason
) => void;
onDetach?: (snapshotId: string) => void;
}
) {
this.session = session;
this.inputCh = inputCh;
this.lastSnapshot = options?.lastSnapshot;
this.store = options?.store;
this.abortSignal = options?.abortSignal;
this.onEndTurn = options?.onEndTurn;
this.onDetach = options?.onDetach;
// Seed the last-good state with the initial session state so that a
// failure on the very first turn still has a valid state to fall back to
// (the seed/loaded state, excluding the failed turn's mutations). The
// last-good snapshotId is undefined until a turn successfully persists.
this.lastGoodState = this.session.getState();
this.lastGoodSnapshotId = options?.lastSnapshot?.snapshotId;
}
// ── Session delegate methods ────────────────────────────────────────
// These forward to `this.session` so callers can write `sess.addMessages()`
// instead of the verbose `sess.session.addMessages()`.
/** Returns a deep copy of the current session state. */
getState(): SessionState<State> {
return this.session.getState();
}
/** Retrieves all messages associated with the session. */
getMessages(): MessageData[] {
return this.session.getMessages();
}
/** Appends messages to the session. */
addMessages(messages: MessageData[]): void {
this.session.addMessages(messages);
}
/** Overwrites the session messages. */
setMessages(messages: MessageData[]): void {
this.session.setMessages(messages);
}
/** Retrieves the custom state of the session. */
getCustom(): State | undefined {
return this.session.getCustom();
}
/** Updates the custom state using a mutator function. */
updateCustom(fn: (custom?: State) => State): void {
this.session.updateCustom(fn);
}
/** Retrieves the list of artifacts generated during the session. */
getArtifacts(): Artifact[] {
return this.session.getArtifacts();
}
/** Adds artifacts to the session, deduplicating by name. */
addArtifacts(artifacts: Artifact[]): void {
this.session.addArtifacts(artifacts);
}
/** Invokes the end-of-turn callback, absorbing errors from a closed stream. */
private notifyEndTurn(
snapshotId: string | undefined,
finishReason?: AgentFinishReason
): void {
try {
this.onEndTurn?.(snapshotId, finishReason);
} catch {
// Stream was closed, absorb exception.
}
}
/**
* Executes the flow handler against incoming input messages sequentially.
*
* The handler receives the turn's {@link AgentInput} and a {@link TurnContext}
* whose `snapshotId` is *reserved up front* - it is the id the snapshot
* persisted at turn end will reuse. This lets a handler set up external,
* snapshot-correlated state (e.g. a git branch/worktree named after the
* snapshot) before generating, then commit it under that id.
*
* The handler may return a {@link TurnResult} carrying an explicit
* `finishReason` for the just-completed turn. When omitted, no per-turn
* reason is reported. Failures always report `failed`.
*/
async run(
fn: (input: AgentInput, ctx: TurnContext) => Promise<TurnResult | void>
): Promise<void> {
for await (const input of this.inputCh) {
if (input.message) {
this.session.addMessages([input.message]);
}
// The first customPatch of every turn is a whole-document replace that
// re-bases clients which may not share the server's baseline.
this.firstCustomPatchInTurn = true;
const parentSnapshotId = this.lastSnapshot?.snapshotId;
// Reserve the turn's snapshotId up front (when a store is configured) so
// the handler can name snapshot-correlated external resources before the
// turn runs. The detach path may have already reserved one; reuse it.
// The persisted snapshot at turn end reuses this id (maybeSnapshot
// prefers `newSnapshotId`).
if (this.store && !this.newSnapshotId) {
this.newSnapshotId = reserveSnapshotId();
}
const turnSnapshotId = this.newSnapshotId;
this.newSnapshotId = undefined;
const turnContext: TurnContext = {
snapshotId: turnSnapshotId!,
parentSnapshotId,
turnIndex: this.turnIndex,
};
try {
await run(`runTurn-${this.turnIndex + 1}`, input, async () => {
const turnResult = await fn(input, turnContext);
const finishReason = turnResult?.finishReason;
this.lastTurnFinishReason = finishReason;
this.lastTurnError = undefined;
const snapshotId = await this.maybeSnapshot(
'completed',
undefined,
turnSnapshotId,
finishReason
);
// Capture the state this successful turn produced. This becomes the
// last-good state to fall back to if a later turn fails, and its
// snapshotId is the last-good snapshot a failed turn resumes from.
this.lastGoodState = this.session.getState();
this.lastGoodSnapshotId = snapshotId;
// Tag the turn span with the snapshotId this turn persisted under, so
// a trace can correlate the turn with its snapshot (server-managed
// agents only; client-managed turns have no snapshotId).
if (snapshotId) {
setCustomMetadataAttribute('agent:snapshotId', snapshotId);
}
this.notifyEndTurn(snapshotId, finishReason);
// The turn span's output is the session state this turn produced -
// applies to both client- and server-managed agents.
return { state: this.session.getState() };
});
this.turnIndex++;
} catch (e: any) {
// An aborted turn rejects out of `generate` and lands here. Treat it as
// `aborted` rather than `failed`: the abort path already persisted the
// `aborted` status (the abort-aware mutator would skip a `failed` write
// anyway), so we record the finish reason and skip the failed snapshot
// write entirely instead of reporting a spurious error.
if (this.abortSignal?.aborted) {
this.lastTurnFinishReason = 'aborted';
this.lastTurnError = undefined;
this.notifyEndTurn(this.lastSnapshot?.snapshotId, 'aborted');
break;
}
this.lastTurnFinishReason = 'failed';
this.lastTurnError = toErrorDetails(e);
const snapshotId = await this.maybeSnapshot(
'failed',
this.lastTurnError,
turnSnapshotId,
'failed'
);
this.notifyEndTurn(snapshotId, 'failed');
// Graceful failure: rather than propagating the exception (which would
// discard the action's final return - and with it the last-good state
// and all prior successful turns), stop processing further inputs and
// let the invocation resolve with `finishReason: 'failed'`. The caller
// recovers the last-good state from the returned AgentOutput.
break;
}
}
}
/**
* Saves a snapshot of the current session state to the persistent store.
*
* When a store is configured every turn is persisted (snapshotting is no
* longer opt-out). Uses the mutator-based `saveSnapshot` to atomically check
* that the snapshot has not been concurrently aborted before writing -
* preventing a race where a "done" write could overwrite a concurrent
* "aborted" status.
*/
async maybeSnapshot(
status?: 'pending' | 'completed' | 'failed',
error?: { status?: string; message: string; details?: any },
snapshotId?: string,
finishReason?: AgentFinishReason
): Promise<string | undefined> {
if (
!this.store ||
(this.isDetached && snapshotId !== this.lastSnapshot?.snapshotId)
)
return this.lastSnapshot?.snapshotId;
const currentVersion = this.session.getVersion();
if (currentVersion === this.lastSnapshotVersion && !status) {
return this.lastSnapshot?.snapshotId;
}
const currentState = this.session.getState();
const snapshotInput: SessionSnapshotInput<State> = {
...(snapshotId || this.newSnapshotId
? { snapshotId: (snapshotId || this.newSnapshotId)! }
: {}),
// Stamp the session id onto every snapshot in the chain so callers can
// resolve a snapshot's session without reaching into its state.
sessionId: this.session.sessionId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
state: currentState as SessionState<State>,
parentId: this.lastSnapshot?.snapshotId,
// Default to a resumable `completed` status. The only caller that omits a
// status is the post-invocation write (which fires when the handler
// mutates state after the last turn); persisting it as `completed` keeps
// it a valid resume target under the "only `completed` is resumable" rule.
status: status ?? 'completed',
// Stamp an initial heartbeat on a `pending` (detached, in-flight)
// snapshot. A background heartbeat loop refreshes it; if it goes stale the
// snapshot is reported as `expired` on read (the worker is presumed dead).
...(status === 'pending' && { heartbeatAt: new Date().toISOString() }),
...(finishReason && { finishReason }),
error,
};
const effectiveId = snapshotId || this.newSnapshotId;
// Use the mutator-based saveSnapshot to atomically check the current
// status before writing. If the snapshot was concurrently aborted,
// the mutator returns null and the write is skipped.
const assignedId = await this.store.saveSnapshot(
effectiveId,
abortAwareMutator(snapshotInput),
{ context: getContext() }
);
if (assignedId === null) {
// Snapshot was aborted concurrently; preserve the existing ID
// without overwriting.
return effectiveId;
}
this.lastSnapshot = { ...snapshotInput, snapshotId: assignedId };
this.lastSnapshotVersion = currentVersion;
return assignedId;
}
}
/**
* Projects an agent's server-side data onto the view a client should see.
*
* Every member is optional; an omitted member passes the corresponding data
* through unchanged. Use this to redact sensitive fields or reshape data
* before it leaves the server - covering both data at rest and data in flight:
*
* - `state` reshapes/redacts session state at rest. Applied to
* `AgentOutput.state` (client-managed agents), to snapshots returned by
* `getSnapshotData`, and as the baseline for streamed `customPatch` diffs
* (so streamed custom-state deltas stay consistent with the transformed
* full state). Note: `state.artifacts` is part of session state, so artifact
* redaction at rest happens here too.
* - `chunk` reshapes/redacts each stream chunk in flight (`modelChunk`,
* `artifact`, `customPatch`, `turnEnd`) - e.g. filtering "internal" tool
* request/response parts out of model chunks, or redacting streamed
* artifacts. Return `null`/`undefined` to drop the chunk entirely.
*
* When both `state` and `chunk` touch the same data (e.g. artifacts), keeping
* the two projections consistent is the author's responsibility.
*/
export interface ClientTransform<S = unknown> {
/**
* Reshapes/redacts session state before it is exposed to the client (at
* rest: `AgentOutput.state`, snapshots, and the streamed `customPatch`
* baseline).
*/
state?: (state: SessionState<S>) => SessionState;
/**
* Reshapes/redacts each stream chunk before it is sent to the client.
* Return `null`/`undefined` to drop the chunk entirely.
*/
chunk?: (chunk: AgentStreamChunk) => AgentStreamChunk | null | undefined;
}
/**
* Function handler definition for custom agent actions.
*/
export type AgentFn<State> = (
sess: SessionRunner<State>,
options: {
sendChunk: (chunk: AgentStreamChunk) => void;
abortSignal?: AbortSignal;
context?: ActionContext;
}
) => Promise<AgentResult>;
/**
* Lookup input for the `getSnapshotData` action / method.
*
* Mirrors {@link GetSnapshotOptions}: provide exactly one of `snapshotId`
* (an exact snapshot) or `sessionId` (the session's latest leaf snapshot).
*/
export const GetSnapshotDataInputSchema = z.object({
snapshotId: z.string().optional(),
sessionId: z.string().optional(),
});
/**
* Lookup input for `getSnapshotData`.
*/
export interface GetSnapshotDataInput {
snapshotId?: string;
sessionId?: string;
context?: ActionContext;
}
export type GetSnapshotDataAction<S = unknown> = Action<
typeof GetSnapshotDataInputSchema,
z.ZodType<SessionSnapshot<S>>
>;
/**
* Represents a configured, registered Agent.
*
* An `Agent` exposes two surfaces:
*
* 1. The ergonomic, transport-agnostic {@link AgentAPI} (`chat`, `loadChat`,
* `getSnapshot`, `abort`) - the same surface returned by `remoteAgent` on
* the client, so server- and client-side code share one interface.
* 2. The lower-level {@link BidiAction} surface (`run`, `streamBidi`, …) for
* advanced use and for serving over HTTP.
*/
export interface Agent<State = unknown>
extends BidiAction<
typeof AgentInputSchema,
typeof AgentOutputSchema,
typeof AgentStreamChunkSchema,
typeof AgentInitSchema
>,
AgentAPI<State> {
getSnapshotData(
opts: GetSnapshotDataInput
): Promise<SessionSnapshot<State> | undefined>;
abort(
snapshotId: string,
options?: SessionStoreOptions
): Promise<SessionSnapshot['status'] | undefined>;
readonly getSnapshotDataAction: GetSnapshotDataAction<State>;
readonly abortAgentAction: Action<
typeof AgentAbortRequestSchema,
typeof AgentAbortResponseSchema
>;
}
/**
* Error thrown for agent init *API misuse* that should surface to the caller as
* a real, thrown error (mapped to an HTTP status by the server handler) rather
* than being absorbed into a graceful `finishReason: 'failed'` result.
*
* Covers calling an agent with an init that does not match its state-management
* mode (e.g. sending `state` to a server-managed agent, or `snapshotId`/
* `sessionId` to a client-managed one) and the snapshot/session ownership
* guard. Other pre-turn failures (missing snapshot, non-resumable snapshot,
* invalid custom state) remain graceful.
*/
export class AgentInitError extends GenkitError {}
/**
* Asserts that the init strategy matches the agent's state-management mode,
* throwing an {@link AgentInitError} on a mismatch.
*
* Server-managed agents (with a store) resume via a `snapshotId` / `sessionId`;
* client-managed agents (no store) supply the full `state` blob. This is API
* misuse, so it propagates as a thrown error rather than a graceful failure.
*/
function assertInitMatchesStateManagement(
config: { name: string; store?: SessionStore<unknown> },
init: AgentInit | undefined
): void {
if ((init?.snapshotId || init?.sessionId) && !config.store) {
throw new AgentInitError({
status: 'FAILED_PRECONDITION',
message:
`Cannot use '${init.snapshotId ? 'snapshotId' : 'sessionId'}' with ` +
`agent '${config.name}': this agent has no store configured ` +
`(client-managed state). Send 'state' instead.`,
});
}
if (init?.state && config.store) {
throw new AgentInitError({
status: 'FAILED_PRECONDITION',
message:
`Cannot send 'state' to agent '${config.name}': this agent uses ` +
`a server-managed store. Send 'snapshotId' or 'sessionId' instead.`,
});
}
}
/**
* Resolves the {@link Session} (and originating snapshot, if any) for an agent
* turn from its {@link AgentInit}.
*
* Server-managed agents (with a store) resume via a `snapshotId` (an exact
* snapshot) or a `sessionId` (the session's latest snapshot); client-managed
* agents (no store) supply the full `state` blob. Throws a {@link GenkitError}
* on a missing snapshot, non-resumable snapshot, or invalid custom state - the
* caller is expected to translate that into a graceful `finishReason: 'failed'`
* result. The state-management mismatch checks are performed up front by
* {@link assertInitMatchesStateManagement} and throw {@link AgentInitError}.
*/
async function resolveSession<State>(
config: { name: string; store?: SessionStore<State> },
store: SessionStore<State>,
init: AgentInit | undefined,
validateCustomState: (custom: unknown) => void
): Promise<{ session: Session<State>; snapshot?: SessionSnapshot<State> }> {
if (init?.snapshotId) {
const snapshot = await store.getSnapshot({
snapshotId: init.snapshotId,
context: getContext(),
});
if (!snapshot) {
throw new GenkitError({
status: 'NOT_FOUND',
message: `Snapshot ${init.snapshotId} not found`,
});
}
// When both `snapshotId` and `sessionId` are supplied, `snapshotId` selects
// the exact snapshot to resume and `sessionId` acts as an ownership guard:
// the snapshot must belong to that session. A mismatch is API misuse, so it
// propagates as a thrown error (AgentInitError) rather than being absorbed
// into a graceful failure.
// Prefer the snapshot's top-level `sessionId`; fall back to the id carried
// in its state for rows written before snapshot-level ids existed.
const snapshotSessionId = snapshot.sessionId ?? snapshot.state?.sessionId;
if (init.sessionId && snapshotSessionId !== init.sessionId) {
throw new AgentInitError({
status: 'INVALID_ARGUMENT',
message:
`Snapshot ${init.snapshotId} does not belong to session ` +
`${init.sessionId} (it belongs to ` +
`${snapshotSessionId ?? 'an unknown session'}).`,
});
}
// Only `completed` snapshots are resumable. A failed/aborted/pending
// snapshot is persisted for inspection but is not a valid resume target.
if (snapshot.status !== 'completed') {
throw new GenkitError({
status: 'INVALID_ARGUMENT',
message:
`Snapshot ${init.snapshotId} is not resumable (status: ` +
`${snapshot.status ?? 'unknown'}). Only 'completed' snapshots can ` +
`be resumed.`,
});
}
validateCustomState(snapshot.state?.custom);
return {
snapshot,
session: new Session<State>(snapshot.state as SessionState<State>),
};
}
if (init?.sessionId) {
// Resume the session's latest snapshot. The store returns the latest leaf
// regardless of status, but only `completed` snapshots are resumable - so
// if the leaf is a non-resumable turn (e.g. a `failed`/`aborted`/`pending`
// turn) walk back over its parent chain to the last-good (`completed`)
// snapshot. When the session has no resumable snapshot (e.g. a first-turn
// failure) seed a fresh session bound to the requested sessionId so
// subsequent turns can find it.
let snapshot = await store.getSnapshot({
sessionId: init.sessionId,
context: getContext(),
});
// Walk back over non-resumable leaves to the last-good (`completed`)
// snapshot. Guard against a self-referential or cyclic `parentId` chain
// (corrupt history) with a visited set so we fail fast with
// `FAILED_PRECONDITION` instead of looping forever on store reads.
const visited = new Set<string>();
while (snapshot && snapshot.status !== 'completed') {
if (visited.has(snapshot.snapshotId)) {
throw new GenkitError({
status: 'FAILED_PRECONDITION',
message:
`Session '${init.sessionId}' has a cyclic snapshot parent chain ` +
`(snapshot '${snapshot.snapshotId}' was visited twice). Resume by ` +
`snapshotId instead.`,
});
}
visited.add(snapshot.snapshotId);
snapshot = snapshot.parentId
? await store.getSnapshot({
snapshotId: snapshot.parentId,
context: getContext(),
})
: undefined;
}
if (snapshot) {
validateCustomState(snapshot.state?.custom);
return {
snapshot,
session: new Session<State>(snapshot.state as SessionState<State>),
};
}
return {
session: new Session<State>({
custom: undefined,
artifacts: [],
messages: [],
sessionId: init.sessionId,
}),
};
}
if (init?.state && !config.store) {
validateCustomState(init.state.custom);
return {
session: new Session<State>(init.state as SessionState<State>),
};
}
return {
session: new Session<State>({
custom: undefined,
artifacts: [],
messages: [],
}),
};
}
/**
* Pumps the action's raw input stream into the runner's input channel while
* intercepting `detach: true` directives.
*
* Running this proxy concurrently lets a detach directive take effect
* immediately rather than waiting for the runner to drain a backlog of
* pre-queued inputs. A detach-only message (no payload) is consumed here and
* not forwarded, since it has no turn to process.
*/
function pipeInputWithDetach<State>(
inputStream: AsyncIterable<AgentInput>,
target: Channel<AgentInput>,
getRunner: () => SessionRunner<State>,
storeEnabled: boolean,
rejectDetach: (reason: any) => void
): void {
(async () => {
try {
for await (const input of inputStream) {
if (input.detach) {
if (!storeEnabled) {
rejectDetach(
new GenkitError({
status: 'FAILED_PRECONDITION',
message:
'Detach is only supported when a session store is provided.',
})
);
} else {
const runner = getRunner();
// Reserve the in-flight snapshot's id up front so the detached
// snapshot and any handler-named external resources share one id.
const turnSnapshotId = runner.newSnapshotId || reserveSnapshotId();
runner.newSnapshotId = turnSnapshotId;
await runner.maybeSnapshot('pending', undefined, turnSnapshotId);
runner.isDetached = true;
if (runner.onDetach) {
runner.onDetach(turnSnapshotId);
}
}
// Only forward to the runner if the input carries a payload beyond
// the detach directive; a detach-only message has no turn to process.
const hasPayload = !!(
input.message ||
input.resume?.restart?.length ||
input.resume?.respond?.length
);
if (hasPayload) {
target.send(input);
}
} else {
target.send(input);
}
}
target.close();
} catch (e) {
target.error(e);
}
})();
}
/**
* Registers a multi-turn custom agent action capable of maintaining persistent state.
*
* When `stateSchema` is provided the custom state is validated at load time
* (from a snapshot store or from the client-supplied `init.state`) and the
* JSON Schema representation is included in the action metadata so that
* tooling (e.g. the Dev UI) can inspect / validate the state shape.
*/
export function defineCustomAgent<State = unknown>(
registry: Registry,
config: {
name: string;
description?: string;
stateSchema?: z.ZodType<State>;
store?: SessionStore<State>;
clientTransform?: ClientTransform<State>;
},
fn: AgentFn<State>
): Agent<State> {
// Helper that applies the optional state transform before exposing state to
// the client. When no transform is configured it returns the raw state.
const toClientState = (
state: SessionState<State>
): SessionState | undefined => {
if (config.clientTransform?.state) {
return config.clientTransform.state(state);
}
return state as SessionState;
};
// If a state schema was provided, pre-compute the JSON schema once so it
// can be embedded in metadata and reused for validation.
const stateJsonSchema = config.stateSchema
? toJsonSchema({ schema: config.stateSchema })
: undefined;
/**
* Validates the `custom` field of a session state against the configured
* `stateSchema`. No-ops when no schema was provided.
*/
const validateCustomState = (custom: unknown): void => {
if (config.stateSchema && custom !== undefined) {
parseSchema(custom, { schema: config.stateSchema });
}
};
const primaryAction = defineBidiAction(
registry,
{
name: config.name,
description: config.description,
actionType: 'agent',
inputSchema: AgentInputSchema,
outputSchema: AgentOutputSchema,
streamSchema: AgentStreamChunkSchema,
initSchema: AgentInitSchema,
metadata: {
agent: {
stateManagement: config.store ? 'server' : 'client',
abortable: !!config.store?.onSnapshotStateChange,
...(stateJsonSchema && { stateSchema: stateJsonSchema }),
},
},
},
async function* (
arg: ActionFnArg<AgentStreamChunk, AgentInput, AgentInit>
) {
const init = arg.init;
const store = config.store || new InMemorySessionStore<State>();
// API-misuse checks (init does not match the agent's state-management
// mode) throw out of the generator so the server handler maps them to a
// proper HTTP status, rather than being absorbed into a graceful
// `finishReason: 'failed'` result below.
assertInitMatchesStateManagement(config, init);
let session!: Session<State>;
let snapshot: SessionSnapshot<State> | undefined;
try {
({ session, snapshot } = await resolveSession<State>(
config,
store,
init,
validateCustomState
));
} catch (e: any) {
// An AgentInitError signals API misuse (e.g. the snapshot/session
// ownership guard) that must surface as a thrown error; re-throw it so
// the server handler maps it to a proper HTTP status.
if (e instanceof AgentInitError) {
throw e;
}
// Other pre-turn / setup failures (missing snapshot, non-resumable
// snapshot, invalid client state). Resolve gracefully with
// `finishReason: 'failed'` - preserving the original `error.status` -
// rather than throwing, so the caller gets a structured, inspectable
// result. There is no last-good turn yet; echo back the
// client-supplied state when present.
return {
finishReason: 'failed' as AgentFinishReason,
error: toErrorDetails(e),
...(!config.store &&
init?.state && { state: init.state as SessionState }),
};
}
// Tag the current trace span with the sessionId so that traces
// belonging to the same agent conversation can be correlated.
setCustomMetadataAttributes({
'agent:sessionId': session.sessionId,
});
let detachedSnapshotId: string | undefined;
let resolveDetach:
| ((value: void | PromiseLike<void>) => void)
| undefined;
let rejectDetach: ((reason: any) => void) | undefined;
const detachPromise = new Promise<void>((resolve, reject) => {
resolveDetach = resolve;
rejectDetach = reject;
});
const abortController = new AbortController();
let unsubscribe: any = undefined;
// Background heartbeat timer for the detached snapshot. Started in
// `onDetach`, cleared when the flow settles (or on abort).
let heartbeatTimer: ReturnType<typeof setInterval> | undefined;
const stopHeartbeat = () => {
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = undefined;
}
};
let runner!: SessionRunner<State>;
// Centralized chunk emitter: every stream chunk passes through here so
// the optional `clientTransform.chunk` can reshape/redact it (or drop it
// by returning a nullish value) before it reaches the client.
//
// The actual dispatch is failure-isolated (like `notifyEndTurn`): the
// artifact/customPatch emitters fire synchronously from inside
// `Session.updateCustom`/`addArtifacts` (i.e. from the user's handler).
// If the client stream is already closed, `sendChunk` throws; absorbing
// it here prevents that from propagating out of the handler and turning
// a normal turn into a `failed` one.
const emitChunk = (chunk: AgentStreamChunk) => {
try {
let toSend: AgentStreamChunk | null | undefined = chunk;
if (config.clientTransform?.chunk) {
toSend = config.clientTransform.chunk(chunk);
}
if (!toSend) return;
arg.sendChunk(toSend);
} catch {
// Stream was closed (or the transform threw); absorb the exception.
}
};
// We construct an asynchronous proxy channel over the inputStream.
// This enables immediate interception of `detach: true` directives. Without this proxy,
// a backlog of pre-queued inputs would have to be resolved sequentially by the runner first.
const runnerInputChannel = new Channel<AgentInput>();
pipeInputWithDetach(
arg.inputStream,
runnerInputChannel,
() => runner,
!!config.store,
(reason) => rejectDetach?.(reason)
);
runner = new SessionRunner<State>(session, runnerInputChannel, {
store,
lastSnapshot: snapshot,
abortSignal: abortController.signal,
onDetach: (snapshotId) => {
detachedSnapshotId = snapshotId;
if (resolveDetach) {
resolveDetach();
}
// Refresh the detached snapshot's heartbeat periodically. The mutator
// only touches a still-`pending` snapshot (returns null otherwise) so
// it never resurrects a terminal snapshot or clobbers a concurrent
// abort. If a read sees this heartbeat go stale, the snapshot is
// reported as `expired` (the worker is presumed dead). `unref` so the
// timer never keeps the process alive on its own.
const ctx = getContext();
heartbeatTimer = setInterval(() => {
void store
.saveSnapshot(
snapshotId,
(current) =>
current?.status === 'pending'
? { ...current, heartbeatAt: new Date().toISOString() }
: null,
{ context: ctx }
)
.catch(() => {
// Best-effort heartbeat; ignore transient store errors.
});
}, DEFAULT_HEARTBEAT_INTERVAL_MS);
heartbeatTimer.unref?.();
if (store.onSnapshotStateChange) {
unsubscribe = store.onSnapshotStateChange(
snapshotId,
(snap) => {
if (snap.status === 'aborted') {
stopHeartbeat();
abortController.abort();
if (unsubscribe) unsubscribe();
}
},
{ context: getContext() }
);
}
},
onEndTurn: (snapshotId, finishReason) => {
if (!runner.isDetached) {
emitChunk({
turnEnd: {
...(config.store && { snapshotId }),
...(finishReason && { finishReason }),
},
});
}
},
});
const sendArtifactChunk = (a: Artifact) => {
if (!runner.isDetached) {
emitChunk({ artifact: a });
}
};
session.on('artifactAdded', sendArtifactChunk);
session.on('artifactUpdated', sendArtifactChunk);
// Auto-emit a `customPatch` chunk whenever custom state is mutated.
// The diff is computed AFTER the clientStateTransform so streamed deltas
// honor redaction and stay consistent with the transformed full state in
// snapshots / final output. The first patch of every turn is a
// whole-document replace (re-basing clients that may lack the baseline);
// subsequent patches are incremental diffs against the last sent value.
let lastSentCustom: unknown;
const sendCustomPatch = () => {
if (runner.isDetached) return;
const transformed = toClientState(session.getState())?.custom;
let patch: JsonPatch;
if (runner.firstCustomPatchInTurn) {
patch = [
{ op: 'replace', path: '', value: structuredClone(transformed) },
];
runner.firstCustomPatchInTurn = false;
} else {
patch = diff(lastSentCustom, transformed);
}
lastSentCustom = structuredClone(transformed);
if (patch.length) {
emitChunk({ customPatch: patch });
}
};
session.on('customChanged', sendCustomPatch);
const sendChunk = (chunk: AgentStreamChunk) => {
if (!runner.isDetached) {
emitChunk(chunk);
}
};
const flowPromise = (async () => {
try {
const result = await runWithSession(registry, session, () =>
fn(runner, {
sendChunk,
abortSignal: abortController.signal,
context: getContext(),
})
);
// After the handler resolves, persist any state it mutated after the
// last turn. Omitting a status defaults to a resumable `completed`
// write, which the version guard skips when nothing changed.
const finalSnapshotId = await runner.maybeSnapshot();
return { result, finalSnapshotId };
} finally {
// The turn has settled (the snapshot reached a terminal status), so
// stop refreshing its heartbeat.
stopHeartbeat();
if (unsubscribe) unsubscribe();
session.off('artifactAdded', sendArtifactChunk);
session.off('artifactUpdated', sendArtifactChunk);
session.off('customChanged', sendCustomPatch);
}
})();
// We race the background flow execution against the detach signal.
// If detachment is requested, we yield output metadata early, but allow
// the flow handler promise to continue its asynchronous completion.
const outcome = await Promise.race([
flowPromise,
detachPromise.then(() => 'detached' as const),
]);
if (outcome === 'detached') {
return {
sessionId: session.sessionId,
snapshotId: detachedSnapshotId!,
finishReason: 'detached' as AgentFinishReason,
...(!config.store && { state: toClientState(session.getState()) }),
};
}
const { result, finalSnapshotId } = outcome;
// A turn failed: resolve gracefully with `finishReason: 'failed'` and the
// last-good state (what the failed turn started with), rather than the
// live state which may hold the failed turn's partial mutations.
if (runner.lastTurnFinishReason === 'failed' && runner.lastTurnError) {
const lastGood = (runner.lastGoodState ??
session.getState()) as SessionState<State>;
const lastGoodMessages = lastGood.messages;
return {
sessionId: session.sessionId,
finishReason: 'failed' as AgentFinishReason,
error: runner.lastTurnError,
...(result.artifacts?.length && { artifacts: result.artifacts }),
...(lastGoodMessages?.length && {
message: lastGoodMessages[lastGoodMessages.length - 1],
}),
// Server-managed: the last successful turn is already persisted (every
// turn is snapshotted), so point at its snapshot. The failed turn's
// own snapshot is persisted too but is not resumable - `sessionId`
// resume skips it back to this last-good `done` snapshot. Undefined on
// a first-turn failure (no successful turn yet; client holds the seed).
...(config.store && { snapshotId: runner.lastGoodSnapshotId }),
// Client-managed: return the last-good state directly.
...(!config.store && { state: toClientState(lastGood) }),
};
}
const finishReason = result.finishReason ?? runner.lastTurnFinishReason;
return {
sessionId: session.sessionId,
...(result.artifacts?.length && { artifacts: result.artifacts }),
...(result.message && { message: result.message }),
...(finishReason && { finishReason }),
...(config.store && { snapshotId: finalSnapshotId }),
...(!config.store && { state: toClientState(session.getState()) }),
};
}
);
// Helper that applies the clientTransform.state projection to a snapshot's
// state, returning a new snapshot object with the transformed state.
const toClientSnapshot = (
snapshot: SessionSnapshot<State>
): SessionSnapshot => {
if (!config.clientTransform?.state || !snapshot.state) {
return snapshot as SessionSnapshot;
}
return {
...snapshot,
state: config.clientTransform.state(snapshot.state),
};
};
// Shared snapshot/abort implementations, reused by both the `defineAction`
// surfaces (which inject the ambient request context) and the ergonomic
// composite methods (which accept caller-supplied options).
const resolveSnapshot = async (
lookup: GetSnapshotDataInput
): Promise<SessionSnapshot | undefined> => {
requireStore(config.store, 'getSnapshotData', config.name);
const snapshot = await config.store.getSnapshot(lookup);
if (!snapshot) return undefined;
// Compute `expired` on read: a `pending` snapshot whose heartbeat has gone
// stale is presumed orphaned (its background worker died), so surface it as
// `expired` rather than leaving it `pending` forever. This is read-only -
// the status is not written back to the store.
const effective = isHeartbeatExpired(snapshot)
? { ...snapshot, status: 'expired' as const }
: snapshot;
return toClientSnapshot(effective);
};
const runAbort = (
snapshotId: string,
options?: SessionStoreOptions
): Promise<SessionSnapshot['status'] | undefined> => {
requireStore(config.store, 'abort', config.name);
return abortSnapshotInStore(config.store, snapshotId, options);
};
const getSnapshotDataAction = defineAction(
registry,
{
name: config.name,
description: `Gets snapshot data for ${config.name} by snapshotId or sessionId`,
actionType: 'agent-snapshot',
inputSchema: GetSnapshotRequestSchema,
outputSchema: SessionSnapshotSchema.optional(),
},
async (lookup) => resolveSnapshot({ ...lookup, context: getContext() })
);
const abortAgentAction = defineAction(
registry,
{
name: config.name,
description: `Aborts ${config.name} agent by snapshotId. Returns the snapshot id and its status after the abort attempt.`,
actionType: 'agent-abort',
inputSchema: AgentAbortRequestSchema,
outputSchema: AgentAbortResponseSchema,
},
async ({ snapshotId }) => {
const status = await runAbort(snapshotId, { context: getContext() });
return { snapsh