fix: merge snapshot and state middleware and fix middleware chaining (#137)

This commit is contained in:
Marcus Schiesser
2025-08-20 11:20:23 +08:00
committed by GitHub
parent 315ae7bb59
commit a3ce1313c2
19 changed files with 1413 additions and 505 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@llamaindex/workflow-core": patch
---
merge snapshot and state middleware and fix middleware chaining
+5
View File
@@ -0,0 +1,5 @@
---
"@llamaindex/workflow-core": patch
---
fix: snapshot not passing createContext params
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
run: pnpm install
- name: Run build
run: pnpx turbo run build --filter="./packages/*"
run: pnpm run build
- name: Pre Release
run: pnpx pkg-pr-new publish --pnpm ./packages/*
+4 -4
View File
@@ -31,9 +31,9 @@ jobs:
- name: Install dependencies
run: pnpm install
- name: Build packages
run: pnpx turbo run build --filter="./packages/*"
run: pnpm run build
- name: Run tests
run: pnpx turbo run test
run: pnpm run test
deno-test:
name: Test on Deno
runs-on: ubuntu-latest
@@ -51,7 +51,7 @@ jobs:
- name: Install dependencies
run: pnpm install
- name: Run build
run: pnpx turbo run build --filter="./packages/*"
run: pnpm run build
- name: Run Deno tests
run: deno test
working-directory: ./demo/deno
@@ -84,6 +84,6 @@ jobs:
- name: Install dependencies
run: pnpm install
- name: Run build
run: pnpx turbo run build --filter="./packages/*"
run: pnpm run build
- name: Run type check
run: pnpm run typecheck
+16 -5
View File
@@ -17,12 +17,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- **Core package dev**: `cd packages/core && pnpm run dev` (watch mode)
- **Core package test**: `cd packages/core && pnpm run test` (Vitest)
- **HTTP package build**: `cd packages/http && pnpm run build` (uses Bunchee)
- **LlamaIndex package test**: `cd packages/llamaindex && pnpm run test`
- **HTTP package dev**: `cd packages/http && pnpm run dev` (watch mode with Bunchee)
### Testing
- **Run tests**: `vitest run` from package directories
- **Run all tests**: `pnpm run test` (from root or individual packages)
- **Test workspace**: Tests are configured in `vitest.workspace.ts` for all packages
- **Browser tests**: Use `happy-dom` environment for browser-specific testing
- **CJS compatibility tests**: Located in `tests/cjs/` directory
## Architecture
@@ -54,10 +56,9 @@ sendEvent(startEvent.with("42"));
### Middleware System
- **State**: `withState()` adds stateful context
- **State**: `withState()` adds stateful context (with built-in snapshot support)
- **Validation**: `withValidation()` provides type-safe event handling
- **Trace Events**: `withTraceEvents()` enables debugging and handler decorators
- **Snapshot**: `withSnapshot()` allows workflow state save/restore
### Key Directories
@@ -71,8 +72,10 @@ sendEvent(startEvent.with("42"));
### Monorepo Structure
- Uses pnpm workspaces with Turbo for build orchestration
- Packages are independently versioned and published
- Packages are independently versioned and published with Changesets
- Demo projects showcase integrations with Next.js, Hono, Deno, browser environments
- **Main packages**: `@llamaindex/workflow-core` (core engine), `@llamaindex/workflow-http` (HTTP protocol)
- **Package exports**: Core package has extensive subpath exports for middleware, utilities, and stream helpers
### Browser Compatibility
@@ -83,3 +86,11 @@ Important: Call `getContext()` at the top level of handlers due to browser async
- Uses Vitest for testing across all packages
- Tests are colocated with source files (`.test.ts` files)
- Browser-specific tests use `happy-dom` environment
- TypeScript compilation builds configured for multiple targets (browser, Node.js, CommonJS)
### Build System
- **Core package**: Uses `tsdown` for building with multiple output formats (ESM, CJS, browser)
- **HTTP package**: Uses `bunchee` for lightweight bundling
- **Turbo**: Orchestrates builds across monorepo with dependency-aware caching
- **Lint-staged**: Automatically formats files on commit with Prettier
+12
View File
@@ -247,6 +247,18 @@ const workflow = withState(createWorkflow());
const { state } = workflow.createContext({ id: "1" });
```
`withState` also supports snapshot, you can use `snapshot` to save the state of the workflow, and `resume` to restore the state of the workflow.
```ts
const { snapshot, resume } = workflow.createContext();
// create snapshot
const [req, snapshotData] = await snapshot();
// resume workflow from snapshot
const { stream } = workflow.resume(["hello"], snapshotData);
```
### `withValidation`
Make first parameter of `handler` to be `sendEvent` and its type safe and runtime safe
+260
View File
@@ -0,0 +1,260 @@
import { createWorkflow, workflowEvent } from "@llamaindex/workflow-core";
import {
createStatefulMiddleware,
request,
SnapshotData,
} from "@llamaindex/workflow-core/middleware/state";
import { OpenAI } from "openai";
import {
ChatCompletionMessage,
ChatCompletionMessageParam,
ChatCompletionMessageToolCall,
ChatCompletionTool,
} from "openai/resources/chat/completions";
import * as readline from "readline/promises";
type ToolResponseEventData = {
toolResponse: string;
toolId: string;
};
type AgentWorkflowState = {
expectedToolCount: number;
messages: ChatCompletionMessageParam[];
toolResponses: Array<ToolResponseEventData>;
humanToolId: string | null;
};
const { withState, getContext } = createStatefulMiddleware(
(state: AgentWorkflowState) => state,
);
const workflow = withState(createWorkflow());
let snapshotData: SnapshotData | null = null;
// Define our events
const userInputEvent = workflowEvent<{
messages: ChatCompletionMessageParam[];
}>();
const toolCallEvent = workflowEvent<{
toolCall: ChatCompletionMessageToolCall;
}>();
const toolResponseEvent = workflowEvent<ToolResponseEventData>();
const finalResponseEvent = workflowEvent<string>();
const humanResponseEvent = workflowEvent<string>();
// Initialize OpenAI client (same as before)
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
// Define available tools
const tools: ChatCompletionTool[] = [
{
type: "function" as const,
function: {
name: "get_weather",
description: "Get the current weather for a location",
parameters: {
type: "object",
properties: {
location: {
type: "string",
description: "The city and state, e.g. San Francisco, CA",
},
},
required: ["location"],
},
},
},
{
type: "function" as const,
function: {
name: "human_ask_name",
description: "Ask the user for his/her name",
},
},
];
// LLM function - handles the AI reasoning
async function llm(
messages: ChatCompletionMessageParam[],
tools: ChatCompletionTool[],
): Promise<ChatCompletionMessage> {
const completion = await openai.chat.completions.create({
model: "gpt-4.1-mini",
messages,
tools,
tool_choice: "auto",
});
const message = completion.choices[0]?.message;
if (!message) {
throw new Error("No response from LLM");
}
return message;
}
// Tool calling function - executes the requested tools
async function callTool(
toolCall: ChatCompletionMessageToolCall,
): Promise<string> {
const toolName = toolCall.function.name;
const toolInput = JSON.parse(toolCall.function.arguments);
// Execute the requested tool
switch (toolName) {
case "get_weather":
// Mock weather API call
const location = toolInput.location;
return `The weather in ${location} is sunny and 72°F`;
default:
return `Unknown tool: ${toolName}`;
}
}
// Handler for processing user input and LLM responses
workflow.handle([userInputEvent], async (event) => {
const { sendEvent, state } = getContext();
const { messages } = event.data;
try {
// Use our same llm() function
const response = await llm(messages, tools);
// Add the assistant's response to the conversation
state.messages = [...messages, response];
// Check if the LLM wants to call tools
if (response.tool_calls && response.tool_calls.length > 0) {
state.expectedToolCount = response.tool_calls.length;
// Send tool call events for each requested tool
for (const toolCall of response.tool_calls) {
sendEvent(
toolCallEvent.with({
toolCall,
}),
);
}
} else {
// No tools requested, send final response
sendEvent(finalResponseEvent.with(response.content || ""));
}
} catch (error) {
console.error("Error calling LLM:", error);
sendEvent(finalResponseEvent.with("Error processing request"));
}
});
// Handler for aggregating tool call responses
workflow.handle([toolResponseEvent], async (event) => {
const { sendEvent, state } = getContext();
// Collect all tool responses until we have all of them
state.toolResponses.push(event.data);
// Once we have all responses, continue the conversation
if (state.toolResponses.length === state.expectedToolCount) {
// Add tool response messages
const finalMessages = [
...state.messages,
// TODO: simplify this using openai type
...state.toolResponses.map((response) => ({
role: "tool" as const,
content: response.toolResponse,
tool_call_id: response.toolId,
})),
];
// Continue the loop with the updated conversation
sendEvent(userInputEvent.with({ messages: finalMessages }));
}
});
// Handler for executing tool calls
workflow.handle([toolCallEvent], async (event) => {
const { toolCall } = event.data;
const { sendEvent, state, snapshot } = getContext();
try {
if (toolCall.function.name.startsWith("human_")) {
// delegate to human if tool call starts with "human_"
state.humanToolId = toolCall.id;
sendEvent(request(humanResponseEvent));
const [_, snapshotData_] = await snapshot();
snapshotData = snapshotData_;
// stop workflow
// TODO: this shows a 'sendEvent after snapshot is not allowed' warning
sendEvent(finalResponseEvent.with("Waiting for human response"));
} else {
// normal machine tool call
const toolResponse = await callTool(toolCall);
// Send the tool response back
sendEvent(
toolResponseEvent.with({
toolResponse,
toolId: toolCall.id,
}),
);
}
} catch (error) {
console.error(`Error executing tool ${toolCall.function.name}:`, error);
sendEvent(
toolResponseEvent.with({
toolResponse: `Error executing ${toolCall.function.name}: ${error}`,
toolId: toolCall.id,
}),
);
}
});
workflow.handle([humanResponseEvent], async (event) => {
const { sendEvent, state } = getContext();
sendEvent(
toolResponseEvent.with({
toolResponse: "My name is " + event.data,
toolId: state.humanToolId!,
}),
);
});
// Run the workflow
const context = workflow.createContext({
expectedToolCount: 0,
messages: [],
toolResponses: [],
humanToolId: null,
});
context.sendEvent(
userInputEvent.with({
messages: [
{
role: "user",
content:
"What's the weather in San Francisco and what is the user's name?",
},
],
}),
);
await context.stream.until(finalResponseEvent).toArray();
if (!snapshotData) {
throw new Error("No snapshot data");
}
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const userName = await rl.question("Name? ");
const resumedContext = workflow.resume([userName], snapshotData);
const result = await resumedContext.stream.until(finalResponseEvent).toArray();
console.log(result[result.length - 1].data);
// TODO: ensure process exit is not needed
process.exit(0);
+4 -3
View File
@@ -1,7 +1,7 @@
import {
withSnapshot,
createStatefulMiddleware,
request,
} from "@llamaindex/workflow-core/middleware/snapshot";
} from "@llamaindex/workflow-core/middleware/state";
import {
createWorkflow,
workflowEvent,
@@ -11,7 +11,8 @@ import { OpenAI } from "openai";
const openai = new OpenAI();
const workflow = withSnapshot(createWorkflow());
const { withState } = createStatefulMiddleware();
const workflow = withState(createWorkflow());
const startEvent = workflowEvent<string>({
debugLabel: "start",
+5 -2
View File
@@ -3,12 +3,14 @@
"private": true,
"version": "0.0.1",
"type": "module",
"packageManager": "pnpm@10.12.4",
"packageManager": "pnpm@10.15.0",
"scripts": {
"build": "turbo build --filter=\"./packages/*\"",
"test": "turbo test --filter=\"./packages/*\"",
"typecheck": "tsc -b --verbose",
"lint": "prettier . --check",
"lint:fix": "prettier . --write",
"publish": "turbo build --filter=\"./packages/*\" && changeset publish",
"publish": "pnpm run build && changeset publish",
"prepare": "husky"
},
"devDependencies": {
@@ -20,6 +22,7 @@
"prettier": "^3.6.1",
"tsdown": "^0.12.9",
"turbo": "^2.5.4",
"typescript": "^5.9.2",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4"
}
+82 -2
View File
@@ -72,8 +72,17 @@ export type WorkflowContext = {
[event: WorkflowEventData<any>, handlerContext: HandlerContext],
void
>;
__internal__property_inheritance_handlers?: Map<
string,
InheritanceTransformer
>;
};
export type InheritanceTransformer = (
handlerContext: WorkflowContext,
originalDescriptor: PropertyDescriptor,
) => PropertyDescriptor;
export const _executorAsyncLocalStorage =
new AsyncContext.Variable<WorkflowContext>();
@@ -85,6 +94,51 @@ export function getContext(): WorkflowContext {
return context;
}
/**
* Use this function to add or extend properties of the root context.
* Called by middleware's createContext to update the root context.
* Handler-scoped contexts will automatically inherit these properties from the root context.
* Never create a new object (e.g., using a spread `{...context}`) in your middleware's createContext.
*
* @param context The context to extend
* @param properties The properties to add to the context
* @param inheritanceTransformers The inheritance transformers to apply to existing properties (optional)
*/
export function extendContext(
context: WorkflowContext,
properties: Record<string, any>,
inheritanceTransformers?: Record<string, InheritanceTransformer>,
): void {
// Add simple properties directly to the context (these inherit normally via prototype chain)
Object.assign(context, properties);
// Register inheritance transformers for properties that need custom inheritance behavior
if (inheritanceTransformers) {
if (!context.__internal__property_inheritance_handlers) {
context.__internal__property_inheritance_handlers = new Map();
}
for (const [propertyKey, transformer] of Object.entries(
inheritanceTransformers,
)) {
context.__internal__property_inheritance_handlers.set(
propertyKey,
transformer,
);
// Apply the transformer to the root context immediately
const rootDescriptor = Object.getOwnPropertyDescriptor(
context,
propertyKey,
);
if (rootDescriptor) {
const newDescriptor = transformer(context, rootDescriptor);
Object.defineProperty(context, propertyKey, newDescriptor);
}
}
}
}
const handlerContextAsyncLocalStorage =
new AsyncContext.Variable<HandlerContext>();
@@ -104,6 +158,7 @@ export const createContext = ({
listeners,
}: ExecutorParams): WorkflowContext => {
const queue: WorkflowEventData<any>[] = [];
let rootWorkflowContext: WorkflowContext;
const runHandler = (
handler: Handler<WorkflowEvent<any>[], any>,
inputEvents: WorkflowEvent<any>[],
@@ -134,7 +189,32 @@ export const createContext = ({
},
};
handlerContext.prev.next.add(handlerContext);
const workflowContext = createWorkflowContext(handlerContext);
// Use prototype chain to inherit the properties of the root workflow context for the specific context for the handler
const specificContext = createWorkflowContext(handlerContext);
const workflowContext = Object.create(rootWorkflowContext);
const specificDescriptors =
Object.getOwnPropertyDescriptors(specificContext);
// Apply inheritance transformers if available
if (rootWorkflowContext.__internal__property_inheritance_handlers) {
for (const [
propertyKey,
transformer,
] of rootWorkflowContext.__internal__property_inheritance_handlers) {
if (propertyKey in specificDescriptors) {
const originalDescriptor = specificDescriptors[propertyKey];
if (originalDescriptor) {
const newDescriptor = transformer(
workflowContext,
originalDescriptor,
);
specificDescriptors[propertyKey] = newDescriptor;
}
}
}
}
Object.defineProperties(workflowContext, specificDescriptors);
handlerContextAsyncLocalStorage.run(handlerContext, () => {
const cbs = [
...new Set([
@@ -267,6 +347,6 @@ export const createContext = ({
},
};
const rootWorkflowContext = createWorkflowContext(handlerRootContext);
rootWorkflowContext = createWorkflowContext(handlerRootContext);
return rootWorkflowContext;
};
+7 -1
View File
@@ -1,7 +1,13 @@
// workflow
export { createWorkflow, type Workflow } from "./workflow";
// context
export { getContext, type WorkflowContext, type Handler } from "./context";
export {
getContext,
extendContext,
type WorkflowContext,
type Handler,
type InheritanceTransformer,
} from "./context";
// event system
export {
isWorkflowEvent,
-361
View File
@@ -1,361 +0,0 @@
import {
eventSource,
type Workflow as WorkflowCore,
type WorkflowContext,
type WorkflowEvent,
workflowEvent,
type WorkflowEventData,
WorkflowStream,
} from "@llamaindex/workflow-core";
import type { HandlerContext } from "../core/context";
import { createStableHash } from "./snapshot/stable-hash";
import { createSubscribable, isPromiseLike } from "../core/utils";
/**
* @internal We don't want to expose this special event to the user
*/
const snapshotEvent = workflowEvent<WorkflowEvent<any>>();
const reasonWeakMap = new WeakMap<WorkflowEventData<any>, any>();
const noop = () => {};
export const request = <T>(
event: WorkflowEvent<T>,
reason?: any,
): WorkflowEventData<WorkflowEvent<T>> => {
const ev = snapshotEvent.with(event);
reasonWeakMap.set(ev, reason);
return ev;
};
export type SnapshotFn = () => Promise<
[requestEvents: WorkflowEvent<any>[], serializable: SnapshotData]
>;
type SnapshotWorkflowContext<Workflow extends WorkflowCore> = ReturnType<
Workflow["createContext"]
> & {
onRequest: <Event extends WorkflowEvent<any>>(
event: Event,
callback: (reason: any) => void | Promise<void>,
) => () => void;
/**
* Snapshot will lock the context and wait for there is no pending event.
*
* This is useful when you want to take a current snapshot of the workflow
*
*/
snapshot: SnapshotFn;
};
type WithSnapshotWorkflow<Workflow extends WorkflowCore> = Omit<
Workflow,
"createContext"
> & {
createContext: () => SnapshotWorkflowContext<Workflow>;
resume: (
data: any[],
serializable: Omit<SnapshotData, "unrecoverableQueue">,
) => SnapshotWorkflowContext<Workflow>;
};
interface SnapshotData {
queue: [data: any, id: number][];
/**
* These events are not recoverable because they are not in any handler
*
* This is useful when you have `messageEvent` but you don't have any handler for it
*/
unrecoverableQueue: [data: any, id: number][];
/**
* This is the version of the snapshot
*
* Change any of the handlers will change the version
*/
version: string;
missing: number[];
}
type OnRequestFn<Event extends WorkflowEvent<any> = WorkflowEvent<any>> = (
eventData: Event,
reason: any,
) => void | Promise<void>;
export function withSnapshot<Workflow extends WorkflowCore>(
workflow: Workflow,
): WithSnapshotWorkflow<Workflow> {
const requests = createSubscribable<OnRequestFn>();
const pendingRequestSetMap = new WeakMap<
WorkflowContext,
Set<PromiseLike<unknown>>
>();
const getPendingRequestSet = (context: WorkflowContext) => {
if (!pendingRequestSetMap.has(context)) {
pendingRequestSetMap.set(context, new Set());
}
return pendingRequestSetMap.get(context)!;
};
const stableHash = createStableHash();
/**
* This is to indicate the version of the snapshot
*
* It happens when you modify the workflow, all old snapshots should be invalidated
*/
const versionObj: [number[], Function][] = [];
const getVersion = () => stableHash(versionObj);
const registeredEvents = new Set<WorkflowEvent<any>>();
const isContextLockedWeakMap = new WeakMap<WorkflowContext, boolean>();
const isContextLocked = (context: WorkflowContext): boolean => {
return isContextLockedWeakMap.get(context) === true;
};
const isContextSnapshotReadyWeakSet = new WeakSet<WorkflowContext>();
const isContextSnapshotReady = (context: WorkflowContext) => {
return isContextSnapshotReadyWeakSet.has(context);
};
const contextEventQueueWeakMap = new WeakMap<
WorkflowContext,
WorkflowEventData<any>[]
>();
const handlerContextSetWeakMap = new WeakMap<
WorkflowContext,
Set<HandlerContext>
>();
const collectedEventHandlerContextWeakMap = new WeakMap<
WorkflowEventData<any>,
Set<HandlerContext>
>();
const createSnapshotFn = (context: WorkflowContext): SnapshotFn => {
return async function snapshotHandler() {
if (isContextLocked(context)) {
throw new Error(
"Context is already locked, you cannot snapshot a same context twice",
);
}
isContextLockedWeakMap.set(context, true);
// 1. wait for all context is ready
const handlerContexts = handlerContextSetWeakMap.get(context)!;
await Promise.all(
[...handlerContexts]
.filter((context) => context.async)
.map((context) => context.pending),
);
// 2. collect all necessary data for a snapshot after lock
const collectedEvents = contextEventQueueWeakMap.get(context)!;
const requestEvents = collectedEvents
.filter((event) => snapshotEvent.include(event))
.map((event) => event.data);
// there might have pending events in the queue, we need to collect them
const queue = collectedEvents.filter(
(event) => !snapshotEvent.include(event),
);
// 3. serialize the data
isContextSnapshotReadyWeakSet.add(context);
if (requestEvents.some((event) => !registeredEvents.has(event))) {
console.warn("request event is not registered in the workflow");
}
const serializable: SnapshotData = {
queue: queue
.filter((event) => eventCounterWeakMap.has(eventSource(event)!))
.map((event) => [event.data, getEventCounter(eventSource(event)!)]),
unrecoverableQueue: queue
.filter((event) => !eventCounterWeakMap.has(eventSource(event)!))
.map((event) => [event.data, getEventCounter(eventSource(event)!)]),
version: getVersion(),
missing: requestEvents
// if you are request an event that is not in the handler, it's meaningless (from a logic perspective)
.filter((event) => eventCounterWeakMap.has(event))
.map((event) => getEventCounter(event)),
};
return [requestEvents, serializable];
};
};
let counter = 0;
const eventCounterWeakMap = new WeakMap<WorkflowEvent<any>, number>();
const counterEventMap = new Map<number, WorkflowEvent<any>>();
const getEventCounter = (event: WorkflowEvent<any>) => {
if (!eventCounterWeakMap.has(event)) {
eventCounterWeakMap.set(event, counter++);
}
return eventCounterWeakMap.get(event)!;
};
const getCounterEvent = (counter: number) => {
if (!counterEventMap.has(counter)) {
throw new Error(`event counter ${counter} not found`);
}
return counterEventMap.get(counter)!;
};
function initContext(context: WorkflowContext) {
handlerContextSetWeakMap.set(context, new Set());
contextEventQueueWeakMap.set(context, []);
context.__internal__call_send_event.subscribe(
(eventData, handlerContext) => {
contextEventQueueWeakMap.get(context)!.push(eventData);
if (isContextLocked(context)) {
if (isContextSnapshotReady(context)) {
console.warn(
"snapshot is already ready, sendEvent after snapshot is not allowed",
);
}
if (!collectedEventHandlerContextWeakMap.has(eventData)) {
collectedEventHandlerContextWeakMap.set(eventData, new Set());
}
collectedEventHandlerContextWeakMap
.get(eventData)!
.add(handlerContext);
}
},
);
context.__internal__call_context.subscribe((handlerContext, next) => {
if (isContextLocked(context)) {
// replace it with noop, avoid calling the handler after snapshot
handlerContext.handler = noop;
next(handlerContext);
} else {
const queue = contextEventQueueWeakMap.get(context)!;
handlerContext.inputs.forEach((input) => {
queue.splice(queue.indexOf(input), 1);
});
const originalHandler = handlerContext.handler;
const pendingRequests = getPendingRequestSet(context);
const isPendingTask = pendingRequests.size !== 0;
if (isPendingTask) {
handlerContext.handler = async (...events) => {
return Promise.all([...pendingRequests]).finally(() => {
return originalHandler(...events);
});
};
}
handlerContextSetWeakMap.get(context)!.add(handlerContext);
next(handlerContext);
}
});
}
return {
...workflow,
handle: (events: WorkflowEvent<any>[], handler: any) => {
// version the snapshot based on the input events and function
// I assume `uniqueId` is changeable
versionObj.push([events.map(getEventCounter), handler]);
events.forEach((event) => {
counterEventMap.set(getEventCounter(event), event);
});
events.forEach((event) => {
registeredEvents.add(event);
});
return workflow.handle(events, handler);
},
resume(
data: any[],
serializable: Omit<SnapshotData, "unrecoverableQueue">,
): any {
const events = data.map((d, i) =>
getCounterEvent(serializable.missing[i]!).with(d),
);
const context = workflow.createContext();
initContext(context);
const stream = context.stream;
context.sendEvent(
...serializable.queue.map(([data, id]) => {
const event = getCounterEvent(id);
return event.with(data);
}),
);
context.sendEvent(...events);
let lazyInitStream: WorkflowStream | null = null;
const snapshotFn = createSnapshotFn(context);
return {
...context,
snapshot: snapshotFn,
onRequest: (
event: WorkflowEvent<any>,
callback: (reason: any) => void | Promise<void>,
): (() => void) =>
requests.subscribe((ev, reason) => {
if (ev === event) {
return callback(reason);
}
}),
get stream() {
if (!lazyInitStream) {
lazyInitStream = stream.pipeThrough(
new TransformStream({
transform: (event, controller) => {
if (snapshotEvent.include(event)) {
const data = event.data;
requests.publish(data, reasonWeakMap.get(event));
} else {
// ignore snapshot event from stream
controller.enqueue(event);
}
},
}),
);
}
return lazyInitStream;
},
};
},
createContext(): any {
const context = workflow.createContext();
initContext(context);
const stream = context.stream;
let lazyInitStream: WorkflowStream | null = null;
const snapshotFn = createSnapshotFn(context);
return {
...context,
snapshot: snapshotFn,
onRequest: (
event: WorkflowEvent<any>,
callback: (reason: any) => void | Promise<void>,
): (() => void) =>
requests.subscribe((ev, reason) => {
if (ev === event) {
return callback(reason);
}
}),
get stream() {
if (!lazyInitStream) {
lazyInitStream = stream.pipeThrough(
new TransformStream({
transform: (event, controller) => {
if (snapshotEvent.include(event)) {
const data = event.data;
const results = requests.publish(
data,
reasonWeakMap.get(event),
);
const pendingRequests = getPendingRequestSet(context);
results.filter(isPromiseLike).forEach((promise) => {
const task = promise.then(() => {
pendingRequests.delete(task);
});
pendingRequests.add(task);
});
} else {
// ignore snapshot event from stream
controller.enqueue(event);
}
},
}),
);
}
return lazyInitStream;
},
};
},
} as unknown as WithSnapshotWorkflow<Workflow>;
}
+389 -34
View File
@@ -1,68 +1,423 @@
import type {
Workflow,
Workflow as WorkflowCore,
WorkflowContext,
import {
eventSource,
getContext,
type Workflow,
type WorkflowContext,
type Workflow as WorkflowCore,
type WorkflowEvent,
workflowEvent,
type WorkflowEventData,
WorkflowStream,
} from "@llamaindex/workflow-core";
import { getContext } from "@llamaindex/workflow-core";
import type { HandlerContext } from "../core/context";
import { extendContext } from "../core/context";
import { createSubscribable, isPromiseLike } from "../core/utils";
import { createStableHash } from "./snapshot/stable-hash";
type WithState<State, Input> = Input extends void | undefined
/**
* @internal We don't want to expose this special event to the user
*/
const snapshotEvent = workflowEvent<WorkflowEvent<any>>();
const reasonWeakMap = new WeakMap<WorkflowEventData<any>, any>();
const noop = () => {};
export const request = <T>(
event: WorkflowEvent<T>,
reason?: any,
): WorkflowEventData<WorkflowEvent<T>> => {
const ev = snapshotEvent.with(event);
reasonWeakMap.set(ev, reason);
return ev;
};
export type SnapshotFn = () => Promise<
[requestEvents: WorkflowEvent<any>[], serializable: SnapshotData]
>;
export type SnapshotWorkflowContext<Workflow extends WorkflowCore> = ReturnType<
Workflow["createContext"]
> & {
onRequest: <Event extends WorkflowEvent<any>>(
event: Event,
callback: (reason: any) => void | Promise<void>,
) => () => void;
/**
* Snapshot will lock the context and wait for there is no pending event.
*
* This is useful when you want to take a current snapshot of the workflow
*
*/
snapshot: SnapshotFn;
};
export interface SnapshotData {
queue: [data: any, id: number][];
/**
* These events are not recoverable because they are not in any handler
*
* This is useful when you have `messageEvent` but you don't have any handler for it
*/
unrecoverableQueue: [data: any, id: number][];
/**
* This is the version of the snapshot
*
* Change any of the handlers will change the version
*/
version: string;
missing: number[];
/**
* Save the current serializable state of the workflow
* This state will be restored when you resume the workflow
*/
state?: string | undefined;
}
type OnRequestFn<Event extends WorkflowEvent<any> = WorkflowEvent<any>> = (
eventData: Event,
reason: any,
) => void | Promise<void>;
export interface SnapshotableContext {
snapshot: SnapshotFn;
onRequest: <Event extends WorkflowEvent<any>>(
event: Event,
callback: (reason: any) => void | Promise<void>,
) => () => void;
}
export type StatefulContextWithSnapshot<State> = ReturnType<
Workflow["createContext"]
> & {
get state(): State;
} & SnapshotableContext;
export type ResumeFunction<State> = (
data: any[],
serializable: Omit<SnapshotData, "unrecoverableQueue">,
) => StatefulContextWithSnapshot<State>;
export type WorkflowWithState<State, Input> = Input extends void | undefined
? {
<Workflow extends WorkflowCore>(
workflow: Workflow,
): Omit<Workflow, "createContext"> & {
createContext(): ReturnType<Workflow["createContext"]> & {
get state(): State;
};
createContext(): StatefulContextWithSnapshot<State>;
resume: ResumeFunction<State>;
};
}
: {
<Workflow extends WorkflowCore>(
workflow: Workflow,
): Omit<Workflow, "createContext"> & {
createContext(input: Input): ReturnType<Workflow["createContext"]> & {
get state(): State;
};
createContext(input: Input): StatefulContextWithSnapshot<State>;
resume: ResumeFunction<State>;
};
};
type CreateState<State, Input, Context extends WorkflowContext> = {
getContext(): Context & {
get state(): State;
};
withState: WithState<State, Input>;
} & SnapshotableContext;
withState: WorkflowWithState<State, Input>;
};
type InitFunc<Input, State> = (input: Input) => State;
export function createStatefulMiddleware<
State,
Input = void,
Context extends WorkflowContext = WorkflowContext,
>(init: (input: Input) => State): CreateState<State, Input, Context> {
>(init?: InitFunc<Input, State>): CreateState<State, Input, Context> {
return {
getContext: getContext as never,
withState: ((workflow: Workflow) => {
const requests = createSubscribable<OnRequestFn>();
const pendingRequestSetMap = new WeakMap<
WorkflowContext,
Set<PromiseLike<unknown>>
>();
const getPendingRequestSet = (context: WorkflowContext) => {
if (!pendingRequestSetMap.has(context)) {
pendingRequestSetMap.set(context, new Set());
}
return pendingRequestSetMap.get(context)!;
};
const stableHash = createStableHash();
/**
* This is to indicate the version of the snapshot
*
* It happens when you modify the workflow, all old snapshots should be invalidated
*/
const versionObj: [number[], Function][] = [];
const getVersion = () => stableHash(versionObj);
const registeredEvents = new Set<WorkflowEvent<any>>();
const isContextLockedWeakMap = new WeakMap<WorkflowContext, boolean>();
const isContextLocked = (context: WorkflowContext): boolean => {
return isContextLockedWeakMap.get(context) === true;
};
const isContextSnapshotReadyWeakSet = new WeakSet<WorkflowContext>();
const isContextSnapshotReady = (context: WorkflowContext) => {
return isContextSnapshotReadyWeakSet.has(context);
};
const contextEventQueueWeakMap = new WeakMap<
WorkflowContext,
WorkflowEventData<any>[]
>();
const handlerContextSetWeakMap = new WeakMap<
WorkflowContext,
Set<HandlerContext>
>();
const collectedEventHandlerContextWeakMap = new WeakMap<
WorkflowEventData<any>,
Set<HandlerContext>
>();
const createSnapshotFn = (context: WorkflowContext): SnapshotFn => {
return async function snapshotHandler() {
if (isContextLocked(context)) {
throw new Error(
"Context is already locked, you cannot snapshot a same context twice",
);
}
isContextLockedWeakMap.set(context, true);
// 1. wait for all context is ready
const handlerContexts = handlerContextSetWeakMap.get(context)!;
await Promise.all(
[...handlerContexts]
.filter((context) => context.async)
.map((context) => context.pending),
);
// 2. collect all necessary data for a snapshot after lock
const collectedEvents = contextEventQueueWeakMap.get(context)!;
const requestEvents = collectedEvents
.filter((event) => snapshotEvent.include(event))
.map((event) => event.data);
// there might have pending events in the queue, we need to collect them
const queue = collectedEvents.filter(
(event) => !snapshotEvent.include(event),
);
// 3. serialize the data
isContextSnapshotReadyWeakSet.add(context);
if (requestEvents.some((event) => !registeredEvents.has(event))) {
console.warn("request event is not registered in the workflow");
}
const serializable: SnapshotData = {
queue: queue
.filter((event) => eventCounterWeakMap.has(eventSource(event)!))
.map((event) => [
event.data,
getEventCounter(eventSource(event)!),
]),
unrecoverableQueue: queue
.filter((event) => !eventCounterWeakMap.has(eventSource(event)!))
.map((event) => [
event.data,
getEventCounter(eventSource(event)!),
]),
version: getVersion(),
missing: requestEvents
// if you are request an event that is not in the handler, it's meaningless (from a logic perspective)
.filter((event) => eventCounterWeakMap.has(event))
.map((event) => getEventCounter(event)),
state: (context as any).state
? JSON.stringify((context as any).state)
: undefined,
};
return [requestEvents, serializable];
};
};
let counter = 0;
const eventCounterWeakMap = new WeakMap<WorkflowEvent<any>, number>();
const counterEventMap = new Map<number, WorkflowEvent<any>>();
const getEventCounter = (event: WorkflowEvent<any>) => {
if (!eventCounterWeakMap.has(event)) {
eventCounterWeakMap.set(event, counter++);
}
return eventCounterWeakMap.get(event)!;
};
const getCounterEvent = (counter: number) => {
if (!counterEventMap.has(counter)) {
throw new Error(`event counter ${counter} not found`);
}
return counterEventMap.get(counter)!;
};
function initContext(context: WorkflowContext) {
handlerContextSetWeakMap.set(context, new Set());
contextEventQueueWeakMap.set(context, []);
context.__internal__call_send_event.subscribe(
(eventData, handlerContext) => {
contextEventQueueWeakMap.get(context)!.push(eventData);
if (isContextLocked(context)) {
if (isContextSnapshotReady(context)) {
console.warn(
"snapshot is already ready, sendEvent after snapshot is not allowed",
);
}
if (!collectedEventHandlerContextWeakMap.has(eventData)) {
collectedEventHandlerContextWeakMap.set(eventData, new Set());
}
collectedEventHandlerContextWeakMap
.get(eventData)!
.add(handlerContext);
}
},
);
context.__internal__call_context.subscribe((handlerContext, next) => {
if (isContextLocked(context)) {
// replace it with noop, avoid calling the handler after snapshot
handlerContext.handler = noop;
next(handlerContext);
} else {
const queue = contextEventQueueWeakMap.get(context)!;
handlerContext.inputs.forEach((input) => {
queue.splice(queue.indexOf(input), 1);
});
const originalHandler = handlerContext.handler;
const pendingRequests = getPendingRequestSet(context);
const isPendingTask = pendingRequests.size !== 0;
if (isPendingTask) {
handlerContext.handler = async (...events) => {
return Promise.all([...pendingRequests]).finally(() => {
return originalHandler(...events);
});
};
}
handlerContextSetWeakMap.get(context)!.add(handlerContext);
next(handlerContext);
}
});
}
// Create a function to generate stream transform wrappers
// Shared function to create stateful context
const createStatefulContext = (state: State): any => {
const context = (workflow.createContext as any)();
initContext(context);
const snapshotFn = createSnapshotFn(context);
extendContext(
context,
{
get state() {
return state;
},
snapshot: snapshotFn,
onRequest: (
event: WorkflowEvent<any>,
callback: (reason: any) => void | Promise<void>,
): (() => void) =>
requests.subscribe((ev, reason) => {
if (ev === event) {
return callback(reason);
}
}),
},
{
stream: (currentContext, originalDescriptor) => {
let lazyWrappedStream: WorkflowStream | null = null;
return {
...originalDescriptor,
get() {
if (!lazyWrappedStream) {
const originalStream =
originalDescriptor.get?.call(currentContext);
if (originalStream) {
lazyWrappedStream = originalStream.pipeThrough(
createStreamWrapper(context),
);
}
}
return lazyWrappedStream;
},
};
},
},
);
return context;
};
const createStreamWrapper = (context: WorkflowContext) =>
new TransformStream({
transform: (event, controller) => {
if (snapshotEvent.include(event)) {
const data = event.data;
const results = requests.publish(data, reasonWeakMap.get(event));
const pendingRequests = getPendingRequestSet(context);
results.filter(isPromiseLike).forEach((promise) => {
const task = promise.then(() => {
pendingRequests.delete(task);
});
pendingRequests.add(task);
});
} else {
// ignore snapshot event from stream
controller.enqueue(event);
}
},
});
return {
...workflow,
createContext: (input: Input) => {
const state = init(input);
const context = workflow.createContext();
context.__internal__call_context.subscribe((_, next) => {
// todo: make sure getContext is consistent with `workflow.createContext`
const context = getContext();
if (!Reflect.has(context, "state")) {
Object.defineProperty(context, "state", {
get: () => state,
});
}
next(_);
handle: (events: WorkflowEvent<any>[], handler: any) => {
// version the snapshot based on the input events and function
// I assume `uniqueId` is changeable
versionObj.push([events.map(getEventCounter), handler]);
events.forEach((event) => {
counterEventMap.set(getEventCounter(event), event);
});
if (!Reflect.has(context, "state")) {
Object.defineProperty(context, "state", {
get: () => state,
});
}
return context as any;
events.forEach((event) => {
registeredEvents.add(event);
});
return workflow.handle(events, handler);
},
resume(
data: any[],
serializable: Omit<SnapshotData, "unrecoverableQueue">,
): any {
const resumedState = serializable.state
? JSON.parse(serializable.state)
: undefined;
const events = data.map((d, i) =>
getCounterEvent(serializable.missing[i]!).with(d),
);
// Call the stateful createContext with the resumed state
const context = createStatefulContext(resumedState);
// triggers the lazy initialization of the stream wrapper
context.stream;
context.sendEvent(
...serializable.queue.map(([data, id]) => {
const event = getCounterEvent(id);
return event.with(data);
}),
);
context.sendEvent(...events);
return context;
},
createContext(input?: Input): any {
const state = init?.(input as Input) as State;
return createStatefulContext(state);
},
};
}) as unknown as WithState<State, Input>,
}) as unknown as WorkflowWithState<State, Input>,
};
}
-68
View File
@@ -1,68 +0,0 @@
import type {
Workflow,
Workflow as WorkflowCore,
WorkflowContext,
} from "@llamaindex/workflow-core";
import { getContext } from "@llamaindex/workflow-core";
type WithState<State, Input> = Input extends void | undefined
? {
<Workflow extends WorkflowCore>(
workflow: Workflow,
): Omit<Workflow, "createContext"> & {
createContext(): ReturnType<Workflow["createContext"]> & {
get state(): State;
};
};
}
: {
<Workflow extends WorkflowCore>(
workflow: Workflow,
): Omit<Workflow, "createContext"> & {
createContext(input: Input): ReturnType<Workflow["createContext"]> & {
get state(): State;
};
};
};
type CreateState<State, Input, Context extends WorkflowContext> = {
getContext(): Context & {
get state(): State;
};
withState: WithState<State, Input>;
};
export function createStateMiddleware<
State,
Input = void,
Context extends WorkflowContext = WorkflowContext,
>(init: (input: Input) => State): CreateState<State, Input, Context> {
return {
getContext: getContext as never,
withState: ((workflow: Workflow) => {
return {
...workflow,
createContext: (input: Input) => {
const state = init(input);
const context = workflow.createContext();
context.__internal__call_context.subscribe((_, next) => {
// todo: make sure getContext is consistent with `workflow.createContext`
const context = getContext();
if (!Reflect.has(context, "state")) {
Object.defineProperty(context, "state", {
get: () => state,
});
}
next(_);
});
if (!Reflect.has(context, "state")) {
Object.defineProperty(context, "state", {
get: () => state,
});
}
return context as any;
},
};
}) as unknown as WithState<State, Input>,
};
}
@@ -0,0 +1,459 @@
import { describe, expect, test, vi } from "vitest";
import {
createWorkflow,
extendContext,
getContext,
workflowEvent,
type InheritanceTransformer,
type WorkflowContext,
} from "@llamaindex/workflow-core";
const startEvent = workflowEvent({
debugLabel: "start",
});
const stopEvent = workflowEvent({
debugLabel: "stop",
});
describe("extendContext", () => {
test("should add simple properties to context", () => {
const workflow = createWorkflow();
const context = workflow.createContext();
const testProperty = "test-value";
const testFunction = vi.fn();
extendContext(context, {
testProperty,
testFunction,
});
expect((context as any).testProperty).toBe(testProperty);
expect((context as any).testFunction).toBe(testFunction);
});
test("should apply inheritance transformers to root context immediately", () => {
const workflow = createWorkflow();
const context = workflow.createContext();
const originalValue = "original";
const transformedValue = "transformed";
// Add a property first
extendContext(context, {
testProperty: originalValue,
});
// Then add a transformer for that property
const transformer: InheritanceTransformer = vi.fn(
(handlerContext, originalDescriptor) => ({
...originalDescriptor,
value: transformedValue,
}),
);
extendContext(
context,
{},
{
testProperty: transformer,
},
);
expect(transformer).toHaveBeenCalledWith(context, expect.any(Object));
expect((context as any).testProperty).toBe(transformedValue);
});
test("should handle getter properties with transformers", () => {
const workflow = createWorkflow();
const context = workflow.createContext();
// Add a getter property
Object.defineProperty(context, "dynamicProperty", {
get() {
return "dynamic-value";
},
configurable: true,
enumerable: true,
});
const transformer: InheritanceTransformer = (
handlerContext,
originalDescriptor,
) => ({
...originalDescriptor,
get() {
const original = originalDescriptor.get?.call(handlerContext);
return `transformed-${original}`;
},
});
extendContext(
context,
{},
{
dynamicProperty: transformer,
},
);
expect((context as any).dynamicProperty).toBe("transformed-dynamic-value");
});
test("should handle multiple inheritance transformers", () => {
const workflow = createWorkflow();
const context = workflow.createContext();
extendContext(context, {
prop1: "value1",
prop2: "value2",
prop3: "value3",
});
const transformer1: InheritanceTransformer = (_, originalDescriptor) => ({
...originalDescriptor,
value: `transformed-${originalDescriptor.value}`,
});
const transformer2: InheritanceTransformer = (_, originalDescriptor) => ({
...originalDescriptor,
value: `${originalDescriptor.value}-extra`,
});
extendContext(
context,
{},
{
prop1: transformer1,
prop2: transformer2,
},
);
expect((context as any).prop1).toBe("transformed-value1");
expect((context as any).prop2).toBe("value2-extra");
expect((context as any).prop3).toBe("value3"); // No transformer, unchanged
});
test("should handle lazy initialization in transformers", () => {
const workflow = createWorkflow();
const context = workflow.createContext();
const initCalls: string[] = [];
Object.defineProperty(context, "lazyProperty", {
get() {
initCalls.push("root");
return "root-value";
},
configurable: true,
enumerable: true,
});
const transformer: InheritanceTransformer = (
handlerContext,
originalDescriptor,
) => {
let lazyValue: any = null;
return {
...originalDescriptor,
get() {
if (lazyValue === null) {
initCalls.push("handler");
const original = originalDescriptor.get?.call(handlerContext);
lazyValue = `lazy-${original}`;
}
return lazyValue;
},
};
};
extendContext(
context,
{},
{
lazyProperty: transformer,
},
);
const value = (context as any).lazyProperty;
expect(value).toBe("lazy-root-value");
expect(initCalls).toEqual(["handler", "root"]);
// Second access should use cached value
const value2 = (context as any).lazyProperty;
expect(value2).toBe("lazy-root-value");
expect(initCalls).toEqual(["handler", "root"]); // No additional calls
});
test("should not apply transformers for non-existent properties", () => {
const workflow = createWorkflow();
const context = workflow.createContext();
const transformer = vi.fn();
extendContext(
context,
{},
{
nonExistentProperty: transformer,
},
);
expect(transformer).not.toHaveBeenCalled();
});
test("should work with multiple extendContext calls", () => {
const workflow = createWorkflow();
const context = workflow.createContext();
// First call
extendContext(context, {
prop1: "value1",
});
// Second call
extendContext(context, {
prop2: "value2",
});
// Third call with transformer
extendContext(context, {
prop3: "value3",
});
const transformer: InheritanceTransformer = (_, originalDescriptor) => ({
...originalDescriptor,
value: `transformed-${originalDescriptor.value}`,
});
extendContext(
context,
{},
{
prop3: transformer,
},
);
expect((context as any).prop1).toBe("value1");
expect((context as any).prop2).toBe("value2");
expect((context as any).prop3).toBe("transformed-value3");
});
test("should preserve original property descriptor properties", () => {
const workflow = createWorkflow();
const context = workflow.createContext();
Object.defineProperty(context, "testProperty", {
value: "test",
writable: false,
enumerable: false,
configurable: true,
});
const transformer: InheritanceTransformer = (_, originalDescriptor) => ({
...originalDescriptor,
value: "transformed",
});
extendContext(
context,
{},
{
testProperty: transformer,
},
);
const descriptor = Object.getOwnPropertyDescriptor(context, "testProperty");
expect(descriptor?.value).toBe("transformed");
expect(descriptor?.writable).toBe(false);
expect(descriptor?.enumerable).toBe(false);
expect(descriptor?.configurable).toBe(true);
});
test("should handle inheritance transformers when property descriptors change", () => {
const workflow = createWorkflow();
const context = workflow.createContext();
// Test that the transformer is called with the latest descriptor
extendContext(context, {
changingProperty: "initial",
});
// Change the property descriptor
Object.defineProperty(context, "changingProperty", {
get() {
return "getter-value";
},
configurable: true,
enumerable: true,
});
const transformer: InheritanceTransformer = (_, originalDescriptor) => {
if (originalDescriptor.get) {
return {
...originalDescriptor,
get() {
return `transformed-${originalDescriptor.get?.call(this)}`;
},
};
}
return {
...originalDescriptor,
value: `transformed-${originalDescriptor.value}`,
};
};
extendContext(
context,
{},
{
changingProperty: transformer,
},
);
expect((context as any).changingProperty).toBe("transformed-getter-value");
});
test("should handle transformer that returns identical descriptor", () => {
const workflow = createWorkflow();
const context = workflow.createContext();
extendContext(context, {
testProperty: "original-value",
});
const transformer: InheritanceTransformer = (_, originalDescriptor) => {
// Return the same descriptor without modifications
return originalDescriptor;
};
extendContext(
context,
{},
{
testProperty: transformer,
},
);
expect((context as any).testProperty).toBe("original-value");
});
test("should handle concurrent property inheritance handlers map", () => {
const workflow = createWorkflow();
const context = workflow.createContext();
extendContext(context, {
prop1: "value1",
prop2: "value2",
});
const transformer1: InheritanceTransformer = (_, originalDescriptor) => ({
...originalDescriptor,
value: `t1-${originalDescriptor.value}`,
});
const transformer2: InheritanceTransformer = (_, originalDescriptor) => ({
...originalDescriptor,
value: `t2-${originalDescriptor.value}`,
});
// Add transformers in separate calls
extendContext(context, {}, { prop1: transformer1 });
extendContext(context, {}, { prop2: transformer2 });
expect((context as any).prop1).toBe("t1-value1");
expect((context as any).prop2).toBe("t2-value2");
// Verify internal map has both transformers
const internalMap = (context as any)
.__internal__property_inheritance_handlers;
expect(internalMap).toBeInstanceOf(Map);
expect(internalMap.size).toBe(2);
expect(internalMap.has("prop1")).toBe(true);
expect(internalMap.has("prop2")).toBe(true);
});
test("should allow simple properties to inherit via prototype chain in handlers", async () => {
const workflow = createWorkflow();
let handlerContext: WorkflowContext | null = null;
workflow.handle([startEvent], () => {
handlerContext = getContext();
return stopEvent.with();
});
const context = workflow.createContext();
const testValue = "inherited-value";
extendContext(context, {
testProperty: testValue,
});
context.sendEvent(startEvent.with());
// Wait for handler to execute
await new Promise<void>((resolve) => {
const checkHandler = () => {
if (handlerContext) {
resolve();
} else {
setTimeout(checkHandler, 10);
}
};
checkHandler();
});
expect(handlerContext).toBeTruthy();
expect((handlerContext as any).testProperty).toBe(testValue);
});
test("should apply inheritance transformers to handler contexts", async () => {
const workflow = createWorkflow();
let handlerContext: WorkflowContext | null = null;
let transformedValue: string | null = null;
workflow.handle([startEvent], () => {
handlerContext = getContext();
transformedValue = (handlerContext as any).testProperty;
return stopEvent.with();
});
const context = workflow.createContext();
extendContext(context, {
testProperty: "original",
});
const transformer: InheritanceTransformer = (
handlerContext,
originalDescriptor,
) => ({
...originalDescriptor,
value: `transformed-${originalDescriptor.value}`,
});
extendContext(
context,
{},
{
testProperty: transformer,
},
);
context.sendEvent(startEvent.with());
// Wait for handler to execute
await new Promise<void>((resolve) => {
const checkHandler = () => {
if (handlerContext && transformedValue !== null) {
resolve();
} else {
setTimeout(checkHandler, 10);
}
};
checkHandler();
});
expect(handlerContext).toBeTruthy();
expect(transformedValue).toBe("transformed-original");
});
});
@@ -5,7 +5,7 @@ import {
type WorkflowEvent,
type WorkflowEventData,
} from "@llamaindex/workflow-core";
import { createStateMiddleware } from "@llamaindex/workflow-core/middleware/store";
import { createStatefulMiddleware } from "@llamaindex/workflow-core/middleware/state";
import { withTraceEvents } from "@llamaindex/workflow-core/middleware/trace-events";
import { withValidation } from "@llamaindex/workflow-core/middleware/validation";
import { zodEvent } from "@llamaindex/workflow-core/util/zod";
@@ -24,7 +24,7 @@ describe("full workflow middleware", () => {
validation: Validation,
createStore: (input: Input) => T,
) => {
const { withState, getContext } = createStateMiddleware(createStore);
const { withState, getContext } = createStatefulMiddleware(createStore);
return [
withState(withValidation(withTraceEvents(createWorkflow()), validation)),
getContext,
@@ -0,0 +1,78 @@
import { describe, expect, test } from "vitest";
import { createWorkflow, workflowEvent } from "@llamaindex/workflow-core";
import {
createStatefulMiddleware,
request,
} from "@llamaindex/workflow-core/middleware/state";
const startEvent = workflowEvent({});
const requestEvent = workflowEvent<string>({});
const stopEvent = workflowEvent({});
type TestState = {
counter: number;
message: string;
};
describe("state with snapshot middleware", () => {
test("should preserve state when resuming from snapshot", async () => {
const { withState, getContext } = createStatefulMiddleware(
(input: TestState) => input,
);
const workflow = withState(createWorkflow());
let handlerState: TestState | null = null;
let snapshotData: any = null;
// Handler that modifies state and creates a snapshot
workflow.handle([startEvent], async () => {
const context = getContext();
// Modify the state
context.state.counter = 42;
context.state.message = "original state";
// Send a request and snapshot
context.sendEvent(request(requestEvent));
const [_, sd] = await context.snapshot();
snapshotData = sd;
return stopEvent.with();
});
// Handler for human response
workflow.handle([requestEvent], ({ data }) => {
const { state } = getContext();
handlerState = state;
return stopEvent.with();
});
// Create initial context with state
const context = workflow.createContext({
counter: 0,
message: "initial",
});
expect(context.state.counter).toBe(0);
expect(context.state.message).toBe("initial");
// run the workflow
context.sendEvent(startEvent.with());
await context.stream.until(stopEvent).toArray();
expect(snapshotData).toBeTruthy();
expect(context.state.counter).toBe(42);
expect(context.state.message).toBe("original state");
const resumedContext = workflow.resume(["hello"], snapshotData);
const events = await resumedContext.stream.until(stopEvent).toArray();
expect(events.length).toBe(2);
expect(handlerState).toBeDefined();
expect(handlerState!.counter).toBe(42);
expect(handlerState!.message).toBe("original state");
});
});
@@ -1,12 +1,11 @@
import { describe, expect, test, vi } from "vitest";
import {
withSnapshot,
createStatefulMiddleware,
request,
} from "@llamaindex/workflow-core/middleware/snapshot";
} from "@llamaindex/workflow-core/middleware/state";
import {
createWorkflow,
eventSource,
getContext,
workflowEvent,
} from "@llamaindex/workflow-core";
@@ -27,7 +26,8 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
describe("with snapshot - snapshot API", () => {
test("single handler (sync)", async () => {
const workflow = withSnapshot(createWorkflow());
const { withState } = createStatefulMiddleware();
const workflow = withState(createWorkflow());
workflow.handle([startEvent], () => {
return request(humanResponseEvent);
});
@@ -48,6 +48,7 @@ describe("with snapshot - snapshot API", () => {
1,
],
"queue": [],
"state": undefined,
"unrecoverableQueue": [],
"version": "@@@0,,4~,,@@1,,7~,,",
}
@@ -61,7 +62,8 @@ describe("with snapshot - snapshot API", () => {
});
test("single handler (async)", async () => {
const workflow = withSnapshot(createWorkflow());
const { withState } = createStatefulMiddleware();
const workflow = withState(createWorkflow());
workflow.handle([startEvent], async () => {
return request(humanResponseEvent);
});
@@ -82,6 +84,7 @@ describe("with snapshot - snapshot API", () => {
1,
],
"queue": [],
"state": undefined,
"unrecoverableQueue": [],
"version": "@@@0,,4~,,@@1,,7~,,",
}
@@ -95,7 +98,8 @@ describe("with snapshot - snapshot API", () => {
});
test("single handler (timer)", async () => {
const workflow = withSnapshot(createWorkflow());
const { withState } = createStatefulMiddleware();
const workflow = withState(createWorkflow());
workflow.handle([startEvent], async () => {
await sleep(10);
return request(humanResponseEvent);
@@ -117,6 +121,7 @@ describe("with snapshot - snapshot API", () => {
1,
],
"queue": [],
"state": undefined,
"unrecoverableQueue": [],
"version": "@@@0,,4~,,@@1,,7~,,",
}
@@ -130,7 +135,8 @@ describe("with snapshot - snapshot API", () => {
});
test("multiple message in the queue", async () => {
const workflow = withSnapshot(createWorkflow());
const { withState, getContext } = createStatefulMiddleware();
const workflow = withState(createWorkflow());
workflow.handle([startEvent], async () => {
const { sendEvent } = getContext();
await sleep(10);
@@ -156,6 +162,7 @@ describe("with snapshot - snapshot API", () => {
1,
],
"queue": [],
"state": undefined,
"unrecoverableQueue": [
[
"1",
@@ -178,7 +185,8 @@ describe("with snapshot - snapshot API", () => {
});
test("multiple requests in the response", async () => {
const workflow = withSnapshot(createWorkflow());
const { withState } = createStatefulMiddleware();
const workflow = withState(createWorkflow());
workflow.handle([startEvent], async () => {
return request(humanResponseEvent);
});
@@ -204,6 +212,7 @@ describe("with snapshot - snapshot API", () => {
1,
],
"queue": [],
"state": undefined,
"unrecoverableQueue": [],
"version": "@@@0,,4~,,@@0,,7~,,@@1,,10~,,",
}
@@ -220,7 +229,8 @@ describe("with snapshot - snapshot API", () => {
vi.stubGlobal("console", {
warn,
});
const workflow = withSnapshot(createWorkflow());
const { withState, getContext } = createStatefulMiddleware();
const workflow = withState(createWorkflow());
workflow.handle([startEvent], async () => {
const { sendEvent } = getContext();
setTimeout(() => {
@@ -236,6 +246,7 @@ describe("with snapshot - snapshot API", () => {
{
"missing": [],
"queue": [],
"state": undefined,
"unrecoverableQueue": [],
"version": "@@@0,,4~,,",
}
@@ -250,7 +261,8 @@ describe("with snapshot - snapshot API", () => {
});
test("onRequestEvent callback", async () => {
const workflow = withSnapshot(createWorkflow());
const { withState } = createStatefulMiddleware();
const workflow = withState(createWorkflow());
workflow.handle([startEvent], async () => {
return request(humanResponseEvent, 1);
});
+62 -12
View File
@@ -28,13 +28,16 @@ importers:
version: 3.6.1
tsdown:
specifier: ^0.12.9
version: 0.12.9(typescript@5.8.3)
version: 0.12.9(typescript@5.9.2)
turbo:
specifier: ^2.5.4
version: 2.5.4
typescript:
specifier: ^5.9.2
version: 5.9.2
vite-tsconfig-paths:
specifier: ^5.1.4
version: 5.1.4(typescript@5.8.3)(vite@7.0.0(@types/node@24.0.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0))
version: 5.1.4(typescript@5.9.2)(vite@7.0.0(@types/node@24.0.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0))
vitest:
specifier: ^3.2.4
version: 3.2.4(@edge-runtime/vm@5.0.0)(@types/node@24.0.4)(happy-dom@18.0.1)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)
@@ -235,7 +238,7 @@ importers:
version: link:../core
bunchee:
specifier: ^6.5.4
version: 6.5.4(typescript@5.8.3)
version: 6.5.4(typescript@5.9.2)
tests/cjs:
dependencies:
@@ -3910,6 +3913,11 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
typescript@5.9.2:
resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==}
engines: {node: '>=14.17'}
hasBin: true
ufo@1.6.1:
resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==}
@@ -5874,7 +5882,7 @@ snapshots:
buffer-from@1.1.2: {}
bunchee@6.5.4(typescript@5.8.3):
bunchee@6.5.4(typescript@5.9.2):
dependencies:
'@rollup/plugin-commonjs': 28.0.6(rollup@4.44.0)
'@rollup/plugin-json': 6.1.0(rollup@4.44.0)
@@ -5891,13 +5899,13 @@ snapshots:
picomatch: 4.0.2
pretty-bytes: 5.6.0
rollup: 4.44.0
rollup-plugin-dts: 6.2.1(rollup@4.44.0)(typescript@5.8.3)
rollup-plugin-dts: 6.2.1(rollup@4.44.0)(typescript@5.9.2)
rollup-plugin-swc3: 0.11.2(@swc/core@1.12.6(@swc/helpers@0.5.17))(rollup@4.44.0)
rollup-preserve-directives: 1.1.3(rollup@4.44.0)
tslib: 2.8.1
yargs: 17.7.2
optionalDependencies:
typescript: 5.8.3
typescript: 5.9.2
bundle-name@4.1.0:
dependencies:
@@ -7138,6 +7146,23 @@ snapshots:
- oxc-resolver
- supports-color
rolldown-plugin-dts@0.13.12(rolldown@1.0.0-beta.20)(typescript@5.9.2):
dependencies:
'@babel/generator': 7.27.5
'@babel/parser': 7.27.5
'@babel/types': 7.27.6
ast-kit: 2.1.0
birpc: 2.4.0
debug: 4.4.1
dts-resolver: 2.1.1
get-tsconfig: 4.10.1
rolldown: 1.0.0-beta.20
optionalDependencies:
typescript: 5.9.2
transitivePeerDependencies:
- oxc-resolver
- supports-color
rolldown@1.0.0-beta.20:
dependencies:
'@oxc-project/runtime': 0.75.0
@@ -7158,11 +7183,11 @@ snapshots:
'@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.20
'@rolldown/binding-win32-x64-msvc': 1.0.0-beta.20
rollup-plugin-dts@6.2.1(rollup@4.44.0)(typescript@5.8.3):
rollup-plugin-dts@6.2.1(rollup@4.44.0)(typescript@5.9.2):
dependencies:
magic-string: 0.30.17
rollup: 4.44.0
typescript: 5.8.3
typescript: 5.9.2
optionalDependencies:
'@babel/code-frame': 7.27.1
@@ -7571,9 +7596,9 @@ snapshots:
toidentifier@1.0.1: {}
tsconfck@3.1.5(typescript@5.8.3):
tsconfck@3.1.5(typescript@5.9.2):
optionalDependencies:
typescript: 5.8.3
typescript: 5.9.2
tsdown@0.12.9(typescript@5.8.3):
dependencies:
@@ -7598,6 +7623,29 @@ snapshots:
- supports-color
- vue-tsc
tsdown@0.12.9(typescript@5.9.2):
dependencies:
ansis: 4.1.0
cac: 6.7.14
chokidar: 4.0.3
debug: 4.4.1
diff: 8.0.2
empathic: 2.0.0
hookable: 5.5.3
rolldown: 1.0.0-beta.20
rolldown-plugin-dts: 0.13.12(rolldown@1.0.0-beta.20)(typescript@5.9.2)
semver: 7.7.2
tinyexec: 1.0.1
tinyglobby: 0.2.14
unconfig: 7.3.2
optionalDependencies:
typescript: 5.9.2
transitivePeerDependencies:
- '@typescript/native-preview'
- oxc-resolver
- supports-color
- vue-tsc
tslib@2.8.1: {}
tsx@4.20.3:
@@ -7644,6 +7692,8 @@ snapshots:
typescript@5.8.3: {}
typescript@5.9.2: {}
ufo@1.6.1: {}
uglify-js@3.19.3:
@@ -7709,11 +7759,11 @@ snapshots:
- tsx
- yaml
vite-tsconfig-paths@5.1.4(typescript@5.8.3)(vite@7.0.0(@types/node@24.0.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)):
vite-tsconfig-paths@5.1.4(typescript@5.9.2)(vite@7.0.0(@types/node@24.0.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)):
dependencies:
debug: 4.4.0
globrex: 0.1.2
tsconfck: 3.1.5(typescript@5.8.3)
tsconfck: 3.1.5(typescript@5.9.2)
optionalDependencies:
vite: 7.0.0(@types/node@24.0.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)
transitivePeerDependencies: