Skip to content

Commit fea916f

Browse files
authored
feat: support input and output schema of langgraph (CopilotKit#1406)
1 parent d1a231d commit fea916f

5 files changed

Lines changed: 183 additions & 2 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@copilotkit/runtime": minor
3+
---
4+
5+
- feat: support input and output schema of langgraph
6+
- docs: add input output schema docs

CopilotKit/packages/runtime/src/lib/runtime/remote-lg-action.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Client as LangGraphClient } from "@langchain/langgraph-sdk";
1+
import { AssistantGraph, Client as LangGraphClient, GraphSchema } from "@langchain/langgraph-sdk";
22
import { createHash } from "node:crypto";
33
import { isValidUUID, randomUUID } from "@copilotkit/shared";
44
import { parse as parsePartialJson } from "partial-json";
@@ -204,6 +204,15 @@ async function streamEvents(controller: ReadableStreamDefaultController, args: E
204204
await client.assistants.update(assistantId, { config: { configurable } });
205205
}
206206
const graphInfo = await client.assistants.getGraph(assistantId);
207+
const graphSchema = await client.assistants.getSchemas(assistantId);
208+
const schemaKeys = getSchemaKeys(graphSchema);
209+
210+
// Do not input keys that are not part of the input schema
211+
if (payload.input && schemaKeys.input) {
212+
payload.input = Object.fromEntries(
213+
Object.entries(payload.input).filter(([key]) => schemaKeys.input.includes(key)),
214+
);
215+
}
207216

208217
let streamingStateExtractor = new StreamingStateExtractor([]);
209218
let prevNodeName = null;
@@ -330,6 +339,7 @@ async function streamEvents(controller: ReadableStreamDefaultController, args: E
330339
state: manuallyEmittedState,
331340
running: true,
332341
active: true,
342+
schemaKeys,
333343
}),
334344
);
335345
continue;
@@ -380,6 +390,7 @@ async function streamEvents(controller: ReadableStreamDefaultController, args: E
380390
state,
381391
running: true,
382392
active: !exitingNode,
393+
schemaKeys,
383394
}),
384395
);
385396
}
@@ -404,6 +415,7 @@ async function streamEvents(controller: ReadableStreamDefaultController, args: E
404415
running: !shouldExit,
405416
active: false,
406417
includeMessages: true,
418+
schemaKeys,
407419
}),
408420
);
409421

@@ -427,6 +439,7 @@ function getStateSyncEvent({
427439
running,
428440
active,
429441
includeMessages = false,
442+
schemaKeys,
430443
}: {
431444
threadId: string;
432445
runId: string;
@@ -436,6 +449,7 @@ function getStateSyncEvent({
436449
running: boolean;
437450
active: boolean;
438451
includeMessages?: boolean;
452+
schemaKeys: { input: string[] | null; output: string[] | null };
439453
}): string {
440454
if (!includeMessages) {
441455
state = Object.keys(state).reduce((acc, key) => {
@@ -451,6 +465,13 @@ function getStateSyncEvent({
451465
};
452466
}
453467

468+
// Do not emit state keys that are not part of the output schema
469+
if (schemaKeys.output) {
470+
state = Object.fromEntries(
471+
Object.entries(state).filter(([key]) => schemaKeys.output.includes(key)),
472+
);
473+
}
474+
454475
return (
455476
JSON.stringify({
456477
event: LangGraphEventTypes.OnCopilotKitStateSync,
@@ -739,3 +760,14 @@ function copilotkitMessagesToLangChain(messages: Message[]): LangGraphPlatformMe
739760

740761
return result;
741762
}
763+
764+
function getSchemaKeys(graphSchema: GraphSchema) {
765+
const CONSTANT_KEYS = ["messages", "copilotkit"];
766+
const inputSchema = Object.keys(graphSchema.input_schema.properties);
767+
const outputSchema = Object.keys(graphSchema.output_schema.properties);
768+
769+
return {
770+
input: inputSchema && inputSchema.length ? [...inputSchema, ...CONSTANT_KEYS] : null,
771+
output: outputSchema && outputSchema.length ? [...outputSchema, ...CONSTANT_KEYS] : null,
772+
};
773+
}

docs/content/docs/coagents/shared-state/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"pages": [
33
"in-app-agent-read",
44
"in-app-agent-write",
5+
"state-inputs-outputs",
56
"predictive-state-updates"
67
]
78
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
---
2+
title: Agent state inputs and outputs
3+
icon: "lucide/ArrowRightLeft"
4+
description: Decide which state properties are received and returned to the frontend
5+
---
6+
7+
## What is this?
8+
9+
Not all state properties are relevant for frontend-backend sharing.
10+
This guide shows how to ensure only the right portion of state is communicated back and forth.
11+
12+
This guide is based on [LangGraph's Input/Output Schema feature](https://langchain-ai.github.io/langgraph/how-tos/input_output_schema/)
13+
14+
## When should I use this?
15+
16+
Depending on your implementation, some properties are meant to be processed internally, while some others are the way for the UI to communicate user input.
17+
In addition, some state properties contain a lot of information. Syncing them back and forth between the agent and UI can be costly, while it might not have any practical benefit.
18+
19+
## Implementation
20+
21+
<Callout>
22+
Due to LangGraph support, this feature is currently only available for Python agents
23+
</Callout>
24+
25+
<Steps>
26+
<Step>
27+
### Examine our old state
28+
LangGraph is stateful. As you transition between nodes, that state is updated and passed to the next node. For this example,
29+
let's assume that the state our agent should be using, can be described like this:
30+
```python title="agent-py/sample_agent/agent.py"
31+
from copilotkit import CopilotKitState
32+
from typing import Literal
33+
34+
class AgentState(CopilotKitState):
35+
question: str
36+
answer: str
37+
resources: List[str]
38+
```
39+
</Step>
40+
<Step>
41+
### Divide state to Input and Output
42+
Our example case lists several state properties, which with its own purpose:
43+
- The question is being asked by the user, expecting the llm to answer
44+
- The answer is what the LLM returns
45+
- The resources list will be used by the LLM to answer the question, and should not be communicated to the user, or set by them.
46+
47+
```python title="agent-py/sample_agent/agent.py"
48+
from copilotkit import CopilotKitState
49+
from typing import Literal
50+
51+
# divide the state into 3
52+
class InputState(CopilotKitState):
53+
question: str
54+
55+
class OutputState(CopilotKitState):
56+
answer: str
57+
58+
class OverallState(InputState, OutputState):
59+
resources: List[str]
60+
61+
# ...add the rest of the agent implementation
62+
63+
# finally, before compiling the graph, we define the 3 state components
64+
builder = StateGraph(OverallState, input=InputState, output=OutputState)
65+
66+
# add all the different nodes and edges and compile the graph
67+
builder.add_node(answer_node)
68+
builder.add_edge(START, "answer_node")
69+
builder.add_edge("answer_node", END)
70+
graph = builder.compile()
71+
```
72+
</Step>
73+
<Step>
74+
### Give it a try!
75+
Now that we know which state properties our agent emits, we can inspect the state and expect the following to happen:
76+
- While we are able to provide a question, we will not receive it back from the agent. If we are using it in our UI, we need to remember the UI is the source of truth for it
77+
- Answer will change once it's returned back from the agent
78+
- The UI has no access to resources.
79+
80+
```tsx
81+
import { useCoAgent } from "@copilotkit/react-core";
82+
83+
type AgentState = {
84+
question: string;
85+
answer: string;
86+
}
87+
88+
const { state } = useCoAgent<AgentState>({
89+
name: "sample_agent",
90+
initialState: {
91+
question: "How's is the weather in SF?",
92+
}
93+
});
94+
95+
console.log(state) // You can expect seeing "answer" change, while the others are not returned from the agent
96+
```
97+
</Step>
98+
</Steps>

sdk-python/copilotkit/langgraph_agent.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import uuid
44
import json
5-
from typing import Optional, List, Callable, Any, cast, Union, TypedDict
5+
from typing import Optional, List, Callable, Any, cast, Union, TypedDict, Literal
66
from typing_extensions import NotRequired
77

88
from langgraph.graph.graph import CompiledGraph
@@ -191,6 +191,7 @@ def _emit_state_sync_event(
191191
active: bool,
192192
include_messages: bool = False
193193
):
194+
# First handle messages as before
194195
if not include_messages:
195196
state = {
196197
k: v for k, v in state.items() if k != "messages"
@@ -201,6 +202,9 @@ def _emit_state_sync_event(
201202
"messages": langchain_messages_to_copilotkit(state.get("messages", []))
202203
}
203204

205+
# Filter by schema keys if available
206+
state = self.filter_state_on_schema_keys(state, 'output')
207+
204208
return langchain_dumps({
205209
"event": "on_copilotkit_state_sync",
206210
"thread_id": thread_id,
@@ -292,6 +296,12 @@ async def _stream_events( # pylint: disable=too-many-locals
292296
# Use provided input or fallback to initial_state
293297
stream_input = resume_input if resume_input else initial_state
294298

299+
# Get the output and input schema keys the user has allowed for this graph
300+
input_keys, output_keys = self.get_schema_keys(config)
301+
self.output_schema_keys = output_keys
302+
self.input_schema_keys = input_keys
303+
304+
stream_input = self.filter_state_on_schema_keys(stream_input, 'input')
295305
async for event in self.graph.astream_events(stream_input, config, version="v2"):
296306
current_node_name = event.get("name")
297307
event_type = event.get("event")
@@ -457,6 +467,40 @@ def dict_repr(self):
457467
'type': 'langgraph'
458468
}
459469

470+
def get_schema_keys(self, config):
471+
CONSTANT_KEYS = ['copilotkit', 'messages']
472+
try:
473+
graph = self.graph.get_graph(config)
474+
end_node = graph.nodes["__end__"]
475+
start_node = graph.nodes["__start__"]
476+
input_schema = start_node.data.schema()
477+
output_schema = end_node.data.schema()
478+
input_schema_keys = list(input_schema["properties"].keys())
479+
output_schema_keys = list(output_schema["properties"].keys())
480+
481+
# We add "copilotkit" and "messages" as they are always sent and received.
482+
for key in CONSTANT_KEYS:
483+
if key not in input_schema_keys:
484+
input_schema_keys.append(key)
485+
if key not in output_schema_keys:
486+
output_schema_keys.append(key)
487+
488+
return input_schema_keys, output_schema_keys
489+
except Exception:
490+
return None
491+
492+
def filter_state_on_schema_keys(self, state, schema_type: Literal["input", "output"]):
493+
try:
494+
schema_keys_name = f"{schema_type}_schema_keys"
495+
if hasattr(self, schema_keys_name) and getattr(self, schema_keys_name):
496+
return {
497+
k: v for k, v in state.items()
498+
if k in getattr(self, schema_keys_name) or k == "messages"
499+
}
500+
except Exception:
501+
return state
502+
503+
460504
class _StreamingStateExtractor:
461505
def __init__(self, emit_intermediate_state: List[dict]):
462506
self.emit_intermediate_state = emit_intermediate_state

0 commit comments

Comments
 (0)