Merge branch 'main' into fix-examples-link-readme

This commit is contained in:
Diwakar Gupta
2025-05-17 13:56:41 +05:30
committed by GitHub
17 changed files with 1990 additions and 1897 deletions
+3
View File
@@ -27,6 +27,9 @@ jobs:
uses: astral-sh/setup-uv@v6
with:
python-version: ${{ matrix.python-version }}
enable-cache: true
cache-suffix: "cli-integration-test"
ignore-nothing-to-cache: true
- name: Setup env
if: steps.changed-files.outputs.all
working-directory: libs/cli/examples
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
enable-cache: true
cache-siffix: test-${{ inputs.working-directory }}
cache-suffix: test-${{ inputs.working-directory }}
- name: Login to Docker Hub
uses: docker/login-action@v3
if: ${{ !github.event.pull_request.head.repo.fork }}
+1
View File
@@ -70,6 +70,7 @@ REDIRECT_MAP = {
"cloud/faq/studio.md": "concepts/langgraph_studio.md#studio-faqs",
"cloud/how-tos/human_in_the_loop_edit_state.md": "cloud/how-tos/add-human-in-the-loop.md",
"cloud/how-tos/human_in_the_loop_user_input.md": "cloud/how-tos/add-human-in-the-loop.md",
"concepts/platform_architecture.md": "langgraph/concepts/langgraph_cloud#architecture",
# cloud streaming redirects
"cloud/how-tos/stream_values.md": "cloud/how-tos/streaming.md#stream-graph-state",
"cloud/how-tos/stream_updates.md": "cloud/how-tos/streaming.md#stream-graph-state",
+10 -8
View File
@@ -71,7 +71,7 @@ Basic usage example:
| [`values`](#stream-graph-state) | Streams the full value of the state after each step of the graph. |
| [`updates`](#stream-graph-state) | Streams the updates to the state after each step of the graph. If multiple updates are made in the same step (e.g., multiple nodes are run), those updates are streamed separately. |
| [`custom`](#stream-custom-data) | Streams custom data from inside your graph nodes. |
| [`messages`](#messages) | Streams LLM tokens and metadata for the graph node where the LLM is invoked. |
| [`messages`](#messages) | Streams 2-tuples (LLM token, metadata) from any graph nodes where an LLM is invoked. |
| [`debug`](#debug) | Streams as much information as possible throughout the execution of the graph. |
### Stream multiple modes
@@ -161,6 +161,8 @@ graph = (
To include outputs from [subgraphs](../concepts/subgraphs.md) in the streamed outputs, you can set `subgraphs=True` in the `.stream()` method of the parent graph. This will stream outputs from both the parent graph and any subgraphs.
The outputs will be streamed as tuples `(namespace, data)`, where `namespace` is a tuple with the path to the node where a subgraph is invoked, e.g. `("parent_node:<task_id>", "child_node:<task_id>")`.
```python
for chunk in graph.stream(
{"foo": "foo"},
@@ -179,21 +181,17 @@ for chunk in graph.stream(
from langgraph.graph import START, StateGraph
from typing import TypedDict
# Define subgraph
class SubgraphState(TypedDict):
foo: str # note that this key is shared with the parent graph state
bar: str
def subgraph_node_1(state: SubgraphState):
return {"bar": "bar"}
def subgraph_node_2(state: SubgraphState):
return {"foo": state["foo"] + state["bar"]}
subgraph_builder = StateGraph(SubgraphState)
subgraph_builder.add_node(subgraph_node_1)
subgraph_builder.add_node(subgraph_node_2)
@@ -201,16 +199,13 @@ for chunk in graph.stream(
subgraph_builder.add_edge("subgraph_node_1", "subgraph_node_2")
subgraph = subgraph_builder.compile()
# Define parent graph
class ParentState(TypedDict):
foo: str
def node_1(state: ParentState):
return {"foo": "hi! " + state["foo"]}
builder = StateGraph(ParentState)
builder.add_node("node_1", node_1)
builder.add_node("node_2", subgraph)
@@ -229,6 +224,13 @@ for chunk in graph.stream(
1. Set `subgraphs=True` to stream outputs from subgraphs.
```
((), {'node_1': {'foo': 'hi! foo'}})
(('node_2:dfddc4ba-c3c5-6887-5012-a243b5b377c2',), {'subgraph_node_1': {'bar': 'bar'}})
(('node_2:dfddc4ba-c3c5-6887-5012-a243b5b377c2',), {'subgraph_node_2': {'foo': 'hi! foobar'}})
((), {'node_2': {'foo': 'hi! foobar'}})
```
**Note** that we are receiving not just the node updates, but we also the namespaces which tell us what graph (or subgraph) we are streaming from.
## Debugging {#debug}
+1 -1
View File
@@ -14,7 +14,7 @@ OUTPUT ?= out/benchmark.json
install: ## Install dependencies
uv sync --frozen --all-extras --all-packages --group dev
benchmark: .uv
benchmark:
mkdir -p out
rm -f $(OUTPUT)
uv run python -m bench -o $(OUTPUT) --rigorous
+28 -196
View File
@@ -2278,7 +2278,7 @@ class Pregel(PregelProtocol):
Args:
input: The input to the graph.
config: The configuration to use for the run.
stream_mode: The mode to stream output, defaults to self.stream_mode.
stream_mode: The mode to stream output, defaults to `self.stream_mode`.
Options are:
- `"values"`: Emit all values in the state after each step, including interrupts.
@@ -2287,112 +2287,28 @@ class Pregel(PregelProtocol):
If multiple updates are made in the same step (e.g. multiple nodes are run) then those updates are emitted separately.
- `"custom"`: Emit custom data from inside nodes or tasks using `StreamWriter`.
- `"messages"`: Emit LLM messages token-by-token together with metadata for any LLM invocations inside nodes or tasks.
Will be emitted as 2-tuples `(LLM token, metadata)`.
- `"debug"`: Emit debug events with as much information as possible for each step.
You can pass a list as the `stream_mode` parameter to stream multiple modes at once.
The streamed outputs will be tuples of `(mode, data)`.
See [LangGraph streaming guide](https://langchain-ai.github.io/langgraph/how-tos/streaming/) for more details.
output_keys: The keys to stream, defaults to all non-context channels.
interrupt_before: Nodes to interrupt before, defaults to all nodes in the graph.
interrupt_after: Nodes to interrupt after, defaults to all nodes in the graph.
checkpoint_during: Whether to checkpoint intermediate steps, defaults to True. If False, only the final checkpoint is saved.
debug: Whether to print debug information during execution, defaults to False.
subgraphs: Whether to stream subgraphs, defaults to False.
subgraphs: Whether to stream events from inside subgraphs, defaults to False.
If True, the events will be emitted as tuples `(namespace, data)`,
or `(namespace, mode, data)` if `stream_mode` is a list,
where `namespace` is a tuple with the path to the node where a subgraph is invoked,
e.g. `("parent_node:<task_id>", "child_node:<task_id>")`.
See [LangGraph streaming guide](https://langchain-ai.github.io/langgraph/how-tos/streaming/) for more details.
Yields:
The output of each step in the graph. The output shape depends on the stream_mode.
Example: Using stream_mode="values":
```python
import operator
from typing_extensions import Annotated, TypedDict
from langgraph.graph import StateGraph, START
class State(TypedDict):
alist: Annotated[list, operator.add]
another_list: Annotated[list, operator.add]
builder = StateGraph(State)
builder.add_node("a", lambda _state: {"another_list": ["hi"]})
builder.add_node("b", lambda _state: {"alist": ["there"]})
builder.add_edge("a", "b")
builder.add_edge(START, "a")
graph = builder.compile()
for event in graph.stream({"alist": ['Ex for stream_mode="values"']}, stream_mode="values"):
print(event)
# {'alist': ['Ex for stream_mode="values"'], 'another_list': []}
# {'alist': ['Ex for stream_mode="values"'], 'another_list': ['hi']}
# {'alist': ['Ex for stream_mode="values"', 'there'], 'another_list': ['hi']}
```
Example: Using stream_mode="updates":
```python
for event in graph.stream({"alist": ['Ex for stream_mode="updates"']}, stream_mode="updates"):
print(event)
# {'a': {'another_list': ['hi']}}
# {'b': {'alist': ['there']}}
```
Example: Using stream_mode="debug":
```python
for event in graph.stream({"alist": ['Ex for stream_mode="debug"']}, stream_mode="debug"):
print(event)
# {'type': 'task', 'timestamp': '2024-06-23T...+00:00', 'step': 1, 'payload': {'id': '...', 'name': 'a', 'input': {'alist': ['Ex for stream_mode="debug"'], 'another_list': []}, 'triggers': ['start:a']}}
# {'type': 'task_result', 'timestamp': '2024-06-23T...+00:00', 'step': 1, 'payload': {'id': '...', 'name': 'a', 'result': [('another_list', ['hi'])]}}
# {'type': 'task', 'timestamp': '2024-06-23T...+00:00', 'step': 2, 'payload': {'id': '...', 'name': 'b', 'input': {'alist': ['Ex for stream_mode="debug"'], 'another_list': ['hi']}, 'triggers': ['a']}}
# {'type': 'task_result', 'timestamp': '2024-06-23T...+00:00', 'step': 2, 'payload': {'id': '...', 'name': 'b', 'result': [('alist', ['there'])]}}
```
Example: Using stream_mode="custom":
```python
from langgraph.types import StreamWriter
def node_a(state: State, writer: StreamWriter):
writer({"custom_data": "foo"})
return {"alist": ["hi"]}
builder = StateGraph(State)
builder.add_node("a", node_a)
builder.add_edge(START, "a")
graph = builder.compile()
for event in graph.stream({"alist": ['Ex for stream_mode="custom"']}, stream_mode="custom"):
print(event)
# {'custom_data': 'foo'}
```
Example: Using stream_mode="messages":
```python
from typing_extensions import Annotated, TypedDict
from langgraph.graph import StateGraph, START
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4o-mini")
class State(TypedDict):
question: str
answer: str
def node_a(state: State):
response = llm.invoke(state["question"])
return {"answer": response.content}
builder = StateGraph(State)
builder.add_node("a", node_a)
builder.add_edge(START, "a")
graph = builder.compile()
for event in graph.stream({"question": "What is the capital of France?"}, stream_mode="messages"):
print(event)
# (AIMessageChunk(content='The', additional_kwargs={}, response_metadata={}, id='...'), {'langgraph_step': 1, 'langgraph_node': 'a', 'langgraph_triggers': ['start:a'], 'langgraph_path': ('__pregel_pull', 'a'), 'langgraph_checkpoint_ns': '...', 'checkpoint_ns': '...', 'ls_provider': 'openai', 'ls_model_name': 'gpt-4o-mini', 'ls_model_type': 'chat', 'ls_temperature': 0.7})
# (AIMessageChunk(content=' capital', additional_kwargs={}, response_metadata={}, id='...'), {'langgraph_step': 1, 'langgraph_node': 'a', 'langgraph_triggers': ['start:a'], ...})
# (AIMessageChunk(content=' of', additional_kwargs={}, response_metadata={}, id='...'), {...})
# (AIMessageChunk(content=' France', additional_kwargs={}, response_metadata={}, id='...'), {...})
# (AIMessageChunk(content=' is', additional_kwargs={}, response_metadata={}, id='...'), {...})
# (AIMessageChunk(content=' Paris', additional_kwargs={}, response_metadata={}, id='...'), {...})
```
"""
stream = SyncQueue()
@@ -2569,7 +2485,7 @@ class Pregel(PregelProtocol):
Args:
input: The input to the graph.
config: The configuration to use for the run.
stream_mode: The mode to stream output, defaults to self.stream_mode.
stream_mode: The mode to stream output, defaults to `self.stream_mode`.
Options are:
- `"values"`: Emit all values in the state after each step, including interrupts.
@@ -2578,112 +2494,28 @@ class Pregel(PregelProtocol):
If multiple updates are made in the same step (e.g. multiple nodes are run) then those updates are emitted separately.
- `"custom"`: Emit custom data from inside nodes or tasks using `StreamWriter`.
- `"messages"`: Emit LLM messages token-by-token together with metadata for any LLM invocations inside nodes or tasks.
Will be emitted as 2-tuples `(LLM token, metadata)`.
- `"debug"`: Emit debug events with as much information as possible for each step.
You can pass a list as the `stream_mode` parameter to stream multiple modes at once.
The streamed outputs will be tuples of `(mode, data)`.
See [LangGraph streaming guide](https://langchain-ai.github.io/langgraph/how-tos/streaming/) for more details.
output_keys: The keys to stream, defaults to all non-context channels.
interrupt_before: Nodes to interrupt before, defaults to all nodes in the graph.
interrupt_after: Nodes to interrupt after, defaults to all nodes in the graph.
checkpoint_during: Whether to checkpoint intermediate steps, defaults to True. If False, only the final checkpoint is saved.
debug: Whether to print debug information during execution, defaults to False.
subgraphs: Whether to stream subgraphs, defaults to False.
subgraphs: Whether to stream events from inside subgraphs, defaults to False.
If True, the events will be emitted as tuples `(namespace, data)`,
or `(namespace, mode, data)` if `stream_mode` is a list,
where `namespace` is a tuple with the path to the node where a subgraph is invoked,
e.g. `("parent_node:<task_id>", "child_node:<task_id>")`.
See [LangGraph streaming guide](https://langchain-ai.github.io/langgraph/how-tos/streaming/) for more details.
Yields:
The output of each step in the graph. The output shape depends on the stream_mode.
Example: Using stream_mode="values":
```python
import operator
from typing_extensions import Annotated, TypedDict
from langgraph.graph import StateGraph, START
class State(TypedDict):
alist: Annotated[list, operator.add]
another_list: Annotated[list, operator.add]
builder = StateGraph(State)
builder.add_node("a", lambda _state: {"another_list": ["hi"]})
builder.add_node("b", lambda _state: {"alist": ["there"]})
builder.add_edge("a", "b")
builder.add_edge(START, "a")
graph = builder.compile()
async for event in graph.astream({"alist": ['Ex for stream_mode="values"']}, stream_mode="values"):
print(event)
# {'alist': ['Ex for stream_mode="values"'], 'another_list': []}
# {'alist': ['Ex for stream_mode="values"'], 'another_list': ['hi']}
# {'alist': ['Ex for stream_mode="values"', 'there'], 'another_list': ['hi']}
```
Example: Using stream_mode="updates":
```python
async for event in graph.astream({"alist": ['Ex for stream_mode="updates"']}, stream_mode="updates"):
print(event)
# {'a': {'another_list': ['hi']}}
# {'b': {'alist': ['there']}}
```
Example: Using stream_mode="debug":
```python
async for event in graph.astream({"alist": ['Ex for stream_mode="debug"']}, stream_mode="debug"):
print(event)
# {'type': 'task', 'timestamp': '2024-06-23T...+00:00', 'step': 1, 'payload': {'id': '...', 'name': 'a', 'input': {'alist': ['Ex for stream_mode="debug"'], 'another_list': []}, 'triggers': ['start:a']}}
# {'type': 'task_result', 'timestamp': '2024-06-23T...+00:00', 'step': 1, 'payload': {'id': '...', 'name': 'a', 'result': [('another_list', ['hi'])]}}
# {'type': 'task', 'timestamp': '2024-06-23T...+00:00', 'step': 2, 'payload': {'id': '...', 'name': 'b', 'input': {'alist': ['Ex for stream_mode="debug"'], 'another_list': ['hi']}, 'triggers': ['a']}}
# {'type': 'task_result', 'timestamp': '2024-06-23T...+00:00', 'step': 2, 'payload': {'id': '...', 'name': 'b', 'result': [('alist', ['there'])]}}
```
Example: Using stream_mode="custom":
```python
from langgraph.types import StreamWriter
async def node_a(state: State, writer: StreamWriter):
writer({"custom_data": "foo"})
return {"alist": ["hi"]}
builder = StateGraph(State)
builder.add_node("a", node_a)
builder.add_edge(START, "a")
graph = builder.compile()
async for event in graph.astream({"alist": ['Ex for stream_mode="custom"']}, stream_mode="custom"):
print(event)
# {'custom_data': 'foo'}
```
Example: Using stream_mode="messages":
```python
from typing_extensions import Annotated, TypedDict
from langgraph.graph import StateGraph, START
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4o-mini")
class State(TypedDict):
question: str
answer: str
async def node_a(state: State):
response = await llm.ainvoke(state["question"])
return {"answer": response.content}
builder = StateGraph(State)
builder.add_node("a", node_a)
builder.add_edge(START, "a")
graph = builder.compile()
async for event in graph.astream({"question": "What is the capital of France?"}, stream_mode="messages"):
print(event)
# (AIMessageChunk(content='The', additional_kwargs={}, response_metadata={}, id='...'), {'langgraph_step': 1, 'langgraph_node': 'a', 'langgraph_triggers': ['start:a'], 'langgraph_path': ('__pregel_pull', 'a'), 'langgraph_checkpoint_ns': '...', 'checkpoint_ns': '...', 'ls_provider': 'openai', 'ls_model_name': 'gpt-4o-mini', 'ls_model_type': 'chat', 'ls_temperature': 0.7})
# (AIMessageChunk(content=' capital', additional_kwargs={}, response_metadata={}, id='...'), {'langgraph_step': 1, 'langgraph_node': 'a', 'langgraph_triggers': ['start:a'], ...})
# (AIMessageChunk(content=' of', additional_kwargs={}, response_metadata={}, id='...'), {...})
# (AIMessageChunk(content=' France', additional_kwargs={}, response_metadata={}, id='...'), {...})
# (AIMessageChunk(content=' is', additional_kwargs={}, response_metadata={}, id='...'), {...})
# (AIMessageChunk(content=' Paris', additional_kwargs={}, response_metadata={}, id='...'), {...})
```
"""
stream = AsyncQueue()
+15 -11
View File
@@ -1,6 +1,6 @@
{
"name": "@langchain/langgraph-sdk",
"version": "0.0.74",
"version": "0.0.76",
"description": "Client library for interacting with the LangGraph API",
"type": "module",
"packageManager": "yarn@1.22.19",
@@ -10,7 +10,7 @@
"prepack": "yarn run build",
"format": "prettier --write src",
"lint": "prettier --check src && tsc --noEmit",
"test": "NODE_OPTIONS=--experimental-vm-modules jest --testPathIgnorePatterns=\\.int\\.test.ts",
"test": "vitest",
"typedoc": "typedoc && typedoc src/react/index.ts --out docs/react --options typedoc.react.json && typedoc src/auth/index.ts --out docs/auth --options typedoc.auth.json"
},
"main": "index.js",
@@ -22,28 +22,32 @@
"uuid": "^9.0.0"
},
"devDependencies": {
"@jest/globals": "^29.7.0",
"@langchain/core": "^0.3.31",
"@langchain/scripts": "^0.1.4",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@tsconfig/recommended": "^1.0.2",
"@types/jest": "^29.5.12",
"@types/node": "^20.12.12",
"@types/uuid": "^9.0.1",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@types/uuid": "^9.0.1",
"@vitejs/plugin-react": "^4.4.1",
"concat-md": "^0.5.1",
"jest": "^29.7.0",
"jsdom": "^26.1.0",
"msw": "^2.8.2",
"prettier": "^3.2.5",
"ts-jest": "^29.1.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typedoc": "^0.27.7",
"typedoc-plugin-markdown": "^4.4.2",
"typescript": "^5.4.5",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"vitest": "^3.1.3"
},
"peerDependencies": {
"react": "^18 || ^19",
"@langchain/core": ">=0.2.31 <0.4.0"
"@langchain/core": ">=0.2.31 <0.4.0",
"react": "^18 || ^19"
},
"peerDependenciesMeta": {
"react": {
+105 -28
View File
@@ -68,6 +68,24 @@ export function getApiKey(apiKey?: string): string | undefined {
return undefined;
}
const REGEX_RUN_METADATA =
/(\/threads\/(?<thread_id>.+))?\/runs\/(?<run_id>.+)/;
function getRunMetadataFromResponse(
response: Response,
): { run_id: string; thread_id?: string } | undefined {
const contentLocation = response.headers.get("Content-Location");
if (!contentLocation) return undefined;
const match = REGEX_RUN_METADATA.exec(contentLocation);
if (!match?.groups?.run_id) return undefined;
return {
run_id: match.groups.run_id,
thread_id: match.groups.thread_id || undefined,
};
}
export interface ClientConfig {
apiUrl?: string;
apiKey?: string;
@@ -130,6 +148,7 @@ class BaseClient {
json?: unknown;
params?: Record<string, unknown>;
timeoutMs?: number | null;
withResponse?: boolean;
},
): [url: URL, init: RequestInit] {
const mutatedOptions = {
@@ -146,6 +165,10 @@ class BaseClient {
delete mutatedOptions.json;
}
if (mutatedOptions.withResponse) {
delete mutatedOptions.withResponse;
}
let timeoutSignal: AbortSignal | null = null;
if (typeof options?.timeoutMs !== "undefined") {
if (options.timeoutMs != null) {
@@ -175,6 +198,17 @@ class BaseClient {
return [targetUrl, mutatedOptions];
}
protected async fetch<T>(
path: string,
options: RequestInit & {
json?: unknown;
params?: Record<string, unknown>;
timeoutMs?: number | null;
signal?: AbortSignal;
withResponse: true;
},
): Promise<[T, Response]>;
protected async fetch<T>(
path: string,
options?: RequestInit & {
@@ -182,15 +216,36 @@ class BaseClient {
params?: Record<string, unknown>;
timeoutMs?: number | null;
signal?: AbortSignal;
withResponse?: false;
},
): Promise<T> {
): Promise<T>;
protected async fetch<T>(
path: string,
options?: RequestInit & {
json?: unknown;
params?: Record<string, unknown>;
timeoutMs?: number | null;
signal?: AbortSignal;
withResponse?: boolean;
},
): Promise<T | [T, Response]> {
const response = await this.asyncCaller.fetch(
...this.prepareFetchOptions(path, options),
);
if (response.status === 202 || response.status === 204) {
return undefined as T;
const body = (() => {
if (response.status === 202 || response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
})();
if (options?.withResponse) {
return [await body, response];
}
return response.json() as T;
return body;
}
}
@@ -856,6 +911,7 @@ export class RunsClient<
const endpoint =
threadId == null ? `/runs/stream` : `/threads/${threadId}/runs/stream`;
const response = await this.asyncCaller.fetch(
...this.prepareFetchOptions(endpoint, {
method: "POST",
@@ -865,6 +921,9 @@ export class RunsClient<
}),
);
const runMetadata = getRunMetadataFromResponse(response);
if (runMetadata) payload?.onRunCreated?.(runMetadata);
const stream: ReadableStream<{ event: any; data: any }> = (
response.body || new ReadableStream({ start: (ctrl) => ctrl.close() })
)
@@ -905,11 +964,18 @@ export class RunsClient<
if_not_exists: payload?.ifNotExists,
checkpoint_during: payload?.checkpointDuring,
};
return this.fetch<Run>(`/threads/${threadId}/runs`, {
const [run, response] = await this.fetch<Run>(`/threads/${threadId}/runs`, {
method: "POST",
json,
signal: payload?.signal,
withResponse: true,
});
const runMetadata = getRunMetadataFromResponse(response);
if (runMetadata) payload?.onRunCreated?.(runMetadata);
return run;
}
/**
@@ -980,27 +1046,30 @@ export class RunsClient<
};
const endpoint =
threadId == null ? `/runs/wait` : `/threads/${threadId}/runs/wait`;
const response = await this.fetch<ThreadState["values"]>(endpoint, {
const [run, response] = await this.fetch<ThreadState["values"]>(endpoint, {
method: "POST",
json,
timeoutMs: null,
signal: payload?.signal,
withResponse: true,
});
const runMetadata = getRunMetadataFromResponse(response);
if (runMetadata) payload?.onRunCreated?.(runMetadata);
const raiseError =
payload?.raiseError !== undefined ? payload.raiseError : true;
if (
raiseError &&
"__error__" in response &&
typeof response.__error__ === "object" &&
response.__error__ &&
"error" in response.__error__ &&
"message" in response.__error__
"__error__" in run &&
typeof run.__error__ === "object" &&
run.__error__ &&
"error" in run.__error__ &&
"message" in run.__error__
) {
throw new Error(
`${response.__error__?.error}: ${response.__error__?.message}`,
);
throw new Error(`${run.__error__?.error}: ${run.__error__?.message}`);
}
return response;
return run;
}
/**
@@ -1095,13 +1164,12 @@ export class RunsClient<
/**
* Stream output from a run in real-time, until the run is done.
* Output is not buffered, so any output produced before this call will
* not be received here.
*
* @param threadId The ID of the thread.
* @param threadId The ID of the thread. Can be set to `null` | `undefined` for stateless runs.
* @param runId The ID of the run.
* @param options Additional options for controlling the stream behavior:
* - signal: An AbortSignal that can be used to cancel the stream request
* - lastEventId: The ID of the last event received. Can be used to reconnect to a stream without losing events.
* - cancelOnDisconnect: When true, automatically cancels the run if the client disconnects from the stream
* - streamMode: Controls what types of events to receive from the stream (can be a single mode or array of modes)
* Must be a subset of the stream modes passed when creating the run. Background runs default to having the union of all
@@ -1109,16 +1177,17 @@ export class RunsClient<
* @returns An async generator yielding stream parts.
*/
async *joinStream(
threadId: string,
threadId: string | undefined | null,
runId: string,
options?:
| {
signal?: AbortSignal;
cancelOnDisconnect?: boolean;
lastEventId?: string;
streamMode?: StreamMode | StreamMode[];
}
| AbortSignal,
): AsyncGenerator<{ event: StreamEvent; data: any }> {
): AsyncGenerator<{ id?: string; event: StreamEvent; data: any }> {
const opts =
typeof options === "object" &&
options != null &&
@@ -1127,15 +1196,23 @@ export class RunsClient<
: options;
const response = await this.asyncCaller.fetch(
...this.prepareFetchOptions(`/threads/${threadId}/runs/${runId}/stream`, {
method: "GET",
timeoutMs: null,
signal: opts?.signal,
params: {
cancel_on_disconnect: opts?.cancelOnDisconnect ? "1" : "0",
stream_mode: opts?.streamMode,
...this.prepareFetchOptions(
threadId != null
? `/threads/${threadId}/runs/${runId}/stream`
: `/runs/${runId}/stream`,
{
method: "GET",
timeoutMs: null,
signal: opts?.signal,
headers: opts?.lastEventId
? { "Last-Event-ID": opts.lastEventId }
: undefined,
params: {
cancel_on_disconnect: opts?.cancelOnDisconnect ? "1" : "0",
stream_mode: opts?.streamMode,
},
},
}),
),
);
const stream: ReadableStream<{ event: string; data: any }> = (
+7
View File
@@ -613,6 +613,12 @@ interface SubmitOptions<
optimisticValues?:
| Partial<StateType>
| ((prev: StateType) => Partial<StateType>);
/**
* Whether or not to stream the nodes of any subgraphs called
* by the assistant.
* @default false
*/
streamSubgraphs?: boolean;
}
export function useStream<
@@ -868,6 +874,7 @@ export function useStream<
checkpoint,
streamMode,
streamSubgraphs: submitOptions?.streamSubgraphs,
}) as AsyncGenerator<EventStreamEvent>;
let streamError: StreamError | undefined;
+71 -67
View File
@@ -1,74 +1,78 @@
/* eslint-disable no-process-env */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { jest } from "@jest/globals";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { Client } from "../client.js";
import { overrideFetchImplementation } from "../singletons/fetch.js";
describe.each([[""], ["mocked"]])("Client uses %s fetch", (description) => {
let globalFetchMock: jest.Mock;
let overriddenFetch: jest.Mock;
let expectedFetchMock: jest.Mock;
let unexpectedFetchMock: jest.Mock;
describe.each([["global"], ["mocked"]])(
"Client uses %s fetch",
(description: string) => {
let globalFetchMock: ReturnType<typeof vi.fn>;
let overriddenFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
globalFetchMock = jest.fn(() =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
batch_ingest_config: {
use_multipart_endpoint: true,
},
}),
text: () => Promise.resolve(""),
}),
);
overriddenFetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
batch_ingest_config: {
use_multipart_endpoint: true,
},
}),
text: () => Promise.resolve(""),
}),
);
expectedFetchMock =
description === "mocked" ? overriddenFetch : globalFetchMock;
unexpectedFetchMock =
description === "mocked" ? globalFetchMock : overriddenFetch;
let expectedFetchMock: ReturnType<typeof vi.fn>;
let unexpectedFetchMock: ReturnType<typeof vi.fn>;
if (description === "mocked") {
overrideFetchImplementation(overriddenFetch);
} else {
overrideFetchImplementation(globalFetchMock);
}
// Mock global fetch
(globalThis as any).fetch = globalFetchMock;
});
beforeEach(() => {
globalFetchMock = vi.fn(() =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
batch_ingest_config: {
use_multipart_endpoint: true,
},
}),
text: () => Promise.resolve(""),
headers: new Headers({}),
}),
);
overriddenFetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
batch_ingest_config: {
use_multipart_endpoint: true,
},
}),
text: () => Promise.resolve(""),
headers: new Headers({}),
}),
);
expectedFetchMock =
description === "mocked" ? overriddenFetch : globalFetchMock;
unexpectedFetchMock =
description === "mocked" ? globalFetchMock : overriddenFetch;
afterEach(() => {
jest.restoreAllMocks();
});
describe("createRuns", () => {
it("should create an example with the given input and generation", async () => {
const client = new Client({ apiKey: "test-api-key" });
const thread = await client.threads.create();
expect(expectedFetchMock).toHaveBeenCalledTimes(1);
expect(unexpectedFetchMock).not.toHaveBeenCalled();
jest.clearAllMocks(); // Clear all mocks before the next operation
// Then clear & run the function
await client.runs.create(thread.thread_id, "somegraph", {
input: { foo: "bar" },
});
expect(expectedFetchMock).toHaveBeenCalledTimes(1);
expect(unexpectedFetchMock).not.toHaveBeenCalled();
if (description === "mocked") {
overrideFetchImplementation(overriddenFetch);
} else {
overrideFetchImplementation(globalFetchMock);
}
// Mock global fetch
(globalThis as any).fetch = globalFetchMock;
});
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("createRuns", () => {
it("should create an example with the given input and generation", async () => {
const client = new Client({ apiKey: "test-api-key" });
const thread = await client.threads.create();
expect(expectedFetchMock).toHaveBeenCalledTimes(1);
expect(unexpectedFetchMock).not.toHaveBeenCalled();
vi.clearAllMocks(); // Clear all mocks before the next operation
// Then clear & run the function
await client.runs.create(thread.thread_id, "somegraph", {
input: { foo: "bar" },
});
expect(expectedFetchMock).toHaveBeenCalledTimes(1);
expect(unexpectedFetchMock).not.toHaveBeenCalled();
});
});
},
);
+1
View File
@@ -1,3 +1,4 @@
import { describe, test, expect } from "vitest";
import { Readable } from "node:stream";
import { IterableReadableStream } from "../utils/stream.js";
import { BytesLineDecoder, SSEDecoder } from "../utils/sse.js";
+443
View File
@@ -0,0 +1,443 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";
import { useStream } from "../react/stream.js";
import "@testing-library/jest-dom/vitest";
function TestChatComponent() {
const { messages, isLoading, error, submit, stop } = useStream({
assistantId: "test-assistant",
apiKey: "test-api-key",
});
return (
<div>
<div data-testid="messages">
{messages.map((msg, i) => (
<div key={msg.id ?? i} data-testid={`message-${i}`}>
{typeof msg.content === "string"
? msg.content
: JSON.stringify(msg.content)}
</div>
))}
</div>
<div data-testid="loading">
{isLoading ? "Loading..." : "Not loading"}
</div>
{error ? <div data-testid="error">{String(error)}</div> : null}
<button
data-testid="submit"
onClick={() =>
submit({ messages: [{ content: "Hello", type: "human" }] })
}
>
Send
</button>
<button data-testid="stop" onClick={stop}>
Stop
</button>
</div>
);
}
// Mock server setup
const server = setupServer(
// Mock thread creation
http.post("*/threads", () => {
return HttpResponse.json({ thread_id: "test-thread-id" });
}),
// Mock stream endpoint
http.post("*/threads/:threadId/runs/stream", async () => {
const encoder = new TextEncoder();
const sendSSE = (event: string, data: unknown) =>
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
const stream = new ReadableStream({
async start(controller) {
await new Promise((resolve) => setTimeout(resolve, 10));
controller.enqueue(
sendSSE("metadata", {
run_id: "1f03278a-1734-6518-80a4-3390db59f960",
attempt: 1,
}),
);
controller.enqueue(
sendSSE("values", {
messages: [
{
content: "Hey",
additional_kwargs: {},
response_metadata: {},
type: "human",
name: null,
id: "2d8c0d9f-a614-4e44-b474-6a56e9471cf5",
example: false,
},
],
}),
);
controller.enqueue(
sendSSE("messages", [
{
content: "",
additional_kwargs: {},
response_metadata: { model_name: "claude-3-7-sonnet-latest" },
type: "AIMessageChunk",
name: null,
id: "run-3e90ba6a-71d6-49e7-94a8-6bcac2fd0f40",
tool_calls: [],
invalid_tool_calls: [],
tool_call_chunks: [],
},
{ run_attempt: 1 },
]),
);
controller.enqueue(
sendSSE("messages", [
{
content: "Hello",
additional_kwargs: {},
response_metadata: { model_name: "claude-3-7-sonnet-latest" },
type: "AIMessageChunk",
name: null,
id: "run-3e90ba6a-71d6-49e7-94a8-6bcac2fd0f40",
tool_calls: [],
invalid_tool_calls: [],
tool_call_chunks: [],
},
{ run_attempt: 1 },
]),
);
controller.enqueue(
sendSSE("messages", [
{
content: "! How can I assist you today?",
additional_kwargs: {},
response_metadata: { model_name: "claude-3-7-sonnet-latest" },
type: "AIMessageChunk",
name: null,
id: "run-3e90ba6a-71d6-49e7-94a8-6bcac2fd0f40",
tool_calls: [],
invalid_tool_calls: [],
tool_call_chunks: [],
},
{ run_attempt: 1 },
]),
);
controller.enqueue(
sendSSE("messages", [
{
content: "",
additional_kwargs: {},
response_metadata: {
stop_reason: "end_turn",
stop_sequence: null,
},
type: "AIMessageChunk",
name: null,
id: "run-3e90ba6a-71d6-49e7-94a8-6bcac2fd0f40",
tool_calls: [],
invalid_tool_calls: [],
tool_call_chunks: [],
},
{ run_attempt: 1 },
]),
);
controller.enqueue(
sendSSE("values", {
messages: [
{
content: "Hey",
additional_kwargs: {},
response_metadata: {},
type: "human",
name: null,
id: "2d8c0d9f-a614-4e44-b474-6a56e9471cf5",
example: false,
},
{
content: "Hello! How can I assist you today?",
additional_kwargs: {},
response_metadata: {
model_name: "claude-3-7-sonnet-latest",
stop_reason: "end_turn",
stop_sequence: null,
},
type: "ai",
name: null,
id: "run-3e90ba6a-71d6-49e7-94a8-6bcac2fd0f40",
tool_calls: [],
invalid_tool_calls: [],
},
],
}),
);
controller.close();
},
});
server.use(
http.post("*/threads/:threadId/history", () => {
return HttpResponse.json([
{
values: {
messages: [
{
content: "Hey",
additional_kwargs: {},
response_metadata: {},
type: "human",
name: null,
id: "2d8c0d9f-a614-4e44-b474-6a56e9471cf5",
example: false,
},
{
content: "Hello! How can I assist you today?",
additional_kwargs: {},
response_metadata: {
model_name: "claude-3-7-sonnet-latest",
stop_reason: "end_turn",
stop_sequence: null,
},
type: "ai",
name: null,
id: "run-3e90ba6a-71d6-49e7-94a8-6bcac2fd0f40",
example: false,
tool_calls: [],
invalid_tool_calls: [],
},
],
},
next: [],
tasks: [],
metadata: {
run_attempt: 1,
source: "loop",
writes: {
agent: {
messages: [
{
content: "Hello! How can I assist you today?",
additional_kwargs: {},
response_metadata: {
model_name: "claude-3-7-sonnet-latest",
stop_reason: "end_turn",
stop_sequence: null,
},
type: "ai",
name: null,
id: "run-3e90ba6a-71d6-49e7-94a8-6bcac2fd0f40",
example: false,
tool_calls: [],
invalid_tool_calls: [],
},
],
},
},
step: 1,
parents: {},
},
created_at: "2025-05-16T17:10:16.987537+00:00",
checkpoint: {
checkpoint_id: "1f03278a-38cf-6c68-8001-22b77ac43ff6",
thread_id: "b06fd92a-955c-446e-b233-7977716c4a9c",
checkpoint_ns: "",
},
parent_checkpoint: {
checkpoint_id: "1f03278a-206b-67c6-8000-ac34a0872e1a",
thread_id: "b06fd92a-955c-446e-b233-7977716c4a9c",
checkpoint_ns: "",
},
checkpoint_id: "1f03278a-38cf-6c68-8001-22b77ac43ff6",
parent_checkpoint_id: "1f03278a-206b-67c6-8000-ac34a0872e1a",
},
{
values: {
messages: [
{
content: "Hey",
additional_kwargs: {},
response_metadata: {},
type: "human",
name: null,
id: "2d8c0d9f-a614-4e44-b474-6a56e9471cf5",
example: false,
},
],
},
next: ["agent"],
tasks: [
{
id: "e1b7b52b-a78e-4b32-0c89-e06bf46405ed",
name: "agent",
path: ["__pregel_pull", "agent"],
error: null,
interrupts: [],
checkpoint: null,
state: null,
result: {
messages: [
{
content: "Hello! How can I assist you today?",
additional_kwargs: {},
response_metadata: {
model_name: "claude-3-7-sonnet-latest",
stop_reason: "end_turn",
stop_sequence: null,
},
type: "ai",
name: null,
id: "run-3e90ba6a-71d6-49e7-94a8-6bcac2fd0f40",
example: false,
tool_calls: [],
invalid_tool_calls: [],
},
],
},
},
],
metadata: {
run_attempt: 1,
},
created_at: "2025-05-16T17:10:14.429889+00:00",
checkpoint: {
checkpoint_id: "1f03278a-206b-67c6-8000-ac34a0872e1a",
thread_id: "b06fd92a-955c-446e-b233-7977716c4a9c",
checkpoint_ns: "",
},
parent_checkpoint: {
checkpoint_id: "1f03278a-2067-6590-bfff-3fb740466fc3",
thread_id: "b06fd92a-955c-446e-b233-7977716c4a9c",
checkpoint_ns: "",
},
checkpoint_id: "1f03278a-206b-67c6-8000-ac34a0872e1a",
parent_checkpoint_id: "1f03278a-2067-6590-bfff-3fb740466fc3",
},
{
values: {
messages: [],
},
next: ["__start__"],
tasks: [
{
id: "291af033-2ddc-3320-8bbc-28060057cae5",
name: "__start__",
path: ["__pregel_pull", "__start__"],
error: null,
interrupts: [],
checkpoint: null,
state: null,
result: {
messages: [
{
id: "2d8c0d9f-a614-4e44-b474-6a56e9471cf5",
type: "human",
content: "Hey",
},
],
},
},
],
metadata: {
run_attempt: 1,
source: "input",
writes: {
__start__: {
messages: [
{
id: "2d8c0d9f-a614-4e44-b474-6a56e9471cf5",
type: "human",
content: "Hey",
},
],
},
},
step: -1,
parents: {},
},
created_at: "2025-05-16T17:10:14.428191+00:00",
checkpoint: {
checkpoint_id: "1f03278a-2067-6590-bfff-3fb740466fc3",
thread_id: "b06fd92a-955c-446e-b233-7977716c4a9c",
checkpoint_ns: "",
},
parent_checkpoint: null,
checkpoint_id: "1f03278a-2067-6590-bfff-3fb740466fc3",
parent_checkpoint_id: null,
},
]);
}),
);
return new HttpResponse(stream, {
headers: { "Content-Type": "text/event-stream" },
});
}),
);
server.use;
describe("useStream", () => {
beforeEach(() => server.listen());
afterEach(() => {
server.resetHandlers();
server.close();
vi.clearAllMocks();
});
it("renders initial state correctly", () => {
render(<TestChatComponent />);
expect(screen.getByTestId("loading")).toHaveTextContent("Not loading");
expect(screen.getByTestId("messages")).toBeEmptyDOMElement();
expect(screen.queryByTestId("error")).not.toBeInTheDocument();
});
it("handles message submission and streaming", async () => {
const user = userEvent.setup();
render(<TestChatComponent />);
// Check loading state
await user.click(screen.getByTestId("submit"));
expect(screen.getByTestId("loading")).toHaveTextContent("Loading...");
// Wait for messages to appear
await waitFor(() => {
expect(screen.getByTestId("message-0")).toHaveTextContent("Hey");
expect(screen.getByTestId("message-1")).toHaveTextContent(
"Hello! How can I assist you today?",
);
});
// Check final state
expect(screen.getByTestId("loading")).toHaveTextContent("Not loading");
});
it("handles stop functionality", async () => {
const user = userEvent.setup();
render(<TestChatComponent />);
// Start streaming and stop immediately
await user.click(screen.getByTestId("submit"));
await user.click(screen.getByTestId("stop"));
// Check loading state is reset
await waitFor(() => {
expect(screen.getByTestId("loading")).toHaveTextContent("Not loading");
});
});
});
+20 -6
View File
@@ -24,15 +24,21 @@ type MessageTupleMetadata = {
[key: string]: unknown;
};
type AsSubgraph<TEvent extends { event: string; data: unknown }> = {
event: TEvent["event"] | `${TEvent["event"]}|${string}`;
data: TEvent["data"];
};
type AsSubgraph<TEvent extends { id?: string; event: string; data: unknown }> =
{
id?: TEvent["id"];
event: TEvent["event"] | `${TEvent["event"]}|${string}`;
data: TEvent["data"];
};
/**
* Stream event with values after completion of each step.
*/
export type ValuesStreamEvent<StateType> = { event: "values"; data: StateType };
export type ValuesStreamEvent<StateType> = {
id?: string;
event: "values";
data: StateType;
};
/** @internal */
export type SubgraphValuesStreamEvent<StateType> = AsSubgraph<
@@ -57,6 +63,7 @@ export type SubgraphMessagesTupleStreamEvent =
* Metadata stream event with information about the run and thread
*/
export type MetadataStreamEvent = {
id?: string;
event: "metadata";
data: { run_id: string; thread_id: string };
};
@@ -65,6 +72,7 @@ export type MetadataStreamEvent = {
* Stream event with error information.
*/
export type ErrorStreamEvent = {
id?: string;
event: "error";
data: { error: string; message: string };
};
@@ -78,6 +86,7 @@ export type SubgraphErrorStreamEvent = AsSubgraph<ErrorStreamEvent>;
* produced the update as well as the update.
*/
export type UpdatesStreamEvent<UpdateType> = {
id?: string;
event: "updates";
data: { [node: string]: UpdateType };
};
@@ -96,14 +105,17 @@ export type CustomStreamEvent<T> = { event: "custom"; data: T };
export type SubgraphCustomStreamEvent<T> = AsSubgraph<CustomStreamEvent<T>>;
type MessagesMetadataStreamEvent = {
id?: string;
event: "messages/metadata";
data: { [messageId: string]: { metadata: unknown } };
};
type MessagesCompleteStreamEvent = {
id?: string;
event: "messages/complete";
data: Message[];
};
type MessagesPartialStreamEvent = {
id?: string;
event: "messages/partial";
data: Message[];
};
@@ -126,7 +138,7 @@ export type SubgraphMessagesStreamEvent =
/**
* Stream event with detailed debug information.
*/
export type DebugStreamEvent = { event: "debug"; data: unknown };
export type DebugStreamEvent = { id?: string; event: "debug"; data: unknown };
/** @internal */
export type SubgraphDebugStreamEvent = AsSubgraph<DebugStreamEvent>;
@@ -135,6 +147,7 @@ export type SubgraphDebugStreamEvent = AsSubgraph<DebugStreamEvent>;
* Stream event with events occurring during execution.
*/
export type EventsStreamEvent = {
id?: string;
event: "events";
data: {
event:
@@ -157,6 +170,7 @@ export type SubgraphEventsStreamEvent = AsSubgraph<EventsStreamEvent>;
* the `RunsStreamPayload` to receive this event.
*/
export type FeedbackStreamEvent = {
id?: string;
event: "feedback";
data: { [feedbackKey: string]: string };
};
+5
View File
@@ -135,6 +135,11 @@ interface RunsInvokePayload {
* One or more commands to invoke the graph with.
*/
command?: Command;
/**
* Callback when a run is created.
*/
onRunCreated?: (params: { run_id: string; thread_id?: string }) => void;
}
export interface RunsStreamPayload<
+3
View File
@@ -93,6 +93,7 @@ export class BytesLineDecoder extends TransformStream<Uint8Array, Uint8Array> {
}
interface StreamPart {
id: string | undefined;
event: string;
data: unknown;
}
@@ -113,6 +114,7 @@ export class SSEDecoder extends TransformStream<Uint8Array, StreamPart> {
if (!event && !data.length && !lastEventId && retry == null) return;
const sse = {
id: lastEventId || undefined,
event,
data: data.length ? decodeArraysToJson(decoder, data) : null,
};
@@ -151,6 +153,7 @@ export class SSEDecoder extends TransformStream<Uint8Array, StreamPart> {
flush(controller) {
if (event) {
controller.enqueue({
id: lastEventId || undefined,
event,
data: data.length ? decodeArraysToJson(decoder, data) : null,
});
+11
View File
@@ -0,0 +1,11 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
},
});
+1265 -1579
View File
File diff suppressed because it is too large Load Diff