Skip to content

Commit 7b3141d

Browse files
authored
feat: langgraph interrupt hook (CopilotKit#1273)
1 parent dc6078c commit 7b3141d

44 files changed

Lines changed: 840 additions & 227 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@copilotkit/react-core": patch
3+
"@copilotkit/react-ui": patch
4+
"@copilotkit/runtime-client-gql": patch
5+
"@copilotkit/runtime": patch
6+
"@copilotkit/shared": patch
7+
---
8+
9+
- feat(interrupt): support LG interrupt with useLangGraphInterrupt hook
10+
- chore(interrupt): add e2e test to interrupt functionality
11+
- feat(interrupt): add support for multiple interrupts and conditions

CopilotKit/packages/react-core/src/components/copilot-provider/copilotkit.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ import { useCopilotRuntimeClient } from "../../hooks/use-copilot-runtime-client"
4545
import { shouldShowDevConsole } from "../../utils";
4646
import { CopilotErrorBoundary } from "../error-boundary/error-boundary";
4747
import { Agent, ExtensionsInput } from "@copilotkit/runtime-client-gql";
48+
import {
49+
LangGraphInterruptAction,
50+
LangGraphInterruptActionSetterArgs,
51+
} from "../../types/interrupt-action";
4852

4953
export function CopilotKit({ children, ...props }: CopilotKitProps) {
5054
const showDevConsole = props.showDevConsole === undefined ? "auto" : props.showDevConsole;
@@ -337,6 +341,24 @@ export function CopilotKitInternal(cpkProps: CopilotKitProps) {
337341

338342
const showDevConsole = props.showDevConsole === undefined ? "auto" : props.showDevConsole;
339343

344+
const [langGraphInterruptAction, _setLangGraphInterruptAction] =
345+
useState<LangGraphInterruptAction | null>(null);
346+
const setLangGraphInterruptAction = useCallback((action: LangGraphInterruptActionSetterArgs) => {
347+
_setLangGraphInterruptAction((prev) => {
348+
if (prev == null) return action as LangGraphInterruptAction;
349+
if (action == null) return null;
350+
let event = prev.event;
351+
if (action.event) {
352+
// @ts-ignore
353+
event = { ...prev.event, ...action.event };
354+
}
355+
return { ...prev, ...action, event };
356+
});
357+
}, []);
358+
const removeLangGraphInterruptAction = useCallback((): void => {
359+
setLangGraphInterruptAction(null);
360+
}, []);
361+
340362
return (
341363
<CopilotContext.Provider
342364
value={{
@@ -383,6 +405,9 @@ export function CopilotKitInternal(cpkProps: CopilotKitProps) {
383405
setAuthStates_c: setAuthStates,
384406
extensions,
385407
setExtensions,
408+
langGraphInterruptAction,
409+
setLangGraphInterruptAction,
410+
removeLangGraphInterruptAction,
386411
}}
387412
>
388413
<CopilotMessages>{children}</CopilotMessages>

CopilotKit/packages/react-core/src/context/copilot-context.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ import {
1616
ForwardedParametersInput,
1717
} from "@copilotkit/runtime-client-gql";
1818
import { Agent } from "@copilotkit/runtime-client-gql";
19+
import {
20+
LangGraphInterruptAction,
21+
LangGraphInterruptActionSetter,
22+
} from "../types/interrupt-action";
1923

2024
/**
2125
* Interface for the configuration of the Copilot API.
@@ -77,9 +81,8 @@ export interface CopilotApiConfig {
7781
credentials?: RequestCredentials;
7882
}
7983

80-
export type InChatRenderFunction = (
81-
props: ActionRenderProps<any> | CatchAllActionRenderProps<any>,
82-
) => string | JSX.Element;
84+
export type InChatRenderFunction<TProps = ActionRenderProps<any> | CatchAllActionRenderProps<any>> =
85+
(props: TProps) => string | JSX.Element;
8386
export type CoagentInChatRenderFunction = (
8487
props: CoAgentStateRenderProps<any>,
8588
) => string | JSX.Element | undefined | null;
@@ -200,6 +203,9 @@ export interface CopilotContextParams {
200203

201204
extensions: ExtensionsInput;
202205
setExtensions: React.Dispatch<React.SetStateAction<ExtensionsInput>>;
206+
langGraphInterruptAction: LangGraphInterruptAction | null;
207+
setLangGraphInterruptAction: LangGraphInterruptActionSetter;
208+
removeLangGraphInterruptAction: () => void;
203209
}
204210

205211
const emptyCopilotContext: CopilotContextParams = {
@@ -263,6 +269,9 @@ const emptyCopilotContext: CopilotContextParams = {
263269
availableAgents: [],
264270
extensions: {},
265271
setExtensions: () => {},
272+
langGraphInterruptAction: null,
273+
setLangGraphInterruptAction: () => null,
274+
removeLangGraphInterruptAction: () => null,
266275
};
267276

268277
export const CopilotContext = React.createContext<CopilotContextParams>(emptyCopilotContext);

CopilotKit/packages/react-core/src/hooks/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,5 @@ export { useCopilotReadable } from "./use-copilot-readable";
1010
export { useCoAgent, type HintFunction, runAgent, startAgent, stopAgent } from "./use-coagent";
1111
export { useCopilotRuntimeClient } from "./use-copilot-runtime-client";
1212
export { useCopilotAuthenticatedAction_c } from "./use-copilot-authenticated-action";
13+
export { useLangGraphInterrupt } from "./use-langgraph-interrupt";
14+
export { useLangGraphInterruptRender } from "./use-langgraph-interrupt-render";

CopilotKit/packages/react-core/src/hooks/use-chat.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useRef } from "react";
1+
import React, { useCallback, useRef } from "react";
22
import {
33
FunctionCallHandler,
44
COPILOT_CLOUD_PUBLIC_API_KEY_HEADER,
@@ -21,6 +21,7 @@ import {
2121
loadMessagesFromJsonRepresentation,
2222
ExtensionsInput,
2323
CopilotRuntimeClient,
24+
langGraphInterruptEvent,
2425
} from "@copilotkit/runtime-client-gql";
2526

2627
import { CopilotApiConfig } from "../context";
@@ -29,6 +30,11 @@ import { CoagentState } from "../types/coagent-state";
2930
import { AgentSession } from "../context/copilot-context";
3031
import { useCopilotRuntimeClient } from "./use-copilot-runtime-client";
3132
import { useAsyncCallback, useErrorToast } from "../components/error-boundary/error-utils";
33+
import {
34+
LangGraphInterruptAction,
35+
LangGraphInterruptActionSetter,
36+
} from "../types/interrupt-action";
37+
import { MetaEvent, MetaEventName } from "@copilotkit/runtime-client-gql";
3238

3339
export type UseChatOptions = {
3440
/**
@@ -138,6 +144,10 @@ export type UseChatOptions = {
138144
* The setState-powered method to update the extensions.
139145
*/
140146
setExtensions: React.Dispatch<React.SetStateAction<ExtensionsInput>>;
147+
148+
langGraphInterruptAction: LangGraphInterruptAction | null;
149+
150+
setLangGraphInterruptAction: LangGraphInterruptActionSetter;
141151
};
142152

143153
export type UseChatHelpers = {
@@ -195,6 +205,8 @@ export function useChat(options: UseChatOptions): UseChatHelpers {
195205
agentLock,
196206
extensions,
197207
setExtensions,
208+
langGraphInterruptAction,
209+
setLangGraphInterruptAction,
198210
} = options;
199211
const runChatCompletionRef = useRef<(previousMessages: Message[]) => Promise<Message[]>>();
200212
const addErrorToast = useErrorToast();
@@ -256,6 +268,7 @@ export function useChat(options: UseChatOptions): UseChatHelpers {
256268
threadId: threadId,
257269
runId: runIdRef.current,
258270
extensions: extensionsRef.current,
271+
metaEvents: composeAndFlushMetaEventsInput([langGraphInterruptAction?.event]),
259272
messages: convertMessagesToGqlInput(filterAgentStateMessages(messagesWithContext)),
260273
...(copilotConfig.cloud
261274
? {
@@ -344,6 +357,14 @@ export function useChat(options: UseChatOptions): UseChatHelpers {
344357
filterAdjacentAgentStateMessages(value.generateCopilotResponse.messages),
345358
);
346359

360+
(value.generateCopilotResponse?.metaEvents ?? []).forEach((ev) => {
361+
if (ev.name === "LangGraphInterruptEvent") {
362+
setLangGraphInterruptAction({
363+
event: langGraphInterruptEvent(ev),
364+
});
365+
}
366+
});
367+
347368
if (messages.length === 0) {
348369
continue;
349370
}
@@ -608,6 +629,36 @@ export function useChat(options: UseChatOptions): UseChatHelpers {
608629
[messages],
609630
);
610631

632+
// Go over all events and see that they include data that should be returned to the agent
633+
const composeAndFlushMetaEventsInput = useCallback(
634+
(metaEvents: (MetaEvent | undefined | null)[]) => {
635+
return metaEvents.reduce((acc: MetaEvent[], event) => {
636+
if (!event) return acc;
637+
638+
switch (event.name) {
639+
case MetaEventName.LangGraphInterruptEvent:
640+
if (event.response) {
641+
// Flush interrupt event from state
642+
setLangGraphInterruptAction(null);
643+
return [
644+
...acc,
645+
{
646+
type: event.type,
647+
name: event.name,
648+
value: event.value,
649+
response: event.response,
650+
},
651+
];
652+
}
653+
return acc;
654+
default:
655+
return acc;
656+
}
657+
}, []);
658+
},
659+
[setLangGraphInterruptAction],
660+
);
661+
611662
const append = useAsyncCallback(
612663
async (message: Message, options?: AppendMessageOptions): Promise<void> => {
613664
if (isLoading) {

CopilotKit/packages/react-core/src/hooks/use-copilot-chat.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ export function useCopilotChat({
112112
chatAbortControllerRef,
113113
extensions,
114114
setExtensions,
115+
langGraphInterruptAction,
116+
setLangGraphInterruptAction,
115117
} = useCopilotContext();
116118
const { messages, setMessages } = useCopilotMessagesContext();
117119

@@ -179,6 +181,8 @@ export function useCopilotChat({
179181
agentLock,
180182
extensions,
181183
setExtensions,
184+
langGraphInterruptAction,
185+
setLangGraphInterruptAction,
182186
});
183187

184188
// this is a workaround born out of a bug that Athena incessantly ran into.
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { useCopilotContext } from "../context";
2+
import React, { useCallback } from "react";
3+
import { executeConditions } from "@copilotkit/shared";
4+
5+
type InterruptProps = {
6+
event: any;
7+
result: any;
8+
render: (props: {
9+
event: any;
10+
result: any;
11+
resolve: (response: string) => void;
12+
}) => string | React.ReactElement;
13+
resolve: (response: string) => void;
14+
};
15+
16+
const InterruptRenderer: React.FC<InterruptProps> = ({ event, result, render, resolve }) => {
17+
return render({ event, result, resolve });
18+
};
19+
20+
export function useLangGraphInterruptRender(): string | React.ReactElement | null {
21+
const { langGraphInterruptAction, setLangGraphInterruptAction } = useCopilotContext();
22+
23+
const responseRef = React.useRef<string>();
24+
const resolveInterrupt = useCallback(
25+
(response: string) => {
26+
responseRef.current = response;
27+
// Use setTimeout to defer the state update to next tick
28+
setTimeout(() => {
29+
setLangGraphInterruptAction({ event: { response } });
30+
}, 0);
31+
},
32+
[setLangGraphInterruptAction],
33+
);
34+
35+
if (
36+
!langGraphInterruptAction ||
37+
!langGraphInterruptAction.event ||
38+
!langGraphInterruptAction.render
39+
)
40+
return null;
41+
42+
const { render, handler, event, conditions } = langGraphInterruptAction;
43+
44+
const conditionsMet = executeConditions({ conditions, value: event.value });
45+
if (!conditionsMet) {
46+
return null;
47+
}
48+
49+
let result = null;
50+
if (handler) {
51+
result = handler({
52+
event,
53+
resolve: resolveInterrupt,
54+
});
55+
}
56+
57+
return React.createElement(InterruptRenderer, {
58+
event,
59+
result,
60+
render,
61+
resolve: resolveInterrupt,
62+
});
63+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { useContext, useEffect, useMemo } from "react";
2+
import { CopilotContext } from "../context/copilot-context";
3+
import { LangGraphInterruptRender } from "../types/interrupt-action";
4+
import { useCopilotChat } from "./use-copilot-chat";
5+
import { useToast } from "../components/toast/toast-provider";
6+
import { dataToUUID } from "@copilotkit/shared";
7+
8+
export function useLangGraphInterrupt(
9+
action: Omit<LangGraphInterruptRender, "id">,
10+
dependencies?: any[],
11+
) {
12+
const { setLangGraphInterruptAction, removeLangGraphInterruptAction, langGraphInterruptAction } =
13+
useContext(CopilotContext);
14+
const { runChatCompletion } = useCopilotChat();
15+
const { addToast } = useToast();
16+
17+
const actionId = dataToUUID(JSON.stringify(action), "lgAction");
18+
// We only consider action to be defined once the ID is there
19+
const hasAction = useMemo(
20+
() => Boolean(langGraphInterruptAction?.id),
21+
[langGraphInterruptAction],
22+
);
23+
24+
const isCurrentAction = useMemo(
25+
() => langGraphInterruptAction?.id && langGraphInterruptAction?.id === actionId,
26+
[langGraphInterruptAction],
27+
);
28+
29+
// Run chat completion to submit a response event. Only if it's the current action
30+
useEffect(() => {
31+
if (hasAction && isCurrentAction && langGraphInterruptAction?.event?.response) {
32+
runChatCompletion();
33+
}
34+
}, [langGraphInterruptAction?.event?.response, runChatCompletion, hasAction, isCurrentAction]);
35+
36+
useEffect(() => {
37+
if (!action) return;
38+
// console.log(2, { hasAction, isCurrentAction, conditions: action.conditions })
39+
// An action was already set, with no conditions and it's not the action we're using right now.
40+
// Show a warning, as this action will not be executed
41+
if (hasAction && !isCurrentAction && !action.conditions?.length) {
42+
addToast({
43+
type: "warning",
44+
message: "An action is already registered for the interrupt event",
45+
});
46+
return;
47+
}
48+
49+
if (hasAction && isCurrentAction) {
50+
return;
51+
}
52+
53+
setLangGraphInterruptAction({ ...action, id: actionId });
54+
}, [
55+
action,
56+
hasAction,
57+
isCurrentAction,
58+
setLangGraphInterruptAction,
59+
removeLangGraphInterruptAction,
60+
...(dependencies || []),
61+
]);
62+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { LangGraphInterruptEvent } from "@copilotkit/runtime-client-gql";
2+
import { Condition } from "@copilotkit/shared";
3+
4+
export interface LangGraphInterruptRender {
5+
id: string;
6+
/**
7+
* The handler function to handle the event.
8+
*/
9+
handler?: (props: {
10+
event: LangGraphInterruptEvent;
11+
resolve: (resolution: string) => void;
12+
}) => unknown | Promise<unknown>;
13+
/**
14+
* The render function to handle the event.
15+
*/
16+
render?: (props: {
17+
result: unknown;
18+
event: LangGraphInterruptEvent;
19+
resolve: (resolution: string) => void;
20+
}) => string | React.ReactElement;
21+
/**
22+
* Conditions to render based on.
23+
* Useful when using multiple interrupts
24+
*/
25+
conditions?: Condition[];
26+
}
27+
28+
export type LangGraphInterruptAction = LangGraphInterruptRender & {
29+
event?: LangGraphInterruptEvent;
30+
};
31+
32+
export type LangGraphInterruptActionSetterArgs =
33+
| (Partial<LangGraphInterruptRender> & { event?: Partial<LangGraphInterruptEvent> })
34+
| null;
35+
export type LangGraphInterruptActionSetter = (action: LangGraphInterruptActionSetterArgs) => void;

0 commit comments

Comments
 (0)