@genkit-ai/ai
Version:
Genkit AI framework generative AI APIs.
334 lines (302 loc) • 10 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 { getAsyncContext, type ActionContext } from '@genkit-ai/core';
import { EventEmitter } from '@genkit-ai/core/async';
import type { Registry } from '@genkit-ai/core/registry';
import {
type Artifact,
type SessionSnapshot,
type SessionState,
} from './agent-types.js';
import { MessageData } from './model-types.js';
// Re-export the shared agent/session wire schemas + types from their canonical
// home (./agent-types.ts) so existing imports from './session.js' keep working.
export {
AgentFinishReasonSchema,
ArtifactSchema,
SessionSnapshotSchema,
SessionStateSchema,
type AgentFinishReason,
type Artifact,
type SessionSnapshot,
type SessionState,
} from './agent-types.js';
/**
* Input type for {@link SessionStore.saveSnapshot}.
*
* Identical to {@link SessionSnapshot} except that `snapshotId` is optional.
* When omitted the store is responsible for assigning a new identifier
* (enabling stores to encode grouping or routing information in the ID).
* When provided the store performs an upsert - updating the existing snapshot.
*/
export type SessionSnapshotInput<S = unknown> = Omit<
SessionSnapshot<S>,
'snapshotId'
> & {
snapshotId?: string;
};
/**
* Options provided to the session store methods.
*/
export interface SessionStoreOptions {
context?: ActionContext;
}
/**
* Lookup options for {@link SessionStore.getSnapshot}.
*
* Exactly one of `snapshotId` or `sessionId` must be provided:
*
* - `snapshotId` loads that specific snapshot.
* - `sessionId` loads the *latest* (leaf) snapshot of the session - the most
* recent snapshot that no other snapshot points to as its parent. This is
* the common case for simple session storage (e.g. `useChat`) where the
* client only tracks a stable session id and lets the server remember the
* conversation. A session with *branching* snapshots (more than one leaf,
* e.g. after a regenerate) has no single "latest"; by default the store
* returns the most-recent leaf, but it can be configured to reject the
* lookup with `FAILED_PRECONDITION` - in which case, resume by `snapshotId`.
*/
export interface GetSnapshotOptions {
snapshotId?: string;
sessionId?: string;
context?: ActionContext;
}
/**
* A function that receives the current snapshot and returns the updated
* snapshot to persist.
*
* - Return the mutated snapshot to save it.
* - Return `null` to silently skip the update (no-op).
* - Throw to abort with an error (e.g. precondition failure).
*/
export type SnapshotMutator<S = unknown> = (
current: SessionSnapshot<S> | undefined
) => SessionSnapshotInput<S> | null;
/**
* Interface for persistent session snapshot storage.
*/
export interface SessionStore<S = unknown> {
/**
* Loads a snapshot either by its `snapshotId` or by `sessionId`.
*
* See {@link GetSnapshotOptions} for the lookup semantics (exactly one of
* `snapshotId` / `sessionId`; `sessionId` resolves to the session's latest
* leaf snapshot, optionally rejecting branching histories).
*/
getSnapshot(
opts: GetSnapshotOptions
): Promise<SessionSnapshot<S> | undefined>;
/**
* Atomically reads the current snapshot (if `snapshotId` is provided),
* passes it to `mutator`, and persists the result.
*
* - When `snapshotId` is provided the store reads the existing snapshot
* and passes it to the mutator. The mutator can inspect the current
* state (e.g. to check for concurrent status changes) and return the
* updated snapshot to save, or `null` to skip the write.
* - When `snapshotId` is `undefined` the store passes `undefined` to
* the mutator (signaling a new snapshot). The store assigns a new
* identifier.
*
* Implementations should ensure the read→mutate→write cycle is atomic
* to prevent race conditions (e.g. a "done" write overwriting a
* concurrent "aborted" status).
*
* The mutator can:
*
* - Return a snapshot to save it.
* - Return `null` to silently skip the write.
* - Throw to abort with an error.
*
* @returns The `snapshotId` that was used, or `null` when the mutator
* returned `null`.
*/
saveSnapshot(
snapshotId: string | undefined,
mutator: SnapshotMutator<S>,
options?: SessionStoreOptions
): Promise<string | null>;
onSnapshotStateChange?(
snapshotId: string,
callback: (snapshot: SessionSnapshot<S>) => void,
options?: SessionStoreOptions
): void | (() => void);
}
/**
* State manager for a session turn, tracking messages, custom state, and artifacts.
*/
export class Session<S = unknown> extends EventEmitter {
private state: SessionState<S>;
private version: number = 0;
/** Stable identifier that correlates traces across agent turns. */
readonly sessionId: string;
constructor(initialState: SessionState<S>) {
super();
// Clone so we never alias (or mutate) the caller's object: the session
// owns its state, and a handler mutating it must not reach back into the
// caller's / chat's state.
const state = structuredClone(initialState);
this.sessionId = state.sessionId || globalThis.crypto.randomUUID();
state.sessionId = this.sessionId;
this.state = state;
}
/**
* Returns a deep copy of the current session state.
*/
getState(): SessionState<S> {
return structuredClone(this.state);
}
/**
* Retrieves all messages associated with the session.
*
* Returns a copy so callers cannot mutate the session's internal message
* array in place.
*/
getMessages(): MessageData[] {
return structuredClone(this.state.messages || []);
}
/**
* Appends a list of messages to the session.
*/
addMessages(messages: MessageData[]) {
this.state.messages = [...(this.state.messages || []), ...messages];
this.version++;
}
/**
* Overwrites the session messages.
*/
setMessages(messages: MessageData[]) {
this.state.messages = messages;
this.version++;
}
/**
* Retrieves the custom state of the session.
*/
getCustom(): S | undefined {
return this.state.custom;
}
/**
* Updates the custom state of the session using a mutator function.
*/
updateCustom(fn: (custom?: S) => S) {
this.state.custom = fn(this.state.custom);
this.version++;
this.emit('customChanged');
}
/**
* Retrieves the list of artifacts generated during the session.
*/
getArtifacts(): Artifact[] {
return this.state.artifacts || [];
}
/**
* Adds artifacts to the session, deduplicating items by name.
* Emits 'artifactAdded' for new artifacts and 'artifactUpdated' for replacements.
*/
addArtifacts(artifacts: Artifact[]) {
const existing = this.state.artifacts || [];
const added: Artifact[] = [];
const updated: Artifact[] = [];
for (const a of artifacts) {
if (a.name) {
const idx = existing.findIndex((e) => e.name === a.name);
if (idx >= 0) {
existing[idx] = a;
updated.push(a);
continue;
}
}
existing.push(a);
added.push(a);
}
this.state.artifacts = existing;
if (added.length + updated.length > 0) {
this.version++;
}
for (const a of added) {
this.emit('artifactAdded', a);
}
for (const a of updated) {
this.emit('artifactUpdated', a);
}
}
/**
* Runs the provided function inside the session's context.
*/
run<O>(fn: () => O) {
return getAsyncContext().run('ai.session', this, fn);
}
/**
* Gets the current mutation version of the session state.
*/
getVersion(): number {
return this.version;
}
}
/**
* Utility to execute a function bound to a Session instance context.
*/
export function runWithSession<S = any, O = any>(
registry: Registry,
session: Session<S>,
fn: () => O
): O {
return getAsyncContext().run('ai.session', session, fn);
}
/**
* Returns the Session instance active in the current context.
*/
export function getCurrentSession<S = any>(
registry: Registry
): Session<S> | undefined {
return getAsyncContext().getStore('ai.session');
}
/**
* Error thrown during session execution.
*/
export class SessionError extends Error {
constructor(msg: string) {
super(msg);
}
}
/**
* Validates that `sessionId` is a non-empty string, throwing a descriptive
* error otherwise.
*
* Session ids can be minted by the client and can be any non-empty string
* (e.g. a UUID, or an application-specific identifier). We only reject empty /
* blank values so the id stays usable as a key (and as a directory name in
* {@link FileSessionStore}).
*/
export function assertValidSessionId(sessionId: string): void {
if (typeof sessionId !== 'string' || sessionId.trim() === '') {
throw new Error(
`Invalid sessionId: expected a non-empty string, got "${sessionId}".`
);
}
}
/**
* Mints a new `snapshotId` (a plain random UUID).
*
* The runtime normally supplies the snapshotId to the store at save time, but
* some flows need the id *ahead of time* - e.g. an agent turn that wants to
* know the snapshotId at turn *start* (to name a git branch / worktree after
* it) and have the snapshot persisted at turn end reuse that very id, or the
* detach path which pre-reserves the in-flight snapshot's id.
*/
export function reserveSnapshotId(): string {
return globalThis.crypto.randomUUID();
}