Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
e4c07f9
feat: support human in the loop for TS
thucpn Jun 9, 2025
eba44a3
add example for custom workflow
thucpn Jun 10, 2025
7875178
fix: need to request humanResponseEvent to save missing step to snapshot
thucpn Jun 10, 2025
ac5a1ef
refactor: human response data should be any
thucpn Jun 10, 2025
832f5b6
refactor runWorkflow function to support resume stream
thucpn Jun 10, 2025
23fdd51
refactor: hitl
thucpn Jun 10, 2025
7b21682
fix: workflow
thucpn Jun 10, 2025
ddccbcf
add summary event
thucpn Jun 10, 2025
45af254
send tool event
thucpn Jun 10, 2025
be894df
use requestId from Vercel
thucpn Jun 11, 2025
99ff5b4
Merge branch 'main' into tp/hitl-for-ts
thucpn Jun 11, 2025
2d31294
update chat route.ts
thucpn Jun 11, 2025
baf16fc
fix copy utils/*
thucpn Jun 11, 2025
98913ed
refactor: workflow and stream
thucpn Jun 11, 2025
d93ee94
Create eight-moons-perform.md
thucpn Jun 11, 2025
38cd475
update typo
thucpn Jun 11, 2025
0e67d8a
make schema simple
thucpn Jun 11, 2025
6851960
fix typo
thucpn Jun 11, 2025
537489a
use messages in startAgentEvent
thucpn Jun 11, 2025
a440a34
save to snapshots folder
thucpn Jun 11, 2025
0896824
fix lint
thucpn Jun 11, 2025
7e4c68b
feat: workflowBaseEvent
thucpn Jun 11, 2025
6a5db05
include response event in input event
thucpn Jun 12, 2025
8f107f5
simplify type
thucpn Jun 12, 2025
2c062c9
update readme
thucpn Jun 12, 2025
af47dbb
update document
thucpn Jun 12, 2025
c5c72f5
fix typecheck
thucpn Jun 12, 2025
9b3c1ad
bump: "@llamaindex/workflow": "~1.1.8"
thucpn Jun 12, 2025
66b8db6
remove any
thucpn Jun 12, 2025
22cd865
use fixed tsx version to fix e2e
thucpn Jun 12, 2025
7ee59c5
fix wrong copy
thucpn Jun 12, 2025
5a16f10
add cli hitl examples as a use case for both Python and TS
thucpn Jun 12, 2025
159d15d
update changeset to release create-llama also
thucpn Jun 12, 2025
10fcf50
fix e2e
thucpn Jun 12, 2025
9175ad9
fix e2e
thucpn Jun 12, 2025
d4c822d
hitl frontend chat
thucpn Jun 12, 2025
5b06106
try disable hitl test
thucpn Jun 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions packages/server/src/handlers/chat.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import type { AgentInputData } from "@llamaindex/workflow";
import type { AgentInputData, WorkflowContext } from "@llamaindex/workflow";
import { type Message } from "ai";
import { IncomingMessage, ServerResponse } from "http";
import type { MessageType } from "llamaindex";
import { type WorkflowFactory } from "../types";
import {
createWorkflowContextFromHumanResponse,
getHumanResponseFromMessage,
pauseForHumanInput,
} from "../utils/hitl";
import {
parseRequestBody,
pipeStreamToResponse,
Expand All @@ -19,6 +24,7 @@ export const handleChat = async (
suggestNextQuestions: boolean,
) => {
try {
const requestId = req.headers["x-request-id"] as string; // TODO: update for chat route also
const body = await parseRequestBody(req);
const { messages } = body as { messages: Message[] };
const chatHistory = messages.map((message) => ({
Expand All @@ -41,14 +47,30 @@ export const handleChat = async (
res.on("close", () => abortController.abort("Connection closed"));

const workflow = await workflowFactory(body);
let context: WorkflowContext;

// if there is human response, we need to create a workflow context from the human response
// otherwise, we can start with empty context
const humanResponse = getHumanResponseFromMessage(lastMessage);
if (humanResponse) {
context = await createWorkflowContextFromHumanResponse(
workflow,
requestId,
humanResponse,
);
} else {
context = workflow.createContext();
}
Comment thread
thucpn marked this conversation as resolved.
Outdated

const workflowEventStream = await runWorkflow(
workflow,
context,
workflowInput,
abortController.signal,
);

const dataStream = toDataStream(workflowEventStream, {
callbacks: {
onPauseForHumanInput: () => pauseForHumanInput(context, requestId),
Comment thread
thucpn marked this conversation as resolved.
Outdated
onFinal: async (completion, dataStreamWriter) => {
chatHistory.push({
role: "assistant" as MessageType,
Expand Down
113 changes: 113 additions & 0 deletions packages/server/src/utils/hitl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/* eslint-disable @typescript-eslint/no-explicit-any */

import {
withSnapshot,
workflowEvent,
type Workflow,
type WorkflowContext,
} from "@llamaindex/workflow";
import type { Message } from "ai";
import { z } from "zod";

// @llama-flow doesn't export snapshot types, we need to infer them from the functions
export type SnapshotWorkflow = ReturnType<typeof withSnapshot<Workflow>>;
export type SnapshotWorkflowContext = ReturnType<
SnapshotWorkflow["createContext"]
>;

export const serializableMemoryMap = new Map<string, any>();

export const saveSnapshot = async (requestId: string, snapshot: any) => {
// TODO: save to file
serializableMemoryMap.set(requestId, snapshot);
};

export const loadSnapshot = async (
requestId: string,
): Promise<any | undefined> => {
return serializableMemoryMap.get(requestId);
};
Comment thread
thucpn marked this conversation as resolved.
Outdated

export type HumanInputEventData = {
event_type: string; // An identifier for the input component in UI
data: unknown; // The data to be sent to the input component in UI
};

// humanInputEvent should be triggered when workflow need to request input from user
// when it is emitted, workflow snapshot will be saved and stream will be paused
// then send HumanInputEventData as annotation to UI to render the input form
export const humanInputEvent = workflowEvent<HumanInputEventData>();

// TODO: we can consider sending JSON Schema for the requested information from user
// Then render it as a form in UI with https://github.com/rjsf-team/react-jsonschema-form
// we can call it formInputEvent (same logic as humanInputEvent but useful when requesting multiple inputs)

export type HumanResponseEventData = {
data: unknown; // The response data from user
};

// When user make a response to the input request, workflow will be re-created from the last snapshot
// and then trigger humanResponseEvent to resume the workflow
export const humanResponseEvent = workflowEvent<HumanResponseEventData>();

export const humanResponseAnnotationSchema = z.object({
type: z.literal("human_response"),
data: z.any(),
});

export const getHumanResponseFromMessage = (message: Message) => {
if (message.annotations) {
for (const annotation of message.annotations) {
if (humanResponseAnnotationSchema.safeParse(annotation).success) {
return (annotation as z.infer<typeof humanResponseAnnotationSchema>)
.data;
}
}
}
return null;
};
Comment thread
thucpn marked this conversation as resolved.
Outdated

export const createWorkflowContextFromHumanResponse = async (
workflow: Workflow,
requestId: string,
humanResponse: any,
): Promise<SnapshotWorkflowContext> => {
// check workflow is snapshotable
if (!("resume" in workflow)) {
// TODO: ensure AgentWorkflow is snapshotable
throw new Error(
"Workflow is not a snapshot workflow. Please use withSnapshot() to make it snapshotable.",
);
}

// if there is no snapshot, we can't resume the workflow
const snapshot = await loadSnapshot(requestId);
if (!snapshot) {
throw new Error("No snapshot found for request id: " + requestId);
}

// resume the workflow from the snapshot with human response
const context = (workflow as SnapshotWorkflow).resume(
humanResponse,
snapshot,
);

return context;
};
Comment thread
thucpn marked this conversation as resolved.
Outdated

export const pauseForHumanInput = async (
context: WorkflowContext,
requestId: string,
) => {
if (!("snapshot" in context)) {
// check workflow is snapshotable
throw new Error(
"Cannot get snapshot of the workflow. Please use withSnapshot() to make it snapshotable.",
);
}

// save snapshot with the key is requestId
const snapshotContext = context as SnapshotWorkflowContext;
const snapshot = await snapshotContext.snapshot();
await saveSnapshot(requestId, snapshot);
};
Comment thread
thucpn marked this conversation as resolved.
Outdated
1 change: 1 addition & 0 deletions packages/server/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from "./events";
export * from "./file";
export * from "./gen-ui";
export * from "./hitl";
export * from "./inline";
export * from "./prompts";
export * from "./request";
Expand Down
10 changes: 10 additions & 0 deletions packages/server/src/utils/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type DataStreamWriter,
type JSONValue,
} from "ai";
import { humanInputEvent, type HumanInputEventData } from "./hitl";

/**
* Configuration options and helper callback methods for stream lifecycle events.
Expand All @@ -24,6 +25,9 @@ export interface StreamCallbacks {
text: string,
dataStreamWriter: DataStreamWriter,
) => Promise<void> | void;

/** `onPauseForHumanInput`: Called when human input event is emitted. */
onPauseForHumanInput?: (event: HumanInputEventData) => Promise<void> | void;
}

/**
Expand Down Expand Up @@ -61,6 +65,12 @@ export function toDataStream(
await callbacks.onText(content, dataStreamWriter);
}
}
} else if (humanInputEvent.include(event)) {
if (callbacks?.onPauseForHumanInput) {
await callbacks.onPauseForHumanInput(event.data);
}
dataStreamWriter.writeMessageAnnotation(event.data as JSONValue);
break; // break to stop the stream
} else {
dataStreamWriter.writeMessageAnnotation(event.data as JSONValue);
}
Expand Down
10 changes: 5 additions & 5 deletions packages/server/src/utils/workflow.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import {
agentToolCallEvent,
agentToolCallResultEvent,
run,
startAgentEvent,
stopAgentEvent,
WorkflowStream,
type AgentInputData,
type Workflow,
type WorkflowContext,
type WorkflowEventData,
} from "@llamaindex/workflow";
import {
Expand All @@ -25,19 +24,20 @@ import { downloadFile } from "./file";
import { toInlineAnnotationEvent } from "./inline";

export async function runWorkflow(
workflow: Workflow,
context: WorkflowContext,
input: AgentInputData,
abortSignal?: AbortSignal,
): Promise<WorkflowStream<WorkflowEventData<unknown>>> {
Comment thread
thucpn marked this conversation as resolved.
Outdated
if (!input.userInput) {
throw new Error("Missing user input to start the workflow");
}
const workflowStream = run(workflow, [
context.sendEvent(
startAgentEvent.with({
userInput: input.userInput,
chatHistory: input.chatHistory,
}),
]);
);
const workflowStream = context.stream;

// Transform the stream to handle annotations
return processWorkflowStream(workflowStream).until(
Expand Down