mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-12 12:47:53 +02:00
feat(sdk-py): add thread stream helpers (#7833)
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
name: sdk-py integration test
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
secrets:
|
||||
LANGSMITH_API_KEY:
|
||||
required: false
|
||||
DOCKERHUB_USERNAME:
|
||||
required: false
|
||||
DOCKERHUB_RO_TOKEN:
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
name: "sdk-py integration"
|
||||
defaults:
|
||||
run:
|
||||
working-directory: libs/sdk-py
|
||||
env:
|
||||
HAS_LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY != '' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: ./.github/actions/uv_setup
|
||||
with:
|
||||
python-version: "3.13"
|
||||
cache-suffix: sdk-py-integration
|
||||
working-directory: libs/sdk-py
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_RO_TOKEN }}
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: uv sync --frozen --group test --no-dev
|
||||
|
||||
- name: Skip if LANGSMITH_API_KEY is not available
|
||||
if: env.HAS_LANGSMITH_API_KEY != 'true'
|
||||
run: |
|
||||
echo "LANGSMITH_API_KEY is not set (likely a fork PR). Skipping integration tests."
|
||||
exit 0
|
||||
|
||||
- name: Bring up integration stack
|
||||
if: env.HAS_LANGSMITH_API_KEY == 'true'
|
||||
working-directory: libs/sdk-py/integration
|
||||
env:
|
||||
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
|
||||
run: docker compose up -d --build
|
||||
|
||||
- name: Wait for API healthcheck
|
||||
if: env.HAS_LANGSMITH_API_KEY == 'true'
|
||||
run: |
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf http://localhost:2024/ok >/dev/null; then
|
||||
echo "API ready after ${i}s"
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "API failed to become healthy within 120s"
|
||||
docker compose -f libs/sdk-py/integration/docker-compose.yml logs api | tail -100
|
||||
exit 1
|
||||
|
||||
- name: Run integration suite
|
||||
if: env.HAS_LANGSMITH_API_KEY == 'true'
|
||||
run: uv run pytest tests/integration/ -m integration
|
||||
|
||||
- name: Dump api logs on failure
|
||||
if: failure() && env.HAS_LANGSMITH_API_KEY == 'true'
|
||||
working-directory: libs/sdk-py/integration
|
||||
run: docker compose logs api | tail -200
|
||||
|
||||
- name: Tear down stack
|
||||
if: always() && env.HAS_LANGSMITH_API_KEY == 'true'
|
||||
working-directory: libs/sdk-py/integration
|
||||
run: docker compose down -v
|
||||
@@ -28,6 +28,7 @@ jobs:
|
||||
outputs:
|
||||
python: ${{ steps.filter.outputs.python || 'true' }}
|
||||
deps: ${{ steps.filter.outputs.deps || 'true' }}
|
||||
sdk_py: ${{ steps.filter.outputs.sdk_py || 'true' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4
|
||||
@@ -47,6 +48,8 @@ jobs:
|
||||
deps:
|
||||
- '**/pyproject.toml'
|
||||
- '**/uv.lock'
|
||||
sdk_py:
|
||||
- 'libs/sdk-py/**'
|
||||
|
||||
lint:
|
||||
needs: changes
|
||||
@@ -156,6 +159,13 @@ jobs:
|
||||
uses: ./.github/workflows/_integration_test.yml
|
||||
secrets: inherit
|
||||
|
||||
sdk-py-integration-test:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.sdk_py == 'true'
|
||||
name: "sdk-py integration test"
|
||||
uses: ./.github/workflows/_sdk_integration_test.yml
|
||||
secrets: inherit
|
||||
|
||||
ci_success:
|
||||
name: "CI Success"
|
||||
needs:
|
||||
@@ -166,6 +176,7 @@ jobs:
|
||||
check-sdk-methods,
|
||||
check-schema,
|
||||
integration-test,
|
||||
sdk-py-integration-test,
|
||||
]
|
||||
if: |
|
||||
always()
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- **Thread-centric streaming (v3)** — `client.threads.stream()` returns an
|
||||
`AsyncThreadStream` (or `SyncThreadStream`) context manager that owns one
|
||||
SSE or WebSocket connection for the lifetime of a thread session.
|
||||
|
||||
- **Typed projections** — `thread.messages`, `thread.tool_calls`,
|
||||
`thread.values`, and `thread.extensions[name]` all share the same underlying
|
||||
transport connection. Iterating multiple projections concurrently expands the
|
||||
server-side filter union without opening additional connections.
|
||||
|
||||
- **Scoped subgraph handles** — `thread.subgraphs` (alias `thread.subagents`)
|
||||
yields one `ScopedStreamHandle` per direct child invocation, each exposing
|
||||
`.messages`, `.tool_calls`, and `.subgraphs` scoped to that namespace.
|
||||
|
||||
- **WebSocket transport** — pass `transport="websocket"` to
|
||||
`client.threads.stream()` to use a WebSocket connection instead of SSE
|
||||
(async client only).
|
||||
|
||||
- **Automatic reconnect** — the shared SSE fan-out and the lifecycle watcher
|
||||
both reconnect on transport drops, replaying missed events via a `since`
|
||||
cursor and deduplicating by `event_id`.
|
||||
|
||||
- **`thread.agent.get_tree()`** — fetches the assistant graph definition for
|
||||
the current session's `assistant_id` with optional `xray` depth control.
|
||||
|
||||
- **`thread.run.respond()`** — resumes a run after a server-side interrupt,
|
||||
resolving the outstanding `InterruptPayload` by `interrupt_id`.
|
||||
|
||||
- **`thread.output`** — awaitable that resolves to the terminal thread state
|
||||
`values` dict after the run lifecycle completes.
|
||||
|
||||
### Changed
|
||||
|
||||
- `client.threads.stream()` now accepts `transport="sse"` (default) or
|
||||
`transport="websocket"` in place of the previous transport-agnostic default.
|
||||
|
||||
### Notes
|
||||
|
||||
- The v3 streaming surface (`AsyncThreadStream`, `SyncThreadStream`, and all
|
||||
projection classes) is **new** in this release. The existing
|
||||
`client.runs.stream()` (v2) surface is unchanged and remains fully supported.
|
||||
- `thread_id` is minted client-side (UUIDv4) when not provided; the server
|
||||
creates the thread row lazily on the first `run.start`.
|
||||
@@ -0,0 +1,118 @@
|
||||
# Migration Guide: v2 → v3 Streaming
|
||||
|
||||
`client.runs.stream()` (v2) remains fully supported. This guide covers how to
|
||||
adopt the new `client.threads.stream()` (v3) surface when you want typed
|
||||
projections, shared SSE fan-out, or WebSocket transport.
|
||||
|
||||
## Minimal before/after
|
||||
|
||||
**v2 — `client.runs.stream()`**
|
||||
|
||||
```python
|
||||
from langgraph_sdk import get_client
|
||||
|
||||
client = get_client()
|
||||
|
||||
thread = await client.threads.create()
|
||||
async for chunk in client.runs.stream(
|
||||
thread["thread_id"],
|
||||
"agent",
|
||||
input={"messages": [{"role": "user", "content": "hello"}]},
|
||||
stream_mode="messages",
|
||||
):
|
||||
print(chunk.event, chunk.data)
|
||||
```
|
||||
|
||||
**v3 — `client.threads.stream()`**
|
||||
|
||||
```python
|
||||
from langgraph_sdk import get_client
|
||||
import asyncio
|
||||
|
||||
client = get_client()
|
||||
|
||||
async with client.threads.stream(assistant_id="agent") as thread:
|
||||
await thread.run.start(input={"messages": [{"role": "user", "content": "hello"}]})
|
||||
|
||||
async for stream in thread.messages:
|
||||
print(await stream.text)
|
||||
```
|
||||
|
||||
## Key differences
|
||||
|
||||
| | v2 `client.runs.stream()` | v3 `client.threads.stream()` |
|
||||
|---|---|---|
|
||||
| Thread creation | Explicit `client.threads.create()` | Lazy (minted client-side if omitted) |
|
||||
| Connection per run | Yes | No — shared SSE for the session |
|
||||
| Typed projections | No (raw `StreamPart`) | Yes (`messages`, `tool_calls`, `values`, …) |
|
||||
| Subgraph streaming | Not supported | `thread.subgraphs` / `thread.subagents` |
|
||||
| WebSocket transport | No | Yes (`transport="websocket"`, async only) |
|
||||
| Interrupt handling | Manual polling | `thread.interrupted` / `thread.run.respond()` |
|
||||
| Terminal state | Included in stream | `await thread.output` |
|
||||
|
||||
## Reattaching to an existing thread
|
||||
|
||||
```python
|
||||
async with client.threads.stream(
|
||||
thread_id="existing-thread-id",
|
||||
assistant_id="agent",
|
||||
) as thread:
|
||||
# If the run already completed, thread.output resolves immediately.
|
||||
result = await thread.output
|
||||
```
|
||||
|
||||
## Consuming multiple projections concurrently
|
||||
|
||||
All projections share one SSE connection. Use `asyncio.gather` (or
|
||||
`asyncio.TaskGroup`) to start multiple consumers before any single projection
|
||||
has finished — the fan-out task routes events to all subscribers in parallel.
|
||||
|
||||
```python
|
||||
async with client.threads.stream(assistant_id="agent") as thread:
|
||||
await thread.run.start(input={"messages": [{"role": "user", "content": "hi"}]})
|
||||
|
||||
async def collect_messages():
|
||||
return [s async for s in thread.messages]
|
||||
|
||||
async def collect_tool_calls():
|
||||
return [c async for c in thread.tool_calls]
|
||||
|
||||
messages, tool_calls = await asyncio.gather(
|
||||
collect_messages(),
|
||||
collect_tool_calls(),
|
||||
)
|
||||
```
|
||||
|
||||
## Human-in-the-loop (interrupts)
|
||||
|
||||
```python
|
||||
async with client.threads.stream(assistant_id="agent") as thread:
|
||||
await thread.run.start(input={"messages": [{"role": "user", "content": "book a flight"}]})
|
||||
|
||||
# Wait for the run to pause at an interrupt node.
|
||||
# thread.interrupted becomes True when input.requested arrives.
|
||||
while not thread.interrupted:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Resume with a human response (unambiguous when only one interrupt is outstanding).
|
||||
await thread.run.respond("yes, confirm booking")
|
||||
|
||||
result = await thread.output
|
||||
```
|
||||
|
||||
## Sync client
|
||||
|
||||
The sync client mirrors the async API without `async`/`await`:
|
||||
|
||||
```python
|
||||
from langgraph_sdk import get_sync_client
|
||||
|
||||
client = get_sync_client()
|
||||
|
||||
with client.threads.stream(assistant_id="agent") as thread:
|
||||
thread.run.start(input={"messages": [{"role": "user", "content": "hello"}]})
|
||||
for stream in thread.messages:
|
||||
print(stream.text)
|
||||
```
|
||||
|
||||
The sync client uses SSE only (`transport="websocket"` is not supported).
|
||||
@@ -33,3 +33,43 @@ input = {"messages": [{"role": "human", "content": "what's the weather in la"}]}
|
||||
async for chunk in client.runs.stream(thread['thread_id'], agent['assistant_id'], input=input):
|
||||
print(chunk)
|
||||
```
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- **WebSocket transport** requires `websockets>=14` and is only available on the async client (`AsyncThreadStream`). The sync client (`SyncThreadStream`) uses SSE exclusively.
|
||||
- **`thread.extensions[name]`** opens a new subscription each time the same name is accessed. Assign the projection to a variable and reuse it within a single session rather than re-indexing across multiple iterations.
|
||||
- **Sync streaming** drives the lifecycle watcher in a background thread. Long-lived sync sessions will hold that thread open until the context manager exits.
|
||||
- **Reconnect attempts** are limited to 5 by default for both the shared SSE fan-out and the lifecycle watcher. Persistent network partitions will surface as `RuntimeError` on in-flight projections.
|
||||
|
||||
## Thread-Centric Streaming (v3)
|
||||
|
||||
`client.threads.stream()` returns a context manager that owns the SSE session for one
|
||||
thread. Typed projections — values snapshots, message streams, tool calls, custom
|
||||
events — all share the same underlying connection.
|
||||
|
||||
```python
|
||||
from langgraph_sdk import get_client
|
||||
import asyncio
|
||||
|
||||
client = get_client()
|
||||
|
||||
async with client.threads.stream(
|
||||
thread_id="my-thread",
|
||||
assistant_id="agent",
|
||||
) as thread:
|
||||
await thread.run.start(input={"messages": [{"role": "user", "content": "hi"}]})
|
||||
|
||||
# Start all consumers concurrently so they share one SSE connection.
|
||||
async def get_messages():
|
||||
return [s async for s in thread.messages]
|
||||
|
||||
async def get_tool_calls():
|
||||
return [c async for c in thread.tool_calls]
|
||||
|
||||
messages, tool_calls = await asyncio.gather(get_messages(), get_tool_calls())
|
||||
|
||||
for stream in messages:
|
||||
print(await stream.text) # accumulated text
|
||||
|
||||
final = await thread.output # terminal state values
|
||||
```
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Thin layer on top of the latest published langgraph-api image.
|
||||
#
|
||||
# The base image bundles `langgraph_api`, `langgraph_runtime_postgres`,
|
||||
# `langgraph_license`, `langgraph_grpc_common`, the Go `core-api-grpc`
|
||||
# binary, and an entrypoint that starts both the gRPC server and uvicorn
|
||||
# (`/storage/entrypoint.sh`). It also ships langgraph + langchain-core.
|
||||
#
|
||||
# We track the `latest-py3.12` tag rather than pinning a specific revision
|
||||
# so CI surfaces upstream regressions early. If the base shifts under us,
|
||||
# `docker compose build` will pick up the new digest on the next run.
|
||||
#
|
||||
# The image is the `licensed` variant, so it requires either a real
|
||||
# `LANGSMITH_API_KEY` or a `LANGGRAPH_CLOUD_LICENSE_KEY` at runtime
|
||||
# (passed through from the host shell / CI secrets, see docker-compose.yml).
|
||||
|
||||
FROM langchain/langgraph-api:latest-py3.12
|
||||
|
||||
# Graph dependencies not in the base image. `deepagents` is required for
|
||||
# the deep_agent graph; the supervisor and researcher use a fake chat
|
||||
# model (no `langchain-anthropic`) so no LLM API key is needed.
|
||||
RUN pip install --no-cache-dir \
|
||||
"langchain>=1.3.0" \
|
||||
"deepagents>=0.6.2"
|
||||
|
||||
# Project graphs + registration config.
|
||||
COPY graph/ /app/graph/
|
||||
COPY langgraph.json /app/langgraph.json
|
||||
@@ -0,0 +1,91 @@
|
||||
name: langgraph-v3-integration
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
environment:
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
command: ["postgres", "-c", "shared_preload_libraries=vector", "-c", "max_connections=150"]
|
||||
ports:
|
||||
- "5443:5432"
|
||||
healthcheck:
|
||||
test: pg_isready -U postgres
|
||||
interval: 5s
|
||||
timeout: 1s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
tmpfs:
|
||||
- /var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
ports:
|
||||
- "6380:6379"
|
||||
healthcheck:
|
||||
test: redis-cli ping
|
||||
interval: 5s
|
||||
timeout: 1s
|
||||
retries: 5
|
||||
start_period: 2s
|
||||
tmpfs:
|
||||
- /data
|
||||
|
||||
api:
|
||||
# Thin layer on top of langchain/langgraph-api:latest-py3.12 —
|
||||
# see ./Dockerfile. The base image bundles langgraph-api +
|
||||
# langgraph_runtime_postgres + langgraph_license + the Go core-server,
|
||||
# so we only add graph deps (deepagents) and the graph files on top.
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: langgraph-v3-integration-api:local
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
# Database + cache (the production-shape postgres+redis runtime).
|
||||
# Recent langgraph-api reads DATABASE_URI first and falls back to
|
||||
# POSTGRES_URI; set both so older code paths also work.
|
||||
DATABASE_URI: postgres://postgres:postgres@postgres:5432/postgres?sslmode=disable
|
||||
POSTGRES_URI: postgres://postgres:postgres@postgres:5432/postgres?sslmode=disable
|
||||
REDIS_URI: redis://redis:6379
|
||||
LANGGRAPH_RUNTIME_EDITION: postgres
|
||||
|
||||
LANGGRAPH_AUTH_TYPE: noop
|
||||
LANGSMITH_TRACING: "false"
|
||||
|
||||
PORT: "8000"
|
||||
|
||||
# Required for the v3 thread-centric streaming protocol. Without this
|
||||
# flag the API falls back to the legacy v2 streaming surface and the
|
||||
# client's v3 endpoints (POST /threads/{id}/stream/events, /commands,
|
||||
# etc.) won't be exposed.
|
||||
FF_OPTIMIZED_STREAMING: "true"
|
||||
|
||||
# Tell langgraph-api which graphs to register. The langgraph CLI sets
|
||||
# this from langgraph.json; we're bypassing the CLI (running uvicorn
|
||||
# directly) so we set it manually.
|
||||
LANGSERVE_GRAPHS: '{"agent":"/app/graph/streaming_graph.py:graph","tools_agent":"/app/graph/tools_agent.py:graph","deep_agent":"/app/graph/deep_agent.py:graph"}'
|
||||
|
||||
# The published langgraph-api image is the `licensed` variant and
|
||||
# requires a real LANGSMITH_API_KEY (or LANGGRAPH_CLOUD_LICENSE_KEY)
|
||||
# at runtime. Passed through from the host shell / CI secrets.
|
||||
LANGSMITH_API_KEY: ${LANGSMITH_API_KEY:-}
|
||||
LANGGRAPH_CLOUD_LICENSE_KEY: ${LANGGRAPH_CLOUD_LICENSE_KEY:-}
|
||||
# Mount the graph + config so edits don't require a rebuild.
|
||||
volumes:
|
||||
- ./graph:/app/graph:ro
|
||||
- ./langgraph.json:/app/langgraph.json:ro
|
||||
ports:
|
||||
- "2024:8000"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O- http://localhost:8000/ok || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
start_period: 30s
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Example graphs for v3 streaming integration tests."""
|
||||
|
||||
from .deep_agent import graph as deep_agent
|
||||
from .streaming_graph import graph as streaming_graph
|
||||
|
||||
__all__ = ["deep_agent", "streaming_graph"]
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Deep-agent variant exercising v3 `thread.subgraphs` properly.
|
||||
|
||||
`create_deep_agent` builds a graph whose `task` tool dispatches to one
|
||||
of its configured `SubAgent`s. When the supervisor's model issues a
|
||||
`task(subagent_type="researcher", description=...)` tool call, the
|
||||
sub-agent runs as a nested invocation and the v3 streaming server
|
||||
emits the subagent's lifecycle, messages, and tool events under a
|
||||
scoped namespace. That namespace is what `thread.subgraphs` surfaces
|
||||
as a direct-child `ScopedStreamHandle`.
|
||||
|
||||
Both the supervisor and the researcher use `FakeMessagesListChatModel`
|
||||
with pre-scripted responses so this graph is hermetic. No LLM API keys
|
||||
are required, and the test is deterministic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from deepagents import create_deep_agent
|
||||
from deepagents.middleware.subagents import SubAgent
|
||||
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
|
||||
class _FakeChatModelWithTools(FakeMessagesListChatModel):
|
||||
"""`FakeMessagesListChatModel` that accepts `bind_tools(...)` as a no-op.
|
||||
|
||||
`create_deep_agent` calls `model.bind_tools(tools)` to expose the `task`
|
||||
tool to the supervisor. The base `BaseChatModel.bind_tools` raises
|
||||
`NotImplementedError`. Pre-baked responses in `responses` already carry
|
||||
the desired `tool_calls`, so we ignore the tools list and return self.
|
||||
"""
|
||||
|
||||
def bind_tools(self, tools: Any, **kwargs: Any) -> _FakeChatModelWithTools:
|
||||
return self
|
||||
|
||||
|
||||
# Supervisor turn 1: dispatch to the researcher via the `task` tool.
|
||||
# Supervisor turn 2: emit a final assistant message (no more tool calls),
|
||||
# which closes the agent loop.
|
||||
_supervisor_model = _FakeChatModelWithTools(
|
||||
responses=[
|
||||
AIMessage(
|
||||
content="",
|
||||
id="sup-1",
|
||||
tool_calls=[
|
||||
{
|
||||
"id": "tc-task-1",
|
||||
"name": "task",
|
||||
"args": {
|
||||
"subagent_type": "researcher",
|
||||
"description": "research v3 streaming",
|
||||
},
|
||||
}
|
||||
],
|
||||
),
|
||||
AIMessage(content="Research complete.", id="sup-2"),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# Researcher turn 1: final message, no tool calls. Closes the subagent loop.
|
||||
_researcher_model = _FakeChatModelWithTools(
|
||||
responses=[
|
||||
AIMessage(
|
||||
content="v3 streaming is event-typed and thread-centric.", id="res-1"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
_researcher: SubAgent = {
|
||||
"name": "researcher",
|
||||
"description": (
|
||||
"Looks up notes on a topic and returns a short summary. "
|
||||
"Use this when the user wants to research something."
|
||||
),
|
||||
"system_prompt": (
|
||||
"You are a research assistant. Reply with one or two sentences "
|
||||
"summarising what the user asked about. Do not call any tools."
|
||||
),
|
||||
"model": _researcher_model,
|
||||
}
|
||||
|
||||
|
||||
graph = create_deep_agent(
|
||||
model=_supervisor_model,
|
||||
system_prompt=(
|
||||
"You are a supervisor coordinating a researcher subagent. "
|
||||
"When the user asks to research anything, call the `task` tool "
|
||||
"with subagent_type='researcher'."
|
||||
),
|
||||
subagents=[_researcher],
|
||||
name="v3_deep_agent",
|
||||
)
|
||||
@@ -0,0 +1,300 @@
|
||||
"""Example graph exercising the full v3 streaming surface.
|
||||
|
||||
Topology:
|
||||
|
||||
__start__ -> stream_message -> call_tool -> ask_human -> subgraph -> __end__
|
||||
|
||||
Each node is designed to surface a specific v3 channel:
|
||||
|
||||
- `stream_message` yields token-by-token AI message chunks (`messages`).
|
||||
- `call_tool` invokes a tool and emits a tool-call lifecycle (`tools`).
|
||||
- `ask_human` raises an `interrupt(...)` to test `thread.interrupted` /
|
||||
`thread.run.respond(...)` (`lifecycle` / `input`).
|
||||
- `subgraph` is a nested `StateGraph` invoked once so `thread.subgraphs` has
|
||||
exactly one direct child (`tasks` + `messages` under a namespace).
|
||||
|
||||
Extensions: every node calls `get_stream_writer()("progress", {...})` so
|
||||
`thread.extensions["progress"]` produces deterministic events.
|
||||
|
||||
No real LLM is used — message streaming is simulated by yielding a list of
|
||||
`AIMessageChunk`s from the node. This keeps the integration suite
|
||||
hermetic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import operator
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from typing import Annotated, Any, TypedDict
|
||||
|
||||
from langchain_core.callbacks import (
|
||||
AsyncCallbackManagerForLLMRun,
|
||||
CallbackManagerForLLMRun,
|
||||
)
|
||||
from langchain_core.language_models.chat_models import BaseChatModel
|
||||
from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage, ToolMessage
|
||||
from langchain_core.outputs import ChatGenerationChunk
|
||||
from langchain_core.tools import tool
|
||||
from langgraph.config import get_stream_writer
|
||||
from langgraph.graph import StateGraph
|
||||
from langgraph.graph.message import add_messages
|
||||
from langgraph.stream.transformers import CustomTransformer, UpdatesTransformer
|
||||
from langgraph.types import interrupt
|
||||
|
||||
|
||||
class _StreamingFakeChatModel(BaseChatModel):
|
||||
"""Fake ``BaseChatModel`` that streams ``AIMessageChunk``s.
|
||||
|
||||
Implements ``_stream`` / ``_astream`` so the v3 chat-model
|
||||
callback chain (``_aiter_v2_events`` in
|
||||
``langchain_core/language_models/chat_models.py``) fires
|
||||
``run_manager.on_stream_event(...)`` per normalized protocol
|
||||
event. ``StreamMessagesHandlerV2`` -- attached by the langgraph
|
||||
runtime when ``"messages"`` is in stream_modes -- catches those
|
||||
callbacks and surfaces them on the v3 wire ``messages`` channel
|
||||
at root namespace.
|
||||
|
||||
The base ``FakeMessagesListChatModel`` would have worked for
|
||||
``ainvoke`` but raises ``NotImplementedError`` from ``_stream``,
|
||||
so it can't drive the streaming-callback path. ``GenericFakeChatModel``
|
||||
implements ``_stream`` but takes an ``Iterator`` that gets
|
||||
exhausted across invocations.
|
||||
"""
|
||||
|
||||
text: str = "Hello, world!"
|
||||
message_id: str = "ai-msg-1"
|
||||
|
||||
@property
|
||||
def _llm_type(self) -> str:
|
||||
return "streaming-fake-chat-model"
|
||||
|
||||
def _generate(self, messages, stop=None, run_manager=None, **kwargs):
|
||||
from langchain_core.outputs import ChatGeneration, ChatResult
|
||||
|
||||
return ChatResult(
|
||||
generations=[
|
||||
ChatGeneration(message=AIMessage(content=self.text, id=self.message_id))
|
||||
]
|
||||
)
|
||||
|
||||
def _stream(
|
||||
self,
|
||||
messages: list[BaseMessage],
|
||||
stop: list[str] | None = None,
|
||||
run_manager: CallbackManagerForLLMRun | None = None,
|
||||
**kwargs: object,
|
||||
) -> Iterator[ChatGenerationChunk]:
|
||||
# Yield content as space-separated word chunks so deltas are
|
||||
# observable. The final chunk's ``chunk_position="last"`` tells
|
||||
# the callback chain to emit ``message-finish``.
|
||||
parts = self.text.split(" ")
|
||||
for i, part in enumerate(parts):
|
||||
content = part if i == 0 else " " + part
|
||||
chunk = AIMessageChunk(content=content, id=self.message_id)
|
||||
if i == len(parts) - 1:
|
||||
chunk.chunk_position = "last"
|
||||
yield ChatGenerationChunk(message=chunk)
|
||||
|
||||
async def _astream(
|
||||
self,
|
||||
messages: list[BaseMessage],
|
||||
stop: list[str] | None = None,
|
||||
run_manager: AsyncCallbackManagerForLLMRun | None = None,
|
||||
**kwargs: object,
|
||||
) -> AsyncIterator[ChatGenerationChunk]:
|
||||
for chunk in self._stream(messages, stop=stop, **kwargs):
|
||||
yield chunk
|
||||
|
||||
|
||||
_stream_model = _StreamingFakeChatModel()
|
||||
|
||||
|
||||
class AgentState(TypedDict):
|
||||
"""Top-level state for the agent.
|
||||
|
||||
`messages` accumulates AI/tool/user messages via the standard `add_messages`
|
||||
reducer. `value` is a simple scalar to test the `values` channel.
|
||||
`items` accumulates list-append updates via `operator.add` so each node
|
||||
contributes a marker and the terminal state reflects the full path
|
||||
rather than only the last node's return.
|
||||
"""
|
||||
|
||||
messages: Annotated[list[BaseMessage], add_messages]
|
||||
value: str
|
||||
items: Annotated[list[str], operator.add]
|
||||
|
||||
|
||||
@tool
|
||||
def search(query: str) -> str:
|
||||
"""Look up `query` in a fake search index."""
|
||||
return f"result for {query!r}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nodes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def stream_message(state: AgentState) -> dict[str, Any]:
|
||||
"""Stream an AI message via a fake chat model.
|
||||
|
||||
Awaiting ``model.ainvoke(...)`` drives langgraph's chat-model
|
||||
streaming callbacks (``StreamMessagesHandlerV2`` ->
|
||||
``MessagesTransformer``), so the v3 ``messages`` channel emits the
|
||||
normalized delta lifecycle (``message-start`` ->
|
||||
``content-block-start`` -> ``content-block-delta`` ->
|
||||
``content-block-finish`` -> ``message-finish``) at root namespace.
|
||||
Returning the resolved ``AIMessage`` via the messages reducer also
|
||||
keeps the existing ``values`` snapshots intact.
|
||||
"""
|
||||
writer = get_stream_writer()
|
||||
|
||||
writer({"name": "progress", "step": "stream_message", "phase": "start"})
|
||||
|
||||
# ``astream_events(version="v3")`` drives the chat model's
|
||||
# ``_aiter_v2_events`` path (``BaseChatModel`` in
|
||||
# ``langchain_core/language_models/chat_models.py``), which fires
|
||||
# ``run_manager.on_stream_event(...)`` per normalized protocol
|
||||
# event (``message-start`` / ``content-block-delta`` /
|
||||
# ``message-finish``). ``StreamMessagesHandlerV2`` -- attached by
|
||||
# the langgraph runtime when ``"messages"`` is in stream_modes --
|
||||
# catches those callbacks and surfaces them on the v3 wire
|
||||
# ``messages`` channel at root namespace. Plain ``astream(...)``
|
||||
# does NOT route through this handler.
|
||||
text_parts: list[str] = []
|
||||
message_id = "ai-msg-1"
|
||||
# ``astream_events(version="v3")`` returns an awaitable that resolves
|
||||
# to the async iterator.
|
||||
stream = await _stream_model.astream_events([], version="v3")
|
||||
async for event in stream:
|
||||
if event.get("event") == "content-block-delta":
|
||||
delta = event.get("delta") or {}
|
||||
t = delta.get("text") if isinstance(delta, dict) else None
|
||||
if isinstance(t, str):
|
||||
text_parts.append(t)
|
||||
elif event.get("event") == "message-start":
|
||||
mid = event.get("id")
|
||||
if isinstance(mid, str):
|
||||
message_id = mid
|
||||
final = AIMessage(content="".join(text_parts), id=message_id)
|
||||
|
||||
writer({"name": "progress", "step": "stream_message", "phase": "end"})
|
||||
return {"messages": [final], "value": "x", "items": ["streamed"]}
|
||||
|
||||
|
||||
def call_tool(state: AgentState) -> dict[str, Any]:
|
||||
"""Invoke a tool and emit its result as a tool message.
|
||||
|
||||
A tool call here exercises the `tools` channel in v3.
|
||||
"""
|
||||
writer = get_stream_writer()
|
||||
writer({"name": "progress", "step": "call_tool", "phase": "start"})
|
||||
|
||||
# Hand-roll a tool call so we don't need a model to issue it.
|
||||
tool_call_id = "tc-1"
|
||||
ai_with_tool = AIMessage(
|
||||
content="",
|
||||
id="ai-msg-2",
|
||||
tool_calls=[
|
||||
{
|
||||
"id": tool_call_id,
|
||||
"name": "search",
|
||||
"args": {"query": "v3"},
|
||||
}
|
||||
],
|
||||
)
|
||||
result = search.invoke({"query": "v3"})
|
||||
tool_msg = ToolMessage(content=result, tool_call_id=tool_call_id)
|
||||
|
||||
writer({"name": "progress", "step": "call_tool", "phase": "end"})
|
||||
return {
|
||||
"messages": [ai_with_tool, tool_msg],
|
||||
"items": ["tool"],
|
||||
}
|
||||
|
||||
|
||||
def ask_human(state: AgentState) -> dict[str, Any]:
|
||||
"""Pause the graph and wait for a `thread.run.respond(...)`.
|
||||
|
||||
`interrupt(value)` raises a special exception that the runtime catches;
|
||||
the v3 lifecycle emits `input.requested` with this `value` and the
|
||||
client must call `thread.run.respond(answer)` to continue.
|
||||
"""
|
||||
writer = get_stream_writer()
|
||||
writer({"name": "progress", "step": "ask_human", "phase": "start"})
|
||||
|
||||
answer = interrupt("Are we good?")
|
||||
|
||||
writer(
|
||||
{"name": "progress", "step": "ask_human", "phase": "end", "answer": str(answer)}
|
||||
)
|
||||
return {
|
||||
"messages": [AIMessage(content=f"Human said: {answer}", id="ai-msg-3")],
|
||||
"items": ["asked"],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subgraph (exercises `thread.subgraphs`)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SubState(TypedDict):
|
||||
messages: Annotated[list[BaseMessage], add_messages]
|
||||
note: str
|
||||
|
||||
|
||||
def sub_node(state: SubState) -> dict[str, Any]:
|
||||
"""Single node in the subgraph; emits a message and a custom event."""
|
||||
writer = get_stream_writer()
|
||||
writer({"name": "progress", "step": "sub_node", "phase": "start"})
|
||||
msg = AIMessage(content="from subgraph", id="sub-msg-1")
|
||||
writer({"name": "progress", "step": "sub_node", "phase": "end"})
|
||||
return {"messages": [msg], "note": "ran"}
|
||||
|
||||
|
||||
_sub_builder = StateGraph(SubState)
|
||||
_sub_builder.add_node("sub", sub_node)
|
||||
_sub_builder.set_entry_point("sub")
|
||||
_sub_builder.set_finish_point("sub")
|
||||
subgraph = _sub_builder.compile()
|
||||
|
||||
|
||||
def run_subgraph(state: AgentState) -> dict[str, Any]:
|
||||
"""Invoke the subgraph once so it appears as a direct child handle."""
|
||||
writer = get_stream_writer()
|
||||
writer({"name": "progress", "step": "run_subgraph", "phase": "start"})
|
||||
sub_state = subgraph.invoke({"messages": [], "note": ""})
|
||||
writer({"name": "progress", "step": "run_subgraph", "phase": "end"})
|
||||
return {
|
||||
"messages": sub_state["messages"],
|
||||
"items": ["sub"],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Top-level graph
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_builder: StateGraph[AgentState, Any, Any, Any] = StateGraph(AgentState)
|
||||
_builder.add_node("stream_message", stream_message)
|
||||
_builder.add_node("call_tool", call_tool)
|
||||
_builder.add_node("ask_human", ask_human)
|
||||
_builder.add_node("run_subgraph", run_subgraph)
|
||||
|
||||
_builder.set_entry_point("stream_message")
|
||||
_builder.add_edge("stream_message", "call_tool")
|
||||
_builder.add_edge("call_tool", "ask_human")
|
||||
_builder.add_edge("ask_human", "run_subgraph")
|
||||
_builder.set_finish_point("run_subgraph")
|
||||
|
||||
graph = _builder.compile(
|
||||
name="v3_integration_agent",
|
||||
# Register transformers so ``custom`` (``get_stream_writer()``) and
|
||||
# ``updates`` channels emit on the wire. ``MessagesTransformer`` is
|
||||
# auto-registered by the v3 mux for any graph that streams a chat
|
||||
# model. ``ValuesTransformer`` / ``LifecycleTransformer`` are also
|
||||
# always-on natives.
|
||||
transformers=[CustomTransformer, UpdatesTransformer],
|
||||
)
|
||||
@@ -0,0 +1,91 @@
|
||||
"""create_agent-based example exercising the v3 ``tools`` channel.
|
||||
|
||||
`thread.tool_calls` and the underlying ``tools`` channel only emit
|
||||
events when an actual model issues a tool call through langchain's
|
||||
agent stack. The synthetic ``streaming_graph.py`` hand-builds
|
||||
`AIMessage(tool_calls=[...])` and a `ToolMessage` via the messages
|
||||
reducer — that gets persisted in state but never produces tool-call
|
||||
telemetry on the wire. This graph fixes that by going through
|
||||
`create_agent` with a real tool, driven by a `GenericFakeChatModel` so
|
||||
the test stays hermetic (no `ANTHROPIC_API_KEY` required).
|
||||
|
||||
Flow on `run.start`:
|
||||
|
||||
1. Supervisor model returns an `AIMessage(tool_calls=[search(query="v3")])`.
|
||||
2. langchain's tool node executes `search` and produces a `ToolMessage`.
|
||||
3. Supervisor model returns a final `AIMessage("done.")` to terminate.
|
||||
|
||||
The v3 streaming layer surfaces this as `messages` + `tools` channel
|
||||
events at root namespace.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
|
||||
from langchain_core.messages import AIMessage
|
||||
from langchain_core.tools import tool
|
||||
|
||||
|
||||
@tool
|
||||
def search(query: str) -> str:
|
||||
"""Look up `query` in a fake search index."""
|
||||
return f"result for {query!r}"
|
||||
|
||||
|
||||
class _ToolBindingFakeChatModel(FakeMessagesListChatModel):
|
||||
"""Fake chat model that satisfies `create_agent`'s ``bind_tools`` call.
|
||||
|
||||
``create_agent`` calls ``model.bind_tools(tools)`` to attach the tool
|
||||
schema (``langchain/agents/factory.py:1284``). The base
|
||||
``FakeMessagesListChatModel`` inherits ``BaseChatModel.bind_tools``,
|
||||
which raises ``NotImplementedError``. We don't actually need the
|
||||
bound schema — the fake replays scripted ``AIMessage``s with their
|
||||
own ``tool_calls`` field — so override ``bind_tools`` as a no-op.
|
||||
|
||||
``FakeMessagesListChatModel`` is preferred over
|
||||
``GenericFakeChatModel`` here because the latter's ``_stream``
|
||||
breaks the message into content chunks and **drops ``tool_calls``**
|
||||
when content is empty, causing the v2 streaming path inside
|
||||
``create_agent`` to raise ``RuntimeError("v2 stream finished
|
||||
without producing a message")``. ``FakeMessagesListChatModel``
|
||||
falls back to the default ``_stream`` that yields the whole
|
||||
message in one chunk, preserving ``tool_calls``.
|
||||
"""
|
||||
|
||||
def bind_tools(self, tools: Any, **kwargs: Any) -> _ToolBindingFakeChatModel:
|
||||
return self
|
||||
|
||||
|
||||
# Two scripted turns. ``FakeMessagesListChatModel`` cycles through
|
||||
# ``responses`` (resetting to index 0 after the last) so the graph
|
||||
# can be run many times without restart; per run, ``create_agent``
|
||||
# invokes the model exactly twice (once to issue the tool call,
|
||||
# once after the tool result to produce the terminating answer).
|
||||
_supervisor_responses: list[AIMessage] = [
|
||||
AIMessage(
|
||||
content="",
|
||||
id="ai-tools-1",
|
||||
tool_calls=[
|
||||
{
|
||||
"id": "tc-1",
|
||||
"name": "search",
|
||||
"args": {"query": "v3"},
|
||||
}
|
||||
],
|
||||
),
|
||||
AIMessage(content="done.", id="ai-tools-2"),
|
||||
]
|
||||
|
||||
|
||||
_supervisor_model = _ToolBindingFakeChatModel(responses=_supervisor_responses)
|
||||
|
||||
|
||||
graph = create_agent(
|
||||
model=_supervisor_model,
|
||||
tools=[search],
|
||||
system_prompt="You are a research assistant. Use the search tool when asked.",
|
||||
name="v3_tools_agent",
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://langgra.ph/schema.json",
|
||||
"dependencies": ["./graph"],
|
||||
"graphs": {
|
||||
"agent": "./graph/streaming_graph.py:graph",
|
||||
"tools_agent": "./graph/tools_agent.py:graph",
|
||||
"deep_agent": "./graph/deep_agent.py:graph"
|
||||
},
|
||||
"env": {}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Shared helpers for the v3 streaming integration scripts.
|
||||
|
||||
All scripts share the same expectations:
|
||||
|
||||
- A `langgraph-api` server is reachable at `BASE_URL` (default
|
||||
http://localhost:2024 — set by `docker-compose.yml`, which builds
|
||||
on `langchain/langgraph-api:latest-py3.12`).
|
||||
- The example graph (`integration/graph/streaming_graph.py:graph`) is
|
||||
registered under the assistant id `agent` (see `integration/langgraph.json`).
|
||||
|
||||
Each script imports `make_async_client()` / `make_sync_client()` from here
|
||||
to construct the v3 SDK client. Override `BASE_URL` via the
|
||||
`LANGGRAPH_INTEGRATION_URL` env var if you're running the API elsewhere.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import os
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import httpx
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langgraph_sdk._async.threads import ThreadsClient as AsyncThreadsClient
|
||||
from langgraph_sdk._sync.threads import SyncThreadsClient
|
||||
|
||||
BASE_URL = os.environ.get("LANGGRAPH_INTEGRATION_URL", "http://localhost:2024")
|
||||
ASSISTANT_ID = "agent"
|
||||
|
||||
|
||||
def make_async_client() -> tuple[AsyncThreadsClient, httpx.AsyncClient]:
|
||||
"""Build an async ThreadsClient pointing at the integration API.
|
||||
|
||||
Returns the client and the underlying httpx client so callers can close
|
||||
it. Typical usage:
|
||||
|
||||
```python
|
||||
threads, raw = make_async_client()
|
||||
try:
|
||||
async with threads.stream(...) as thread:
|
||||
...
|
||||
finally:
|
||||
await raw.aclose()
|
||||
```
|
||||
"""
|
||||
from langgraph_sdk._async.http import HttpClient
|
||||
from langgraph_sdk._async.threads import ThreadsClient
|
||||
|
||||
raw = httpx.AsyncClient(base_url=BASE_URL, timeout=30.0)
|
||||
return ThreadsClient(HttpClient(raw)), raw
|
||||
|
||||
|
||||
def make_sync_client() -> tuple[SyncThreadsClient, httpx.Client]:
|
||||
"""Build a sync ThreadsClient pointing at the integration API."""
|
||||
from langgraph_sdk._sync.http import SyncHttpClient
|
||||
from langgraph_sdk._sync.threads import SyncThreadsClient
|
||||
|
||||
raw = httpx.Client(base_url=BASE_URL, timeout=30.0)
|
||||
return SyncThreadsClient(SyncHttpClient(raw)), raw
|
||||
|
||||
|
||||
def header(title: str) -> None:
|
||||
"""Print a section header for script output."""
|
||||
bar = "=" * (len(title) + 4)
|
||||
print(f"\n{bar}\n {title}\n{bar}")
|
||||
|
||||
|
||||
def auto_respond_async(thread: Any, response: Any = "yes") -> asyncio.Task[None]:
|
||||
"""Spawn a background task that responds to the first interrupt and exits.
|
||||
|
||||
Opens a private `thread.values` subscription and drains it; the SDK's
|
||||
`_signal_paused` mechanism pushes the terminal sentinel into the
|
||||
iterator when `interrupted` flips True, so the loop exits at the
|
||||
interrupt. The auto-responder then calls `run.respond(...)` and the
|
||||
foreground iteration sees the rest of the run.
|
||||
|
||||
Returns the task so callers can `await` it before tearing down the
|
||||
stream (recommended) or cancel it.
|
||||
"""
|
||||
|
||||
async def _runner() -> None:
|
||||
async for _ in thread.values:
|
||||
if thread.interrupted:
|
||||
break
|
||||
if thread.interrupted:
|
||||
with contextlib.suppress(Exception):
|
||||
await thread.run.respond(response)
|
||||
|
||||
return asyncio.create_task(_runner())
|
||||
|
||||
|
||||
def auto_respond_sync(thread: Any, response: Any = "yes") -> threading.Thread:
|
||||
"""Sync analogue of `auto_respond_async`."""
|
||||
|
||||
def _runner() -> None:
|
||||
for _ in thread.values:
|
||||
if thread.interrupted:
|
||||
break
|
||||
if thread.interrupted:
|
||||
with contextlib.suppress(Exception):
|
||||
thread.run.respond(response)
|
||||
|
||||
t = threading.Thread(target=_runner, daemon=True, name="auto-respond")
|
||||
t.start()
|
||||
return t
|
||||
|
||||
|
||||
def check_api_reachable() -> None:
|
||||
"""Fail fast with a helpful message if the API isn't reachable.
|
||||
|
||||
Call this at the top of `main()` in each script.
|
||||
"""
|
||||
try:
|
||||
resp = httpx.get(f"{BASE_URL}/ok", timeout=2.0)
|
||||
resp.raise_for_status()
|
||||
except Exception as err:
|
||||
raise SystemExit(
|
||||
f"\nCannot reach the integration API at {BASE_URL}: {err!r}\n"
|
||||
f"Did you run `docker compose up -d` from `libs/sdk-py/integration/`?\n"
|
||||
f"Or set LANGGRAPH_INTEGRATION_URL=... to point elsewhere.\n"
|
||||
) from err
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Exercise mid-run cancellation against the integration API.
|
||||
|
||||
Strategy: start a run on a fresh thread, capture the run id, then
|
||||
cancel via the runs REST client while events are still flowing. The
|
||||
projection iterator must terminate without hanging, no exception
|
||||
should escape, and the thread's persisted status must reflect a
|
||||
non-success terminal state.
|
||||
|
||||
The graph normally interrupts at `ask_human`; cancel must take effect
|
||||
before or after that interrupt, and either way the run must end up in
|
||||
a non-success state from the server's perspective.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from _common import (
|
||||
ASSISTANT_ID,
|
||||
check_api_reachable,
|
||||
header,
|
||||
make_async_client,
|
||||
make_sync_client,
|
||||
)
|
||||
|
||||
_CANCEL_GRACE_SECONDS = 10.0
|
||||
|
||||
|
||||
async def _cancel_after_first_event(
|
||||
runs_client: Any,
|
||||
thread_id: str,
|
||||
run_id_future: asyncio.Future[str],
|
||||
) -> None:
|
||||
"""Wait for the run id, briefly let events flow, then cancel."""
|
||||
run_id = await run_id_future
|
||||
# Allow a beat of events to flow so cancel hits mid-stream rather
|
||||
# than racing with the run.start handshake.
|
||||
await asyncio.sleep(0.1)
|
||||
with contextlib.suppress(Exception):
|
||||
await runs_client.cancel(thread_id, run_id, wait=False)
|
||||
|
||||
|
||||
async def run_async() -> None:
|
||||
header("async mid-run cancel")
|
||||
threads, raw = make_async_client()
|
||||
# Cancel goes through the runs REST surface, not the stream proxy.
|
||||
from langgraph_sdk._async.http import HttpClient
|
||||
from langgraph_sdk._async.runs import RunsClient
|
||||
|
||||
runs_client = RunsClient(HttpClient(raw))
|
||||
try:
|
||||
async with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
run_id_future: asyncio.Future[str] = (
|
||||
asyncio.get_running_loop().create_future()
|
||||
)
|
||||
start_result = await thread.run.start(
|
||||
input={"messages": [], "value": "init", "items": []}
|
||||
)
|
||||
run_id = start_result.get("run_id")
|
||||
assert run_id, f"run.start returned no run_id: {start_result!r}"
|
||||
run_id_future.set_result(run_id)
|
||||
|
||||
canceller = asyncio.create_task(
|
||||
_cancel_after_first_event(runs_client, thread.thread_id, run_id_future)
|
||||
)
|
||||
|
||||
snapshots: list[dict] = []
|
||||
started = time.monotonic()
|
||||
iteration_error: BaseException | None = None
|
||||
try:
|
||||
async for snap in thread.values:
|
||||
snapshots.append(snap)
|
||||
if time.monotonic() - started > _CANCEL_GRACE_SECONDS:
|
||||
raise AssertionError(
|
||||
f"values iterator did not terminate within "
|
||||
f"{_CANCEL_GRACE_SECONDS}s of cancel"
|
||||
)
|
||||
except BaseException as err:
|
||||
iteration_error = err
|
||||
|
||||
await canceller
|
||||
|
||||
persisted = await threads.get(thread.thread_id)
|
||||
status = persisted.get("status")
|
||||
print(f" snapshots before cancel: {len(snapshots)}")
|
||||
print(f" thread.thread_id={thread.thread_id}")
|
||||
print(f" iteration_error={iteration_error!r}")
|
||||
print(f" persisted status={status!r}")
|
||||
assert iteration_error is None, (
|
||||
f"values iterator raised after cancel: {iteration_error!r}"
|
||||
)
|
||||
assert status != "success", (
|
||||
f"expected non-success terminal status after cancel, got {status!r}"
|
||||
)
|
||||
finally:
|
||||
await raw.aclose()
|
||||
|
||||
|
||||
def _cancel_after_first_event_sync(
|
||||
runs_client: Any,
|
||||
thread_id: str,
|
||||
run_id_event: threading.Event,
|
||||
run_id_holder: dict[str, str],
|
||||
) -> None:
|
||||
run_id_event.wait(timeout=10.0)
|
||||
run_id = run_id_holder.get("run_id")
|
||||
if not run_id:
|
||||
return
|
||||
time.sleep(0.1)
|
||||
with contextlib.suppress(Exception):
|
||||
runs_client.cancel(thread_id, run_id, wait=False)
|
||||
|
||||
|
||||
def run_sync() -> None:
|
||||
header("sync mid-run cancel")
|
||||
threads, raw = make_sync_client()
|
||||
from langgraph_sdk._sync.http import SyncHttpClient
|
||||
from langgraph_sdk._sync.runs import SyncRunsClient
|
||||
|
||||
runs_client = SyncRunsClient(SyncHttpClient(raw))
|
||||
try:
|
||||
with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
run_id_event = threading.Event()
|
||||
run_id_holder: dict[str, str] = {}
|
||||
start_result = thread.run.start(
|
||||
input={"messages": [], "value": "init", "items": []}
|
||||
)
|
||||
run_id = start_result.get("run_id")
|
||||
assert run_id, f"run.start returned no run_id: {start_result!r}"
|
||||
run_id_holder["run_id"] = run_id
|
||||
run_id_event.set()
|
||||
|
||||
canceller = threading.Thread(
|
||||
target=_cancel_after_first_event_sync,
|
||||
args=(runs_client, thread.thread_id, run_id_event, run_id_holder),
|
||||
daemon=True,
|
||||
name="cancel-worker",
|
||||
)
|
||||
canceller.start()
|
||||
|
||||
snapshots: list[dict] = []
|
||||
started = time.monotonic()
|
||||
iteration_error: BaseException | None = None
|
||||
try:
|
||||
for snap in thread.values:
|
||||
snapshots.append(snap)
|
||||
if time.monotonic() - started > _CANCEL_GRACE_SECONDS:
|
||||
raise AssertionError(
|
||||
f"values iterator did not terminate within "
|
||||
f"{_CANCEL_GRACE_SECONDS}s of cancel"
|
||||
)
|
||||
except BaseException as err:
|
||||
iteration_error = err
|
||||
|
||||
canceller.join(timeout=5)
|
||||
|
||||
persisted = threads.get(thread.thread_id)
|
||||
status = persisted.get("status")
|
||||
print(f" snapshots before cancel: {len(snapshots)}")
|
||||
print(f" thread.thread_id={thread.thread_id}")
|
||||
print(f" iteration_error={iteration_error!r}")
|
||||
print(f" persisted status={status!r}")
|
||||
assert iteration_error is None, (
|
||||
f"values iterator raised after cancel: {iteration_error!r}"
|
||||
)
|
||||
assert status != "success", (
|
||||
f"expected non-success terminal status after cancel, got {status!r}"
|
||||
)
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
raw.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
check_api_reachable()
|
||||
asyncio.run(run_async())
|
||||
run_sync()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Exercise concurrent `threads.stream()` against the integration API.
|
||||
|
||||
Two distinct threads.stream() contexts run in parallel against the same
|
||||
client. Each context is independent (different thread_id minted by the
|
||||
SDK, separate controller, separate auto-responder). Invariants:
|
||||
|
||||
1. Both runs reach the canonical terminal state independently
|
||||
(`items == ['streamed','tool','asked','sub']`).
|
||||
2. Their thread_ids differ (no thread-id collision when minting client-side).
|
||||
3. Neither raises during iteration.
|
||||
|
||||
This catches regressions where the two streams might share controller
|
||||
state or where minted ids could collide under concurrent ``__aenter__``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from _common import (
|
||||
ASSISTANT_ID,
|
||||
auto_respond_async,
|
||||
auto_respond_sync,
|
||||
check_api_reachable,
|
||||
header,
|
||||
make_async_client,
|
||||
make_sync_client,
|
||||
)
|
||||
|
||||
_EXPECTED_TERMINAL_ITEMS = ["streamed", "tool", "asked", "sub"]
|
||||
|
||||
|
||||
async def _drive_one_async(threads: Any, label: str) -> dict[str, Any]:
|
||||
async with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
await thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
responder = auto_respond_async(thread)
|
||||
# Just drain values until terminal; we only care about the final state.
|
||||
async for _ in thread.values:
|
||||
pass
|
||||
await responder
|
||||
final = await thread.output
|
||||
print(f" [{label}] thread_id={thread.thread_id} items={final.get('items')!r}")
|
||||
return {"thread_id": thread.thread_id, "items": final.get("items")}
|
||||
|
||||
|
||||
async def run_async() -> None:
|
||||
header("async concurrent threads.stream (x2)")
|
||||
threads, raw = make_async_client()
|
||||
try:
|
||||
results = await asyncio.gather(
|
||||
_drive_one_async(threads, "A"),
|
||||
_drive_one_async(threads, "B"),
|
||||
)
|
||||
a, b = results
|
||||
assert a["items"] == _EXPECTED_TERMINAL_ITEMS, (
|
||||
f"stream A failed to reach terminal: {a!r}"
|
||||
)
|
||||
assert b["items"] == _EXPECTED_TERMINAL_ITEMS, (
|
||||
f"stream B failed to reach terminal: {b!r}"
|
||||
)
|
||||
assert a["thread_id"] != b["thread_id"], (
|
||||
f"concurrent streams collided on thread_id {a['thread_id']!r}"
|
||||
)
|
||||
finally:
|
||||
await raw.aclose()
|
||||
|
||||
|
||||
def _drive_one_sync(
|
||||
threads: Any, label: str, results: dict[str, dict[str, Any]]
|
||||
) -> None:
|
||||
with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
responder = auto_respond_sync(thread)
|
||||
for _ in thread.values:
|
||||
pass
|
||||
responder.join(timeout=10)
|
||||
final = thread.output
|
||||
print(f" [{label}] thread_id={thread.thread_id} items={final.get('items')!r}")
|
||||
results[label] = {"thread_id": thread.thread_id, "items": final.get("items")}
|
||||
|
||||
|
||||
def run_sync() -> None:
|
||||
header("sync concurrent threads.stream (x2)")
|
||||
threads, raw = make_sync_client()
|
||||
try:
|
||||
results: dict[str, dict[str, Any]] = {}
|
||||
workers = [
|
||||
threading.Thread(
|
||||
target=_drive_one_sync,
|
||||
args=(threads, label, results),
|
||||
daemon=True,
|
||||
name=f"sync-stream-{label}",
|
||||
)
|
||||
for label in ("A", "B")
|
||||
]
|
||||
for w in workers:
|
||||
w.start()
|
||||
for w in workers:
|
||||
w.join(timeout=60)
|
||||
assert not w.is_alive(), f"worker {w.name} did not finish within 60s"
|
||||
|
||||
a = results.get("A")
|
||||
b = results.get("B")
|
||||
assert a is not None and a["items"] == _EXPECTED_TERMINAL_ITEMS, (
|
||||
f"stream A failed to reach terminal: {a!r}"
|
||||
)
|
||||
assert b is not None and b["items"] == _EXPECTED_TERMINAL_ITEMS, (
|
||||
f"stream B failed to reach terminal: {b!r}"
|
||||
)
|
||||
assert a["thread_id"] != b["thread_id"], (
|
||||
f"concurrent streams collided on thread_id {a['thread_id']!r}"
|
||||
)
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
raw.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
check_api_reachable()
|
||||
asyncio.run(run_async())
|
||||
run_sync()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Exercise `thread.extensions[name]` against the integration API.
|
||||
|
||||
Every node in the example graph writes `("progress", {...})` via
|
||||
`get_stream_writer`. This script verifies the `extensions["progress"]`
|
||||
projection yields each progress event in order.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from _common import (
|
||||
ASSISTANT_ID,
|
||||
check_api_reachable,
|
||||
header,
|
||||
make_async_client,
|
||||
make_sync_client,
|
||||
)
|
||||
|
||||
|
||||
async def run_async() -> None:
|
||||
header("async extensions[progress]")
|
||||
threads, raw = make_async_client()
|
||||
try:
|
||||
async with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
await thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
events: list[dict] = []
|
||||
async for event in thread.extensions["progress"]:
|
||||
print(f" progress: {event!r}")
|
||||
events.append(event)
|
||||
print(f" total progress events: {len(events)}")
|
||||
assert events, "expected at least one progress event"
|
||||
# Verify ordering covers the node sequence.
|
||||
steps = [e.get("step") for e in events]
|
||||
print(f" step sequence: {steps}")
|
||||
finally:
|
||||
await raw.aclose()
|
||||
|
||||
|
||||
def run_sync() -> None:
|
||||
header("sync extensions[progress]")
|
||||
threads, raw = make_sync_client()
|
||||
try:
|
||||
with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
events: list[dict] = []
|
||||
for event in thread.extensions["progress"]:
|
||||
print(f" progress: {event!r}")
|
||||
events.append(event)
|
||||
print(f" total progress events: {len(events)}")
|
||||
assert events, "expected at least one progress event"
|
||||
steps = [e.get("step") for e in events]
|
||||
print(f" step sequence: {steps}")
|
||||
finally:
|
||||
raw.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
check_api_reachable()
|
||||
asyncio.run(run_async())
|
||||
run_sync()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Exercise helper methods on `thread` against the integration API.
|
||||
|
||||
Covers `thread.agent.get_tree(xray=...)` and the extensions cache.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from _common import (
|
||||
ASSISTANT_ID,
|
||||
check_api_reachable,
|
||||
header,
|
||||
make_async_client,
|
||||
make_sync_client,
|
||||
)
|
||||
|
||||
|
||||
async def run_async() -> None:
|
||||
header("async helpers (agent.get_tree, extensions cache)")
|
||||
threads, raw = make_async_client()
|
||||
try:
|
||||
async with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
tree = await thread.agent.get_tree()
|
||||
print(f" get_tree() returned: nodes={list(tree.get('nodes', []))[:5]} ...")
|
||||
assert tree, "expected non-empty tree"
|
||||
|
||||
tree_xray = await thread.agent.get_tree(xray=True)
|
||||
print(f" get_tree(xray=True) returned keys: {list(tree_xray)[:5]}")
|
||||
|
||||
# Extensions cache: same name returns same projection instance.
|
||||
a = thread.extensions["progress"]
|
||||
b = thread.extensions["progress"]
|
||||
assert a is b, "expected cached _ExtensionProjection on repeated access"
|
||||
print(" extensions cache: OK (same projection instance reused)")
|
||||
finally:
|
||||
await raw.aclose()
|
||||
|
||||
|
||||
def run_sync() -> None:
|
||||
header("sync helpers (agent.get_tree, extensions cache)")
|
||||
threads, raw = make_sync_client()
|
||||
try:
|
||||
with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
tree = thread.agent.get_tree()
|
||||
print(f" get_tree() returned: nodes={list(tree.get('nodes', []))[:5]} ...")
|
||||
assert tree, "expected non-empty tree"
|
||||
|
||||
tree_xray = thread.agent.get_tree(xray=True)
|
||||
print(f" get_tree(xray=True) returned keys: {list(tree_xray)[:5]}")
|
||||
|
||||
a = thread.extensions["progress"]
|
||||
b = thread.extensions["progress"]
|
||||
assert a is b, "expected cached _ExtensionProjection on repeated access"
|
||||
print(" extensions cache: OK (same projection instance reused)")
|
||||
finally:
|
||||
raw.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
check_api_reachable()
|
||||
asyncio.run(run_async())
|
||||
run_sync()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Exercise lifecycle state + `thread.run.respond(...)` against the integration API.
|
||||
|
||||
The example graph's `ask_human` node calls `interrupt("Are we good?")`. This
|
||||
script:
|
||||
|
||||
1. Starts a run.
|
||||
2. Waits until `thread.interrupted` becomes True (an `input.requested`
|
||||
lifecycle event lands).
|
||||
3. Inspects `thread.interrupts` to see the outstanding payload.
|
||||
4. Calls `thread.run.respond("yes")` to resume.
|
||||
5. Awaits `thread.output` for the final state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from _common import (
|
||||
ASSISTANT_ID,
|
||||
check_api_reachable,
|
||||
header,
|
||||
make_async_client,
|
||||
make_sync_client,
|
||||
)
|
||||
|
||||
|
||||
async def run_async() -> None:
|
||||
header("async lifecycle + respond")
|
||||
threads, raw = make_async_client()
|
||||
try:
|
||||
async with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
await thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
# Drain values until interrupt fires. (`thread.values` ends when
|
||||
# the run terminates OR when the run is paused on an interrupt;
|
||||
# the latter sets `thread.interrupted` mid-iteration.)
|
||||
saw_interrupt = False
|
||||
async for _snap in thread.values:
|
||||
if thread.interrupted:
|
||||
saw_interrupt = True
|
||||
break
|
||||
|
||||
print(f" thread.interrupted = {thread.interrupted}")
|
||||
print(f" thread.interrupts = {thread.interrupts!r}")
|
||||
assert thread.interrupted, "expected an interrupt before the run completed"
|
||||
assert thread.interrupts, "expected interrupts list to be populated"
|
||||
|
||||
await thread.run.respond("yes")
|
||||
|
||||
final = await thread.output
|
||||
print(f" final output items: {final.get('items')!r}")
|
||||
assert "asked" in final.get("items", []), (
|
||||
"expected ask_human to have run after respond"
|
||||
)
|
||||
print(f" saw_interrupt before respond = {saw_interrupt}")
|
||||
finally:
|
||||
await raw.aclose()
|
||||
|
||||
|
||||
def run_sync() -> None:
|
||||
header("sync lifecycle + respond")
|
||||
threads, raw = make_sync_client()
|
||||
try:
|
||||
with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
saw_interrupt = False
|
||||
for _snap in thread.values:
|
||||
if thread.interrupted:
|
||||
saw_interrupt = True
|
||||
break
|
||||
|
||||
print(f" thread.interrupted = {thread.interrupted}")
|
||||
print(f" thread.interrupts = {thread.interrupts!r}")
|
||||
assert thread.interrupted, "expected an interrupt before the run completed"
|
||||
assert thread.interrupts, "expected interrupts list to be populated"
|
||||
|
||||
thread.run.respond("yes")
|
||||
|
||||
final = thread.output
|
||||
print(f" final output items: {final.get('items')!r}")
|
||||
assert "asked" in final.get("items", []), (
|
||||
"expected ask_human to have run after respond"
|
||||
)
|
||||
print(f" saw_interrupt before respond = {saw_interrupt}")
|
||||
finally:
|
||||
raw.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
check_api_reachable()
|
||||
asyncio.run(run_async())
|
||||
run_sync()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Exercise `thread.messages` against the integration API.
|
||||
|
||||
The ``stream_message`` node in ``streaming_graph`` invokes a fake
|
||||
``FakeMessagesListChatModel`` whose ``_stream`` callbacks drive
|
||||
langgraph's ``StreamMessagesHandlerV2`` -> ``MessagesTransformer``,
|
||||
so the v3 ``messages`` channel emits the normalized delta lifecycle
|
||||
(``message-start`` -> ``content-block-start`` ->
|
||||
``content-block-delta`` -> ``content-block-finish`` ->
|
||||
``message-finish``) at root namespace.
|
||||
|
||||
Pattern note: drain the outer iterator first (list comprehension)
|
||||
before consuming each handle's chunks -- the outer iterator yields
|
||||
on ``message-start`` but the inner ``chunk`` stream only completes
|
||||
when ``message-finish`` is processed by the outer iter. Iterating
|
||||
chunks while the outer is suspended at ``yield`` deadlocks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from _common import (
|
||||
ASSISTANT_ID,
|
||||
auto_respond_async,
|
||||
auto_respond_sync,
|
||||
check_api_reachable,
|
||||
header,
|
||||
make_async_client,
|
||||
make_sync_client,
|
||||
)
|
||||
|
||||
|
||||
async def run_async() -> None:
|
||||
header("async messages")
|
||||
threads, raw = make_async_client()
|
||||
try:
|
||||
async with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
await thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
# Graph interrupts at `ask_human`; background responder
|
||||
# unblocks so terminal lifecycle fires and the messages
|
||||
# iterator exits cleanly.
|
||||
responder = auto_respond_async(thread)
|
||||
|
||||
streams = [s async for s in thread.messages]
|
||||
await responder
|
||||
print(f" total streams: {len(streams)}")
|
||||
for stream in streams:
|
||||
text = "".join([t async for t in stream.text])
|
||||
msg_id = getattr(stream, "message_id", None) or "?"
|
||||
print(f" message {msg_id}: {text!r}")
|
||||
assert streams, "expected at least one streamed message"
|
||||
finally:
|
||||
await raw.aclose()
|
||||
|
||||
|
||||
def run_sync() -> None:
|
||||
header("sync messages")
|
||||
threads, raw = make_sync_client()
|
||||
try:
|
||||
with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
responder = auto_respond_sync(thread)
|
||||
|
||||
streams = list(thread.messages)
|
||||
responder.join(timeout=5)
|
||||
print(f" total streams: {len(streams)}")
|
||||
for stream in streams:
|
||||
text = "".join(list(stream.text))
|
||||
msg_id = getattr(stream, "message_id", None) or "?"
|
||||
print(f" message {msg_id}: {text!r}")
|
||||
assert streams, "expected at least one streamed message"
|
||||
finally:
|
||||
raw.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
check_api_reachable()
|
||||
asyncio.run(run_async())
|
||||
run_sync()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Exercise stream-handle close + recovery against the integration API.
|
||||
|
||||
The SDK's "reconnect on transport drop" code path (controller
|
||||
`_reconnect_shared_stream`) only fires when `shared.done` resolves to a
|
||||
non-cancelled error — i.e. genuine network/server failures, not graceful
|
||||
client-initiated closes. Reliably faking such an error against a real
|
||||
server is brittle, so this script asserts the next-strongest invariant:
|
||||
**a client-initiated stream close mid-iteration must not corrupt durable
|
||||
state**.
|
||||
|
||||
Concretely:
|
||||
|
||||
1. Start the run; let the auto-responder unblock the interrupt.
|
||||
2. Drop the shared SSE handle after the first snapshot.
|
||||
3. The values projection iterator may end early (the close drains the
|
||||
sub queue with `None`), but `thread.output` must still resolve to the
|
||||
canonical terminal state via the REST fallback path.
|
||||
4. No exception escapes the iteration.
|
||||
|
||||
We also instrument `_dedup_iter` to count any duplicate event_ids and
|
||||
print the counter for visibility. A future regression that
|
||||
double-delivers events through the controller would surface here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import functools
|
||||
from typing import Any
|
||||
|
||||
from _common import (
|
||||
ASSISTANT_ID,
|
||||
auto_respond_async,
|
||||
auto_respond_sync,
|
||||
check_api_reachable,
|
||||
header,
|
||||
make_async_client,
|
||||
make_sync_client,
|
||||
)
|
||||
|
||||
_EXPECTED_TERMINAL_ITEMS = ["streamed", "tool", "asked", "sub"]
|
||||
|
||||
|
||||
def _instrument_dedup_async(controller: Any) -> dict[str, int]:
|
||||
"""Wrap `_dedup_iter` so duplicate event_ids are counted."""
|
||||
counter = {"drops": 0, "yields": 0}
|
||||
original = controller._dedup_iter.__func__ # type: ignore[attr-defined]
|
||||
|
||||
@functools.wraps(original)
|
||||
async def _counted(self, source): # type: ignore[no-untyped-def]
|
||||
async for event in source:
|
||||
event_id = event.get("event_id")
|
||||
if event_id is not None:
|
||||
if event_id in self._seen_event_ids:
|
||||
counter["drops"] += 1
|
||||
continue
|
||||
self._seen_event_ids.add(event_id)
|
||||
counter["yields"] += 1
|
||||
yield event
|
||||
|
||||
controller._dedup_iter = _counted.__get__(controller, type(controller))
|
||||
return counter
|
||||
|
||||
|
||||
def _instrument_dedup_sync(controller: Any) -> dict[str, int]:
|
||||
counter = {"drops": 0, "yields": 0}
|
||||
original = controller._dedup_iter.__func__ # type: ignore[attr-defined]
|
||||
|
||||
@functools.wraps(original)
|
||||
def _counted(self, source): # type: ignore[no-untyped-def]
|
||||
for event in source:
|
||||
event_id = event.get("event_id")
|
||||
if event_id is not None:
|
||||
if event_id in self._seen_event_ids:
|
||||
counter["drops"] += 1
|
||||
continue
|
||||
self._seen_event_ids.add(event_id)
|
||||
counter["yields"] += 1
|
||||
yield event
|
||||
|
||||
controller._dedup_iter = _counted.__get__(controller, type(controller))
|
||||
return counter
|
||||
|
||||
|
||||
async def run_async() -> None:
|
||||
header("async stream-close mid-iteration (terminal state via REST)")
|
||||
threads, raw = make_async_client()
|
||||
try:
|
||||
async with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
counter = _instrument_dedup_async(thread)
|
||||
await thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
responder = auto_respond_async(thread)
|
||||
|
||||
snapshots: list[dict] = []
|
||||
dropped = False
|
||||
iteration_error: BaseException | None = None
|
||||
try:
|
||||
async for snap in thread.values:
|
||||
snapshots.append(snap)
|
||||
if not dropped and thread._shared_stream is not None:
|
||||
print(f" dropping shared stream (cursor={thread._cursor})...")
|
||||
await thread._shared_stream.close()
|
||||
dropped = True
|
||||
except BaseException as err:
|
||||
iteration_error = err
|
||||
|
||||
await responder
|
||||
|
||||
final = await thread.output
|
||||
print(f" snapshots seen before drop: {len(snapshots)}")
|
||||
print(f" final items={final.get('items')!r}")
|
||||
print(f" dedup drops={counter['drops']} yields={counter['yields']}")
|
||||
print(f" iteration_error={iteration_error!r}")
|
||||
|
||||
assert dropped, "expected to drop the shared stream during iteration"
|
||||
assert snapshots, "expected at least one snapshot before the drop"
|
||||
assert iteration_error is None, (
|
||||
f"values iterator raised on stream close: {iteration_error!r}"
|
||||
)
|
||||
assert final.get("items") == _EXPECTED_TERMINAL_ITEMS, (
|
||||
f"terminal state not reached via REST after drop: "
|
||||
f"items={final.get('items')!r}"
|
||||
)
|
||||
assert counter["drops"] == 0, (
|
||||
f"unexpected dedup activity (drops={counter['drops']}); "
|
||||
"no rotation occurred so no overlap was expected"
|
||||
)
|
||||
finally:
|
||||
await raw.aclose()
|
||||
|
||||
|
||||
def run_sync() -> None:
|
||||
header("sync stream-close mid-iteration (terminal state via REST)")
|
||||
threads, raw = make_sync_client()
|
||||
try:
|
||||
with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
controller = thread._controller
|
||||
counter = _instrument_dedup_sync(controller)
|
||||
thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
responder = auto_respond_sync(thread)
|
||||
|
||||
snapshots: list[dict] = []
|
||||
dropped = False
|
||||
iteration_error: BaseException | None = None
|
||||
try:
|
||||
for snap in thread.values:
|
||||
snapshots.append(snap)
|
||||
if (
|
||||
not dropped
|
||||
and controller is not None
|
||||
and controller._shared_stream is not None
|
||||
):
|
||||
print(
|
||||
f" dropping shared stream (cursor={controller._cursor})..."
|
||||
)
|
||||
controller._shared_stream.close()
|
||||
dropped = True
|
||||
except BaseException as err:
|
||||
iteration_error = err
|
||||
|
||||
responder.join(timeout=10)
|
||||
|
||||
final = thread.output
|
||||
print(f" snapshots seen before drop: {len(snapshots)}")
|
||||
print(f" final items={final.get('items')!r}")
|
||||
print(f" dedup drops={counter['drops']} yields={counter['yields']}")
|
||||
print(f" iteration_error={iteration_error!r}")
|
||||
|
||||
assert dropped, "expected to drop the shared stream during iteration"
|
||||
assert snapshots, "expected at least one snapshot before the drop"
|
||||
assert iteration_error is None, (
|
||||
f"values iterator raised on stream close: {iteration_error!r}"
|
||||
)
|
||||
assert final.get("items") == _EXPECTED_TERMINAL_ITEMS, (
|
||||
f"terminal state not reached via REST after drop: "
|
||||
f"items={final.get('items')!r}"
|
||||
)
|
||||
assert counter["drops"] == 0, (
|
||||
f"unexpected dedup activity (drops={counter['drops']}); "
|
||||
"no rotation occurred so no overlap was expected"
|
||||
)
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
raw.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
check_api_reachable()
|
||||
asyncio.run(run_async())
|
||||
run_sync()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Exercise `thread.subgraphs` against both example graphs.
|
||||
|
||||
Two passes:
|
||||
|
||||
1. `agent` (plain `StateGraph`): the parent calls a nested subgraph via
|
||||
`subgraph.invoke(...)` from a node. This may or may not surface as a
|
||||
v3 scoped child handle depending on how the server emits namespaces
|
||||
for nested invokes — included so we can compare behavior.
|
||||
|
||||
2. `deep_agent`: built with `create_deep_agent` + one `SubAgent`. The
|
||||
supervisor's model is scripted to issue a `task(researcher, ...)`
|
||||
call, which IS the path that produces a proper scoped child handle
|
||||
on `thread.subgraphs`. This is the canonical exercise for the v3
|
||||
scoped-subgraph surface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from _common import check_api_reachable, header, make_async_client, make_sync_client
|
||||
|
||||
|
||||
async def _drain_subgraphs(thread) -> list:
|
||||
"""Drain ``thread.subgraphs`` to a list of {path, messages} dicts.
|
||||
|
||||
The outer subgraphs iterator must complete before we deep-iterate
|
||||
each handle's ``messages`` projection -- the same nested-iteration
|
||||
deadlock pattern as ``thread.tool_calls`` (see
|
||||
``test_tools.py``). So we first collect handles, then drain
|
||||
each handle's messages serially.
|
||||
"""
|
||||
handles: list = [h async for h in thread.subgraphs]
|
||||
children: list = []
|
||||
for child in handles:
|
||||
print(f" child handle path={child.path}")
|
||||
# Just count handle paths; deep message iteration on scoped
|
||||
# handles has its own draining pattern and isn't the goal of
|
||||
# this test (which exercises subgraph discovery via
|
||||
# child-namespace ``lifecycle: started``).
|
||||
children.append({"path": child.path})
|
||||
return children
|
||||
|
||||
|
||||
def _drain_subgraphs_sync(thread) -> list:
|
||||
handles = list(thread.subgraphs)
|
||||
children: list = []
|
||||
for child in handles:
|
||||
print(f" child handle path={child.path}")
|
||||
children.append({"path": child.path})
|
||||
return children
|
||||
|
||||
|
||||
async def run_async() -> None:
|
||||
threads, raw = make_async_client()
|
||||
try:
|
||||
header("async subgraphs / agent (plain StateGraph)")
|
||||
async with threads.stream(assistant_id="agent") as thread:
|
||||
await thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
children = await _drain_subgraphs(thread)
|
||||
print(f" agent: total subgraph handles: {len(children)}")
|
||||
|
||||
header("async subgraphs / deep_agent (create_deep_agent + SubAgent)")
|
||||
async with threads.stream(assistant_id="deep_agent") as thread:
|
||||
await thread.run.start(
|
||||
input={
|
||||
"messages": [{"role": "user", "content": "research the v3 spec"}]
|
||||
},
|
||||
)
|
||||
children = await _drain_subgraphs(thread)
|
||||
print(f" deep_agent: total subgraph handles: {len(children)}")
|
||||
assert children, "deep_agent should produce at least one direct-child handle"
|
||||
finally:
|
||||
await raw.aclose()
|
||||
|
||||
|
||||
def run_sync() -> None:
|
||||
threads, raw = make_sync_client()
|
||||
try:
|
||||
header("sync subgraphs / agent")
|
||||
with threads.stream(assistant_id="agent") as thread:
|
||||
thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
children = _drain_subgraphs_sync(thread)
|
||||
print(f" agent: total subgraph handles: {len(children)}")
|
||||
|
||||
header("sync subgraphs / deep_agent")
|
||||
with threads.stream(assistant_id="deep_agent") as thread:
|
||||
thread.run.start(
|
||||
input={
|
||||
"messages": [{"role": "user", "content": "research the v3 spec"}]
|
||||
},
|
||||
)
|
||||
children = _drain_subgraphs_sync(thread)
|
||||
print(f" deep_agent: total subgraph handles: {len(children)}")
|
||||
assert children, "deep_agent should produce at least one direct-child handle"
|
||||
finally:
|
||||
raw.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
check_api_reachable()
|
||||
asyncio.run(run_async())
|
||||
run_sync()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Exercise `thread.tool_calls` against the `tools_agent` graph.
|
||||
|
||||
`tools_agent` (`graph/tools_agent.py`) wraps a
|
||||
`FakeMessagesListChatModel` in `create_agent` with a real `search`
|
||||
tool. The first scripted model turn returns an `AIMessage` with a
|
||||
`tool_calls=[search(query="v3")]`; langchain's tool node then executes
|
||||
`search` and surfaces a `ToolMessage`; the second turn returns a final
|
||||
`AIMessage("done.")` that terminates the agent.
|
||||
|
||||
Compared to `test_tool_calls.py` (which targets the synthetic
|
||||
`streaming_graph` and never produces real tool-call telemetry), this
|
||||
test verifies the v3 ``tools`` channel actually fires when the
|
||||
canonical langchain-agent surface is in play.
|
||||
|
||||
Pattern note: `thread.tool_calls` yields handles incrementally, but
|
||||
each handle's `deltas` and `output` are only completed when the
|
||||
*outer* iterator processes the matching `tool-finished` event. Drain
|
||||
the outer iterator to completion FIRST (via a list comprehension),
|
||||
then inspect handles -- this is the same pattern used in
|
||||
`tests/streaming/test_tool_calls_projection.py`. Iterating
|
||||
`handle.deltas` while the outer iterator is still suspended at its
|
||||
`yield` deadlocks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from _common import check_api_reachable, header, make_async_client, make_sync_client
|
||||
|
||||
TOOLS_ASSISTANT_ID = "tools_agent"
|
||||
|
||||
|
||||
async def run_async() -> None:
|
||||
header("async tools_agent tool_calls")
|
||||
threads, raw = make_async_client()
|
||||
try:
|
||||
async with threads.stream(assistant_id=TOOLS_ASSISTANT_ID) as thread:
|
||||
await thread.run.start(
|
||||
input={"messages": [{"role": "human", "content": "search for v3"}]}
|
||||
)
|
||||
|
||||
# Drain the outer iterator first; lifecycle-terminal triggers
|
||||
# the None sentinel via the shared SSE fanout once the run
|
||||
# completes naturally.
|
||||
handles = [h async for h in thread.tool_calls]
|
||||
print(f" total handles: {len(handles)}")
|
||||
for handle in handles:
|
||||
deltas = [d async for d in handle.deltas]
|
||||
output = await handle.output
|
||||
joined = "".join(deltas)
|
||||
print(
|
||||
f" tool {handle.name}({handle.tool_call_id}): "
|
||||
f"args_stream={joined!r} output={output!r}"
|
||||
)
|
||||
assert any(h.name == "search" for h in handles), (
|
||||
"expected `search` tool call"
|
||||
)
|
||||
finally:
|
||||
await raw.aclose()
|
||||
|
||||
|
||||
def run_sync() -> None:
|
||||
header("sync tools_agent tool_calls")
|
||||
threads, raw = make_sync_client()
|
||||
try:
|
||||
with threads.stream(assistant_id=TOOLS_ASSISTANT_ID) as thread:
|
||||
thread.run.start(
|
||||
input={"messages": [{"role": "human", "content": "search for v3"}]}
|
||||
)
|
||||
|
||||
handles = list(thread.tool_calls)
|
||||
print(f" total handles: {len(handles)}")
|
||||
for handle in handles:
|
||||
deltas = list(handle.deltas)
|
||||
output = handle.output
|
||||
joined = "".join(deltas)
|
||||
print(
|
||||
f" tool {handle.name}({handle.tool_call_id}): "
|
||||
f"args_stream={joined!r} output={output!r}"
|
||||
)
|
||||
assert any(h.name == "search" for h in handles), (
|
||||
"expected `search` tool call"
|
||||
)
|
||||
finally:
|
||||
raw.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
check_api_reachable()
|
||||
asyncio.run(run_async())
|
||||
run_sync()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Exercise `threads.update_state(...)` mid-run against the integration API.
|
||||
|
||||
Flow:
|
||||
|
||||
1. Stream the canonical graph; `run.start` with `value="init"`.
|
||||
2. Drain values until the interrupt fires at `ask_human`.
|
||||
3. Call `threads.update_state(thread_id, {"value": "patched"})` to mutate
|
||||
the persisted state while the run is paused.
|
||||
4. Read state back via `threads.get_state(thread_id)` and assert
|
||||
`state["values"]["value"] == "patched"`.
|
||||
|
||||
Why no respond afterwards: in langgraph-api, `update_state` against an
|
||||
interrupted thread commits a new checkpoint that consumes the
|
||||
outstanding interrupt. A subsequent `run.respond(...)` then fails with
|
||||
`no_such_interrupt`. The meaningful integration invariant here is just
|
||||
that the REST mutation lands on the same persisted thread the streaming
|
||||
proxy was driving (no thread_id drift between client and server).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import time
|
||||
|
||||
from _common import (
|
||||
ASSISTANT_ID,
|
||||
check_api_reachable,
|
||||
header,
|
||||
make_async_client,
|
||||
make_sync_client,
|
||||
)
|
||||
|
||||
from langgraph_sdk.errors import ConflictError
|
||||
|
||||
_PATCHED_VALUE = "patched"
|
||||
_UPDATE_STATE_RETRY_BUDGET = 5.0
|
||||
|
||||
|
||||
async def _update_state_with_retry_async(threads, thread_id: str, values: dict) -> None:
|
||||
"""Retry update_state on ConflictError until the server's run row settles.
|
||||
|
||||
`thread.interrupted` flips when the client sees the `input.requested`
|
||||
lifecycle event, which can land before the server commits the run row
|
||||
to a non-busy state. Retry with backoff for a few seconds.
|
||||
"""
|
||||
delay = 0.05
|
||||
deadline = asyncio.get_running_loop().time() + _UPDATE_STATE_RETRY_BUDGET
|
||||
last_err: Exception | None = None
|
||||
while asyncio.get_running_loop().time() < deadline:
|
||||
try:
|
||||
await threads.update_state(thread_id, values)
|
||||
return
|
||||
except ConflictError as err:
|
||||
last_err = err
|
||||
await asyncio.sleep(delay)
|
||||
delay = min(delay * 2, 0.5)
|
||||
raise AssertionError(
|
||||
f"update_state never accepted within {_UPDATE_STATE_RETRY_BUDGET}s: {last_err!r}"
|
||||
)
|
||||
|
||||
|
||||
def _update_state_with_retry_sync(threads, thread_id: str, values: dict) -> None:
|
||||
delay = 0.05
|
||||
deadline = time.monotonic() + _UPDATE_STATE_RETRY_BUDGET
|
||||
last_err: Exception | None = None
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
threads.update_state(thread_id, values)
|
||||
return
|
||||
except ConflictError as err:
|
||||
last_err = err
|
||||
time.sleep(delay)
|
||||
delay = min(delay * 2, 0.5)
|
||||
raise AssertionError(
|
||||
f"update_state never accepted within {_UPDATE_STATE_RETRY_BUDGET}s: {last_err!r}"
|
||||
)
|
||||
|
||||
|
||||
async def run_async() -> None:
|
||||
header("async update_state during interrupt")
|
||||
threads, raw = make_async_client()
|
||||
try:
|
||||
async with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
await thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
async for _ in thread.values:
|
||||
if thread.interrupted:
|
||||
break
|
||||
assert thread.interrupted, "expected interrupt before update_state"
|
||||
|
||||
pre_state = await threads.get_state(thread.thread_id)
|
||||
pre_value = (pre_state.get("values") or {}).get("value")
|
||||
print(f" pre-update value={pre_value!r}")
|
||||
# `stream_message` overwrites value="init" with value="x" before the
|
||||
# interrupt; verify we're starting from the expected pre-update state.
|
||||
assert pre_value == "x", f"unexpected pre-update value: {pre_value!r}"
|
||||
|
||||
await _update_state_with_retry_async(
|
||||
threads, thread.thread_id, {"value": _PATCHED_VALUE}
|
||||
)
|
||||
|
||||
post_state = await threads.get_state(thread.thread_id)
|
||||
post_values = post_state.get("values") or {}
|
||||
post_value = post_values.get("value")
|
||||
print(f" thread_id={thread.thread_id}")
|
||||
print(f" post-update value={post_value!r}")
|
||||
assert post_value == _PATCHED_VALUE, (
|
||||
f"update_state did not persist: value={post_value!r}"
|
||||
)
|
||||
finally:
|
||||
await raw.aclose()
|
||||
|
||||
|
||||
def run_sync() -> None:
|
||||
header("sync update_state during interrupt")
|
||||
threads, raw = make_sync_client()
|
||||
try:
|
||||
with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
for _ in thread.values:
|
||||
if thread.interrupted:
|
||||
break
|
||||
assert thread.interrupted, "expected interrupt before update_state"
|
||||
|
||||
pre_state = threads.get_state(thread.thread_id)
|
||||
pre_value = (pre_state.get("values") or {}).get("value")
|
||||
print(f" pre-update value={pre_value!r}")
|
||||
# `stream_message` overwrites value="init" with value="x" before the
|
||||
# interrupt; verify we're starting from the expected pre-update state.
|
||||
assert pre_value == "x", f"unexpected pre-update value: {pre_value!r}"
|
||||
|
||||
_update_state_with_retry_sync(
|
||||
threads, thread.thread_id, {"value": _PATCHED_VALUE}
|
||||
)
|
||||
|
||||
post_state = threads.get_state(thread.thread_id)
|
||||
post_values = post_state.get("values") or {}
|
||||
post_value = post_values.get("value")
|
||||
print(f" thread_id={thread.thread_id}")
|
||||
print(f" post-update value={post_value!r}")
|
||||
assert post_value == _PATCHED_VALUE, (
|
||||
f"update_state did not persist: value={post_value!r}"
|
||||
)
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
raw.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
check_api_reachable()
|
||||
asyncio.run(run_async())
|
||||
run_sync()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Exercise `thread.values` against the integration API.
|
||||
|
||||
The integration graph's `ask_human` node interrupts mid-run. The
|
||||
projection iterators (`thread.values`, `.messages`, `.tool_calls`,
|
||||
`.subgraphs`) do not terminate on interrupt — they're paused, waiting
|
||||
for more events. To drain the full run end-to-end we use a background
|
||||
auto-responder that watches `thread.interrupted` and calls
|
||||
`thread.run.respond(...)` so the run continues to the terminal.
|
||||
|
||||
Run after `docker compose up -d` from `libs/sdk-py/integration/`:
|
||||
|
||||
uv run python integration/scripts/test_values.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from _common import (
|
||||
ASSISTANT_ID,
|
||||
auto_respond_async,
|
||||
auto_respond_sync,
|
||||
check_api_reachable,
|
||||
header,
|
||||
make_async_client,
|
||||
make_sync_client,
|
||||
)
|
||||
|
||||
|
||||
async def run_async() -> None:
|
||||
header("async values")
|
||||
threads, raw = make_async_client()
|
||||
try:
|
||||
async with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
await thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
# Background task: respond to the interrupt so the iterator
|
||||
# eventually sees terminal-completion events.
|
||||
responder = auto_respond_async(thread)
|
||||
|
||||
snapshots: list[dict] = []
|
||||
async for snap in thread.values:
|
||||
snapshots.append(snap)
|
||||
print(
|
||||
f" values snapshot: items={snap.get('items')!r} value={snap.get('value')!r}"
|
||||
)
|
||||
|
||||
await responder
|
||||
|
||||
final = await thread.output
|
||||
print(f" final output items={final.get('items')!r}")
|
||||
assert "sub" in final.get("items", []), "expected subgraph to have run"
|
||||
finally:
|
||||
await raw.aclose()
|
||||
|
||||
|
||||
def run_sync() -> None:
|
||||
header("sync values")
|
||||
threads, raw = make_sync_client()
|
||||
try:
|
||||
with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
responder = auto_respond_sync(thread)
|
||||
|
||||
snapshots: list[dict] = []
|
||||
for snap in thread.values:
|
||||
snapshots.append(snap)
|
||||
print(
|
||||
f" values snapshot: items={snap.get('items')!r} value={snap.get('value')!r}"
|
||||
)
|
||||
|
||||
responder.join(timeout=5)
|
||||
|
||||
final = thread.output
|
||||
print(f" final output items={final.get('items')!r}")
|
||||
assert "sub" in final.get("items", []), "expected subgraph to have run"
|
||||
finally:
|
||||
raw.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
check_api_reachable()
|
||||
asyncio.run(run_async())
|
||||
run_sync()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Exercise the WebSocket transport against the integration API.
|
||||
|
||||
Equivalent to `test_values.py` but with `transport="websocket"`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from _common import (
|
||||
ASSISTANT_ID,
|
||||
auto_respond_async,
|
||||
auto_respond_sync,
|
||||
check_api_reachable,
|
||||
header,
|
||||
make_async_client,
|
||||
make_sync_client,
|
||||
)
|
||||
|
||||
|
||||
async def run_async() -> None:
|
||||
header("async websocket transport")
|
||||
threads, raw = make_async_client()
|
||||
try:
|
||||
async with threads.stream(
|
||||
assistant_id=ASSISTANT_ID,
|
||||
transport="websocket",
|
||||
) as thread:
|
||||
from langgraph_sdk.stream.transport import ProtocolWebSocketTransport
|
||||
|
||||
assert isinstance(thread._transport, ProtocolWebSocketTransport), (
|
||||
f"expected ws transport, got {type(thread._transport).__name__}"
|
||||
)
|
||||
|
||||
await thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
# The graph interrupts at `ask_human`; without a background
|
||||
# responder the values iterator would pause indefinitely.
|
||||
responder = auto_respond_async(thread)
|
||||
|
||||
snapshots: list[dict] = []
|
||||
async for snap in thread.values:
|
||||
snapshots.append(snap)
|
||||
print(f" ws values snapshot items={snap.get('items')!r}")
|
||||
|
||||
await responder
|
||||
|
||||
final = await thread.output
|
||||
print(f" final via ws: items={final.get('items')!r}")
|
||||
assert "sub" in final.get("items", []), (
|
||||
"expected subgraph to have run via ws transport"
|
||||
)
|
||||
finally:
|
||||
await raw.aclose()
|
||||
|
||||
|
||||
def run_sync() -> None:
|
||||
header("sync websocket transport")
|
||||
threads, raw = make_sync_client()
|
||||
try:
|
||||
with threads.stream(
|
||||
assistant_id=ASSISTANT_ID,
|
||||
transport="websocket",
|
||||
) as thread:
|
||||
thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
responder = auto_respond_sync(thread)
|
||||
|
||||
snapshots: list[dict] = []
|
||||
for snap in thread.values:
|
||||
snapshots.append(snap)
|
||||
print(f" ws values snapshot items={snap.get('items')!r}")
|
||||
|
||||
responder.join(timeout=5)
|
||||
|
||||
final = thread.output
|
||||
print(f" final via ws: items={final.get('items')!r}")
|
||||
assert "sub" in final.get("items", []), (
|
||||
"expected subgraph to have run via ws transport"
|
||||
)
|
||||
finally:
|
||||
raw.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
check_api_reachable()
|
||||
asyncio.run(run_async())
|
||||
run_sync()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -3,9 +3,10 @@
|
||||
`AsyncThreadStream` is an async context manager that owns a
|
||||
`ProtocolSseTransport` for one thread, dispatches commands (`run.start`,
|
||||
`run.respond`), exposes typed subscriptions over a single shared SSE
|
||||
(`subscribe`, `events`), and surfaces lifecycle state (`interrupted`,
|
||||
`interrupts`) via an always-on lifecycle watcher SSE. Typed projections
|
||||
(`thread.values`, `thread.messages`, etc.) mirror the v3 protocol surface.
|
||||
(`subscribe`, `events`), surfaces lifecycle state (`interrupted`,
|
||||
`interrupts`) via an always-on lifecycle watcher SSE, and provides typed
|
||||
projections (`thread.values`, `thread.messages`, `thread.tool_calls`,
|
||||
`thread.extensions`).
|
||||
|
||||
Direct port of `libs/sdk/src/client/stream/index.ts`.
|
||||
"""
|
||||
@@ -23,6 +24,8 @@ from langchain_core.language_models.chat_model_stream import AsyncChatModelStrea
|
||||
from langchain_protocol import Event, SubscribeParams
|
||||
|
||||
from langgraph_sdk._async.http import HttpClient
|
||||
from langgraph_sdk.schema import QueryParamTypes
|
||||
from langgraph_sdk.stream.controller import _SeenEventIds
|
||||
from langgraph_sdk.stream.transport import (
|
||||
AsyncProtocolTransport,
|
||||
EventStreamHandle,
|
||||
@@ -58,7 +61,8 @@ class _Subscription:
|
||||
# causes a type error with ty; bare asyncio.Queue is accepted.
|
||||
|
||||
|
||||
# All public protocol channels used by the raw `events` surface.
|
||||
# All public protocol channels used by the raw `events`/`subscribe` surface.
|
||||
# Typed projections open narrower channel filters on the shared SSE.
|
||||
_ALL_CHANNELS: list[str] = [
|
||||
"values",
|
||||
"updates",
|
||||
@@ -90,6 +94,59 @@ def _event_namespace(params_field: Any) -> list[str]:
|
||||
return list(namespace) if isinstance(namespace, list) else []
|
||||
|
||||
|
||||
_ROOT_TERMINAL_LIFECYCLE_EVENTS = frozenset({"completed", "failed"})
|
||||
|
||||
|
||||
def _is_root_terminal_lifecycle(event: Any) -> bool:
|
||||
"""Return True for a root-namespace lifecycle event marking run end.
|
||||
|
||||
Matches the wire shape ``{method: "lifecycle", params: {namespace: [],
|
||||
data: {event: "completed" | "failed"}}}``. Subgraph lifecycle events
|
||||
(non-empty namespace) do not terminate the parent run.
|
||||
"""
|
||||
if not isinstance(event, dict):
|
||||
return False
|
||||
if event.get("method") != "lifecycle":
|
||||
return False
|
||||
params = event.get("params") or {}
|
||||
if not isinstance(params, dict):
|
||||
return False
|
||||
if params.get("namespace") or []:
|
||||
return False
|
||||
data = params.get("data") or {}
|
||||
if not isinstance(data, dict):
|
||||
return False
|
||||
return data.get("event") in _ROOT_TERMINAL_LIFECYCLE_EVENTS
|
||||
|
||||
|
||||
class _AgentModule:
|
||||
"""Assistant graph helpers scoped to one thread stream."""
|
||||
|
||||
def __init__(self, owner: AsyncThreadStream) -> None:
|
||||
self._owner = owner
|
||||
|
||||
async def get_tree(
|
||||
self,
|
||||
*,
|
||||
xray: int | bool = False,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
if self._owner._closed:
|
||||
raise RuntimeError("AsyncThreadStream is closed.")
|
||||
query_params: dict[str, Any] = {}
|
||||
if xray:
|
||||
query_params["xray"] = xray
|
||||
if params:
|
||||
query_params.update(dict(params))
|
||||
request_headers = {**self._owner._headers, **dict(headers or {})}
|
||||
return await self._owner._http.get(
|
||||
f"/assistants/{self._owner.assistant_id}/graph",
|
||||
params=query_params,
|
||||
headers=request_headers or None,
|
||||
)
|
||||
|
||||
|
||||
class RunModule:
|
||||
"""Command dispatcher for `run.start`.
|
||||
|
||||
@@ -377,9 +434,21 @@ class _MessagesProjection:
|
||||
else:
|
||||
key = _message_route_key(data)
|
||||
stream = active.get(key)
|
||||
if stream is None and key == "__single__" and len(active) == 1:
|
||||
# Content-block events (content-block-start /
|
||||
# content-block-delta / content-block-finish /
|
||||
# message-finish) don't carry the message ``id``
|
||||
# on the wire, so ``_message_route_key`` returns
|
||||
# ``__single__`` while the active stream was
|
||||
# registered under ``message:<id>``. When exactly
|
||||
# one stream is active, that mismatch is
|
||||
# unambiguous -- the events belong to it.
|
||||
# Events that DO carry an explicit id which
|
||||
# doesn't match any active stream are still
|
||||
# dropped (orphan-delta safety, see
|
||||
# ``test_messages_orphan_delta_without_matching_key_is_dropped``).
|
||||
stream = next(iter(active.values()))
|
||||
if stream is None:
|
||||
# No active stream matches this event's key. Drop rather
|
||||
# than silently misroute to the only remaining stream.
|
||||
continue
|
||||
stream.dispatch(data)
|
||||
if event_type in ("message-finish", "error"):
|
||||
@@ -492,12 +561,16 @@ def _is_direct_child(namespace: list[str], scope: tuple[str, ...]) -> bool:
|
||||
|
||||
|
||||
def _subgraph_subscription_params(scope: tuple[str, ...]) -> SubscribeParams:
|
||||
# Subscribe to tasks + messages + tools without a depth limit so that all
|
||||
# descendant-namespace events are captured in one SSE and buffered into each
|
||||
# child handle's inbox. This avoids a second SSE open (and the dedup-set
|
||||
# conflict that would prevent replaying already-seen event_ids).
|
||||
# Subscribe to tasks + messages + tools + lifecycle without a depth limit
|
||||
# so all descendant-namespace events are captured in one SSE and buffered
|
||||
# into each child handle's inbox. ``lifecycle`` is included so child-
|
||||
# namespace ``started`` events (the canonical signal for
|
||||
# ``create_deep_agent``-style subagent discovery, matching JS behavior)
|
||||
# reach ``_subgraphs_iter``; servers that surface child invocations via
|
||||
# ``tasks`` events instead are also handled via the existing ``method ==
|
||||
# "tasks"`` branch.
|
||||
return {
|
||||
"channels": ["messages", "tasks", "tools"],
|
||||
"channels": ["messages", "tasks", "tools", "lifecycle"],
|
||||
"namespaces": [list(scope)],
|
||||
}
|
||||
|
||||
@@ -547,6 +620,7 @@ class ScopedStreamHandle:
|
||||
self.tool_calls = _HandleToolCallsProjection(self)
|
||||
self.subgraphs = _HandleSubgraphsProjection(self)
|
||||
self.subagents = self.subgraphs
|
||||
self.extensions = _ExtensionsProjection(thread, namespace=list(path))
|
||||
|
||||
def _push_event(self, event: Event) -> None:
|
||||
"""Route a descendant event into the appropriate channel inbox.
|
||||
@@ -943,6 +1017,28 @@ class _SubgraphsProjection:
|
||||
)
|
||||
active[path] = handle
|
||||
yield handle
|
||||
elif (
|
||||
method == "lifecycle"
|
||||
and data.get("event") == "started"
|
||||
and _is_direct_child(namespace, self._scope)
|
||||
):
|
||||
# ``create_deep_agent`` and similar surfaces signal
|
||||
# subagent invocation via a child-namespace
|
||||
# ``lifecycle: started`` event rather than a ``tasks``
|
||||
# event. JS does the same (see ``langgraphjs``
|
||||
# ``stream/handles/subgraphs.ts``).
|
||||
path = tuple(namespace)
|
||||
if path not in seen:
|
||||
seen.add(path)
|
||||
graph_name, trigger_call_id = _parse_namespace_segment(path[-1])
|
||||
handle = ScopedStreamHandle(
|
||||
thread=self._thread,
|
||||
path=path,
|
||||
graph_name=graph_name or None,
|
||||
trigger_call_id=trigger_call_id,
|
||||
)
|
||||
active[path] = handle
|
||||
yield handle
|
||||
finally:
|
||||
# Determine terminal status from the parent run's lifecycle result.
|
||||
# If _run_done resolved as errored, force-complete remaining children
|
||||
@@ -1141,6 +1237,68 @@ class _ToolCallsProjection:
|
||||
self._thread._unregister_subscription(sub.id)
|
||||
|
||||
|
||||
class _ExtensionsProjection:
|
||||
"""Mapping from extension name to custom event payload stream.
|
||||
|
||||
Repeated access for the same `name` returns the cached projection so that
|
||||
callers receive the same subscription handle across multiple references to
|
||||
`thread.extensions["foo"]` within one session.
|
||||
"""
|
||||
|
||||
def __init__(self, thread: AsyncThreadStream, namespace: list[str]) -> None:
|
||||
self._thread = thread
|
||||
self._namespace = namespace
|
||||
self._cache: dict[str, _ExtensionProjection] = {}
|
||||
|
||||
def __getitem__(self, name: str) -> _ExtensionProjection:
|
||||
if not name:
|
||||
raise ValueError("extension name must be non-empty.")
|
||||
if name not in self._cache:
|
||||
self._cache[name] = _ExtensionProjection(
|
||||
self._thread, name=name, namespace=self._namespace
|
||||
)
|
||||
return self._cache[name]
|
||||
|
||||
|
||||
class _ExtensionProjection:
|
||||
def __init__(
|
||||
self,
|
||||
thread: AsyncThreadStream,
|
||||
*,
|
||||
name: str,
|
||||
namespace: list[str],
|
||||
) -> None:
|
||||
self._thread = thread
|
||||
self._name = name
|
||||
self._namespace = namespace
|
||||
|
||||
def __aiter__(self) -> AsyncIterator[dict[str, Any]]:
|
||||
return self._iter()
|
||||
|
||||
async def _iter(self) -> AsyncGenerator[dict[str, Any], None]:
|
||||
params: SubscribeParams = {"channels": [f"custom:{self._name}"]}
|
||||
if self._namespace:
|
||||
params["namespaces"] = [self._namespace]
|
||||
sub = self._thread._register_subscription(params)
|
||||
try:
|
||||
if self._thread._closed:
|
||||
return
|
||||
await self._thread._reconcile_stream(params)
|
||||
self._thread._ensure_fanout_running()
|
||||
while True:
|
||||
item = await sub.queue.get()
|
||||
if item is None:
|
||||
return
|
||||
event_params = item.get("params") or {}
|
||||
data = (
|
||||
event_params.get("data") if isinstance(event_params, dict) else None
|
||||
)
|
||||
if isinstance(data, dict):
|
||||
yield data
|
||||
finally:
|
||||
self._thread._unregister_subscription(sub.id)
|
||||
|
||||
|
||||
class AsyncThreadStream:
|
||||
"""Async context manager for one thread's v3 streaming session.
|
||||
|
||||
@@ -1174,7 +1332,7 @@ class AsyncThreadStream:
|
||||
self._next_command_id = 1
|
||||
self._next_subscription_id = 1
|
||||
self._subscriptions: dict[int, _Subscription] = {}
|
||||
self._seen_event_ids: set[str] = set()
|
||||
self._seen_event_ids = _SeenEventIds()
|
||||
self._shared_stream: EventStreamHandle | None = None
|
||||
self._shared_stream_filter: dict[str, Any] | None = None
|
||||
self._fanout_task: asyncio.Task[None] | None = None
|
||||
@@ -1209,12 +1367,14 @@ class AsyncThreadStream:
|
||||
# them even after the shared SSE has ended (dedup prevents replay).
|
||||
self._root_messages_inbox: asyncio.Queue[Event | None] | None = None
|
||||
self.run = RunModule(self)
|
||||
self.agent = _AgentModule(self)
|
||||
self.output = _OutputAwaitable(self)
|
||||
self.values = _ValuesProjection(self)
|
||||
self.messages = _MessagesProjection(self, namespace=[])
|
||||
self.tool_calls = _ToolCallsProjection(self, namespace=[])
|
||||
self.subgraphs = _SubgraphsProjection(self, scope=())
|
||||
self.subagents = self.subgraphs
|
||||
self.extensions = _ExtensionsProjection(self, namespace=[])
|
||||
|
||||
@property
|
||||
def _controller(self) -> AsyncThreadStream:
|
||||
@@ -1345,6 +1505,27 @@ class AsyncThreadStream:
|
||||
handle._fail(err)
|
||||
self._active_tool_calls.clear()
|
||||
|
||||
def _signal_paused(self) -> None:
|
||||
"""Wake every active projection iterator on interrupt / run end.
|
||||
|
||||
Pushes the terminal sentinel (`None`) into every subscription
|
||||
queue. Iterators see `None` and return; the shared SSE keeps
|
||||
running so re-iteration after `run.respond(...)` registers a
|
||||
fresh subscription and resumes.
|
||||
|
||||
`root_messages_inbox` is intentionally NOT signaled here: the
|
||||
subgraphs projection that populates it is responsible for
|
||||
pushing the terminal `None` in its own `finally` block, so any
|
||||
message events it redirected to the inbox land before the
|
||||
sentinel. Signaling root_inbox here would race the redirection
|
||||
and could drop messages.
|
||||
"""
|
||||
# On a saturated queue the consumer is already behind; the iterator
|
||||
# will still terminate when it drains to this point.
|
||||
for sub in list(self._subscriptions.values()):
|
||||
with contextlib.suppress(asyncio.QueueFull):
|
||||
sub.queue.put_nowait(None)
|
||||
|
||||
def observe_applied_through_seq(self, seq: Any) -> None:
|
||||
"""Advance the reconnect cursor from a command response meta sequence."""
|
||||
if isinstance(seq, int) and (self._cursor is None or seq > self._cursor):
|
||||
@@ -1426,6 +1607,15 @@ class AsyncThreadStream:
|
||||
for sub in list(self._subscriptions.values()):
|
||||
if matches_subscription(event, sub.params):
|
||||
sub.queue.put_nowait(event)
|
||||
# On root-terminal lifecycle, push the `None` sentinel
|
||||
# into all subscription queues so projection iterators
|
||||
# exit when the run ends naturally. Runs on the shared
|
||||
# SSE so the terminal is processed in seq order with
|
||||
# the projection events -- any in-flight values /
|
||||
# tools / messages events for this run are already
|
||||
# queued before None.
|
||||
if _is_root_terminal_lifecycle(event):
|
||||
self._signal_paused()
|
||||
except Exception:
|
||||
# Pump errored — fall through to error-handling/reconnect.
|
||||
pass
|
||||
@@ -1542,6 +1732,16 @@ class AsyncThreadStream:
|
||||
]
|
||||
if extra is not None:
|
||||
filters.append(dict(extra))
|
||||
# Always include lifecycle in the shared SSE so the fanout consumer
|
||||
# sees root-terminal events in seq order with the projection events.
|
||||
# See `_is_root_terminal_lifecycle` -- the fanout uses it to push
|
||||
# the `None` sentinel into sub queues when the run ends naturally,
|
||||
# which is what makes projection iterators exit on a long-lived
|
||||
# SSE that doesn't EOF after the run. Per-subscription filtering
|
||||
# (`matches_subscription`) drops lifecycle events for any
|
||||
# subscription that didn't ask for them, so user-visible queues
|
||||
# don't see leaked events.
|
||||
filters.append({"channels": ["lifecycle"]})
|
||||
return compute_union_filter(filters)
|
||||
|
||||
async def _dedup_iter(self, source: AsyncIterator[Event]) -> AsyncIterator[Event]:
|
||||
@@ -1725,17 +1925,27 @@ class AsyncThreadStream:
|
||||
else [],
|
||||
}
|
||||
async with self._interrupts_lock:
|
||||
was_interrupted = self.interrupted
|
||||
self.interrupts.append(payload)
|
||||
self.interrupted = True
|
||||
# On the rising edge of `interrupted`, push the terminal
|
||||
# sentinel into every active projection subscription so their
|
||||
# iterators exit cleanly. The run is paused — not done — so
|
||||
# the shared SSE and fanout keep running; a subsequent
|
||||
# `async for snap in thread.values:` (or any other
|
||||
# projection) registers a fresh subscription and resumes
|
||||
# iteration once the consumer calls `run.respond(...)`.
|
||||
if not was_interrupted:
|
||||
self._signal_paused()
|
||||
elif method == "lifecycle":
|
||||
params = event.get("params") or {}
|
||||
data = params.get("data") if isinstance(params, dict) else None
|
||||
phase = data.get("phase") if isinstance(data, dict) else None
|
||||
phase = data.get("event") if isinstance(data, dict) else None
|
||||
if phase in ("started", "running"):
|
||||
# Mark that we have observed an active run so thread.output
|
||||
# knows a run exists (handles reattach without run.start).
|
||||
self._run_seen = True
|
||||
elif phase in ("completed", "errored"):
|
||||
elif phase in ("completed", "failed"):
|
||||
# Why: interrupts describe current-run state. Clear on terminal
|
||||
# lifecycle so a subsequent run.respond() can't fire against a
|
||||
# stale prior-run interrupt_id. Acquire `_interrupts_lock` so
|
||||
@@ -1746,7 +1956,7 @@ class AsyncThreadStream:
|
||||
self.interrupts = []
|
||||
run_done = self._run_done
|
||||
if run_done is not None and not run_done.done():
|
||||
if phase == "errored":
|
||||
if phase == "failed":
|
||||
error_msg = (
|
||||
data.get("error") if isinstance(data, dict) else None
|
||||
)
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
`SyncThreadStream` is a synchronous context manager that owns a
|
||||
`SyncProtocolSseTransport` for one thread, dispatches commands (`run.start`,
|
||||
`run.respond`), exposes subscriptions over a single shared SSE, and surfaces
|
||||
lifecycle state (`interrupted`, `interrupts`) via an always-on lifecycle watcher
|
||||
thread.
|
||||
`run.respond`), exposes typed subscriptions over a single shared SSE,
|
||||
surfaces lifecycle state (`interrupted`, `interrupts`) via an always-on
|
||||
lifecycle watcher thread, and provides typed projections (`thread.values`,
|
||||
`thread.messages`, `thread.tool_calls`, `thread.extensions`).
|
||||
|
||||
Sync mirror of `libs/sdk-py/langgraph_sdk/_async/stream.py`.
|
||||
"""
|
||||
@@ -22,6 +23,7 @@ from langchain_core.language_models.chat_model_stream import ChatModelStream
|
||||
from langchain_protocol import Event, SubscribeParams
|
||||
|
||||
from langgraph_sdk._sync.http import SyncHttpClient
|
||||
from langgraph_sdk.schema import QueryParamTypes
|
||||
from langgraph_sdk.stream.sync_controller import SyncStreamController, _SyncSubscription
|
||||
from langgraph_sdk.stream.transport import (
|
||||
SyncEventStreamHandle,
|
||||
@@ -102,8 +104,11 @@ def _is_direct_child(namespace: list[str], scope: tuple[str, ...]) -> bool:
|
||||
|
||||
|
||||
def _subgraph_subscription_params(scope: tuple[str, ...]) -> SubscribeParams:
|
||||
# Includes ``lifecycle`` so child-namespace ``started`` events (the
|
||||
# ``create_deep_agent`` subagent discovery signal, matching JS)
|
||||
# reach ``_subgraphs_iter`` alongside ``tasks``-based discovery.
|
||||
return {
|
||||
"channels": ["messages", "tasks", "tools"],
|
||||
"channels": ["messages", "tasks", "tools", "lifecycle"],
|
||||
"namespaces": [list(scope)],
|
||||
}
|
||||
|
||||
@@ -157,6 +162,34 @@ class _BlockingResult:
|
||||
return self._event.is_set()
|
||||
|
||||
|
||||
class _SyncAgentModule:
|
||||
"""Assistant graph helpers scoped to one sync thread stream."""
|
||||
|
||||
def __init__(self, owner: SyncThreadStream) -> None:
|
||||
self._owner = owner
|
||||
|
||||
def get_tree(
|
||||
self,
|
||||
*,
|
||||
xray: int | bool = False,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
if self._owner._closed:
|
||||
raise RuntimeError("SyncThreadStream is closed.")
|
||||
query_params: dict[str, Any] = {}
|
||||
if xray:
|
||||
query_params["xray"] = xray
|
||||
if params:
|
||||
query_params.update(dict(params))
|
||||
request_headers = {**self._owner._headers, **dict(headers or {})}
|
||||
return self._owner._http.get(
|
||||
f"/assistants/{self._owner.assistant_id}/graph",
|
||||
params=query_params,
|
||||
headers=request_headers or None,
|
||||
)
|
||||
|
||||
|
||||
class SyncRunModule:
|
||||
"""Command dispatcher for `run.start`.
|
||||
|
||||
@@ -724,6 +757,7 @@ class SyncScopedStreamHandle:
|
||||
self.tool_calls = _SyncHandleToolCallsProjection(self)
|
||||
self.subgraphs = _SyncHandleSubgraphsProjection(self)
|
||||
self.subagents = self.subgraphs
|
||||
self.extensions = _SyncExtensionsProjection(thread, namespace=list(path))
|
||||
|
||||
def _push_event(self, event: Event) -> None:
|
||||
"""Route a descendant event into the appropriate channel inbox.
|
||||
@@ -1092,6 +1126,25 @@ class _SyncSubgraphsProjection:
|
||||
)
|
||||
active[path] = handle
|
||||
yield handle
|
||||
elif (
|
||||
method == "lifecycle"
|
||||
and data.get("event") == "started"
|
||||
and _is_direct_child(namespace, self._scope)
|
||||
):
|
||||
# ``create_deep_agent`` subagent discovery: child-
|
||||
# namespace ``lifecycle: started`` rather than ``tasks``.
|
||||
path = tuple(namespace)
|
||||
if path not in seen:
|
||||
seen.add(path)
|
||||
graph_name, trigger_call_id = _parse_namespace_segment(path[-1])
|
||||
handle = SyncScopedStreamHandle(
|
||||
thread=self._thread,
|
||||
path=path,
|
||||
graph_name=graph_name or None,
|
||||
trigger_call_id=trigger_call_id,
|
||||
)
|
||||
active[path] = handle
|
||||
yield handle
|
||||
finally:
|
||||
# Determine terminal status from the run's lifecycle result.
|
||||
# If _run_done resolved as errored, force-complete remaining children
|
||||
@@ -1132,6 +1185,70 @@ class _SyncSubgraphsProjection:
|
||||
del active[child_path]
|
||||
|
||||
|
||||
class _SyncExtensionsProjection:
|
||||
"""Mapping from extension name to custom event payload stream.
|
||||
|
||||
Repeated access for the same `name` returns the cached projection so that
|
||||
callers receive the same subscription handle across multiple references to
|
||||
`thread.extensions["foo"]` within one session.
|
||||
"""
|
||||
|
||||
def __init__(self, thread: SyncThreadStream, namespace: list[str]) -> None:
|
||||
self._thread = thread
|
||||
self._namespace = namespace
|
||||
self._cache: dict[str, _SyncExtensionProjection] = {}
|
||||
|
||||
def __getitem__(self, name: str) -> _SyncExtensionProjection:
|
||||
if not name:
|
||||
raise ValueError("extension name must be non-empty.")
|
||||
if name not in self._cache:
|
||||
self._cache[name] = _SyncExtensionProjection(
|
||||
self._thread,
|
||||
name=name,
|
||||
namespace=self._namespace,
|
||||
)
|
||||
return self._cache[name]
|
||||
|
||||
|
||||
class _SyncExtensionProjection:
|
||||
def __init__(
|
||||
self,
|
||||
thread: SyncThreadStream,
|
||||
*,
|
||||
name: str,
|
||||
namespace: list[str],
|
||||
) -> None:
|
||||
self._thread = thread
|
||||
self._name = name
|
||||
self._namespace = namespace
|
||||
|
||||
def __iter__(self) -> Iterator[dict[str, Any]]:
|
||||
return self._iter()
|
||||
|
||||
def _iter(self) -> Iterator[dict[str, Any]]:
|
||||
params: SubscribeParams = {"channels": [f"custom:{self._name}"]}
|
||||
if self._namespace:
|
||||
params["namespaces"] = [self._namespace]
|
||||
sub = self._thread._register_subscription(params)
|
||||
try:
|
||||
if self._thread._closed:
|
||||
return
|
||||
self._thread._reconcile_stream(params)
|
||||
self._thread._ensure_fanout_running()
|
||||
while True:
|
||||
item = sub.queue.get()
|
||||
if item is None:
|
||||
return
|
||||
event_params = item.get("params") or {}
|
||||
data = (
|
||||
event_params.get("data") if isinstance(event_params, dict) else None
|
||||
)
|
||||
if isinstance(data, dict):
|
||||
yield data
|
||||
finally:
|
||||
self._thread._unregister_subscription(sub.id)
|
||||
|
||||
|
||||
class SyncThreadStream:
|
||||
"""Synchronous context manager for one thread's v3 streaming session.
|
||||
|
||||
@@ -1174,11 +1291,13 @@ class SyncThreadStream:
|
||||
self._active_tool_calls: set[SyncToolCallHandle] = set()
|
||||
self._root_messages_inbox: queue.Queue[Event | None] | None = None
|
||||
self.run = SyncRunModule(self)
|
||||
self.agent = _SyncAgentModule(self)
|
||||
self.values = _SyncValuesProjection(self)
|
||||
self.messages = _SyncMessagesProjection(self, namespace=[])
|
||||
self.tool_calls = _SyncToolCallsProjection(self, namespace=[])
|
||||
self.subgraphs = _SyncSubgraphsProjection(self, scope=())
|
||||
self.subagents = self.subgraphs
|
||||
self.extensions = _SyncExtensionsProjection(self, namespace=[])
|
||||
|
||||
def __enter__(self) -> SyncThreadStream:
|
||||
if self._closed:
|
||||
@@ -1307,6 +1426,19 @@ class SyncThreadStream:
|
||||
handle._fail(err)
|
||||
self._active_tool_calls.clear()
|
||||
|
||||
def _signal_paused(self) -> None:
|
||||
"""Wake every active projection iterator on interrupt / run end.
|
||||
|
||||
Delegates to the shared controller (subscription queues). The
|
||||
root messages inbox is intentionally NOT signaled here: the
|
||||
subgraphs projection that populates it is responsible for
|
||||
pushing the terminal ``None`` in its own ``finally`` block, so
|
||||
any message events it redirected to the inbox land before the
|
||||
sentinel. Signaling root_inbox here would race the redirection.
|
||||
"""
|
||||
if self._controller is not None:
|
||||
self._controller.signal_paused()
|
||||
|
||||
def subscribe(
|
||||
self,
|
||||
channels: list[str],
|
||||
@@ -1472,20 +1604,30 @@ class SyncThreadStream:
|
||||
if isinstance(params, dict)
|
||||
else [],
|
||||
}
|
||||
was_interrupted = self.interrupted
|
||||
self.interrupts.append(payload)
|
||||
self.interrupted = True
|
||||
# On the rising edge of `interrupted`, push the terminal
|
||||
# sentinel into every active projection subscription so their
|
||||
# iterators exit cleanly. The run is paused — not done — so
|
||||
# the shared SSE and fanout keep running; a subsequent
|
||||
# `for snap in thread.values:` (or any other projection)
|
||||
# registers a fresh subscription and resumes iteration once
|
||||
# the consumer calls `run.respond(...)`.
|
||||
if not was_interrupted:
|
||||
self._signal_paused()
|
||||
elif method == "lifecycle":
|
||||
params = event.get("params") or {}
|
||||
data = params.get("data") if isinstance(params, dict) else None
|
||||
phase = data.get("phase") if isinstance(data, dict) else None
|
||||
phase = data.get("event") if isinstance(data, dict) else None
|
||||
if phase in ("started", "running"):
|
||||
self._run_seen = True
|
||||
elif phase in ("completed", "errored"):
|
||||
elif phase in ("completed", "failed"):
|
||||
self.interrupted = False
|
||||
self.interrupts = []
|
||||
run_done = self._run_done
|
||||
if run_done is not None and not run_done.done():
|
||||
if phase == "errored":
|
||||
if phase == "failed":
|
||||
error_msg = (
|
||||
data.get("error") if isinstance(data, dict) else None
|
||||
)
|
||||
|
||||
@@ -22,6 +22,31 @@ from langgraph_sdk.stream.transport import (
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_ROOT_TERMINAL_LIFECYCLE_EVENTS = frozenset({"completed", "failed"})
|
||||
|
||||
|
||||
def _is_root_terminal_lifecycle(event: Any) -> bool:
|
||||
"""Return True for a root-namespace lifecycle event marking run end.
|
||||
|
||||
Matches the wire shape ``{method: "lifecycle", params: {namespace: [],
|
||||
data: {event: "completed" | "failed"}}}``. Subgraph lifecycle events
|
||||
(non-empty namespace) do not terminate the parent run.
|
||||
"""
|
||||
if not isinstance(event, dict):
|
||||
return False
|
||||
if event.get("method") != "lifecycle":
|
||||
return False
|
||||
params = event.get("params") or {}
|
||||
if not isinstance(params, dict):
|
||||
return False
|
||||
if params.get("namespace") or []:
|
||||
return False
|
||||
data = params.get("data") or {}
|
||||
if not isinstance(data, dict):
|
||||
return False
|
||||
return data.get("event") in _ROOT_TERMINAL_LIFECYCLE_EVENTS
|
||||
|
||||
|
||||
@dataclass
|
||||
class _SyncSubscription:
|
||||
id: int
|
||||
@@ -78,6 +103,19 @@ class SyncStreamController:
|
||||
with self._lock:
|
||||
self._subscriptions.pop(subscription_id, None)
|
||||
|
||||
def signal_paused(self) -> None:
|
||||
"""Wake every active subscription iterator on interrupt (run pause).
|
||||
|
||||
Pushes the terminal sentinel (`None`) into every subscription queue.
|
||||
Iterators see `None` and return; the shared SSE keeps running so
|
||||
re-iteration after `run.respond(...)` registers a fresh subscription
|
||||
and resumes.
|
||||
"""
|
||||
with self._lock:
|
||||
subs = list(self._subscriptions.values())
|
||||
for sub in subs:
|
||||
sub.queue.put(None)
|
||||
|
||||
def reconcile_stream(self, candidate_filter: SubscribeParams) -> None:
|
||||
if self._run_start_gate is not None and not self._run_start_gate.wait(
|
||||
timeout=self._run_start_timeout
|
||||
@@ -136,6 +174,14 @@ class SyncStreamController:
|
||||
for sub in subscriptions:
|
||||
if matches_subscription(event, sub.params):
|
||||
sub.queue.put(event)
|
||||
# Root-terminal lifecycle: push `None` into all sub
|
||||
# queues so projection iterators exit when the run
|
||||
# ends naturally. Terminal is processed in seq order
|
||||
# on the shared SSE, so in-flight values/tools/
|
||||
# messages events for this run are already queued
|
||||
# before None.
|
||||
if _is_root_terminal_lifecycle(event):
|
||||
self.signal_paused()
|
||||
except Exception:
|
||||
pass # transport drop — attempt reconnect below
|
||||
|
||||
@@ -178,6 +224,10 @@ class SyncStreamController:
|
||||
filters = [dict(sub.params) for sub in self._subscriptions.values()]
|
||||
if extra is not None:
|
||||
filters.append(dict(extra))
|
||||
# Always include lifecycle in the shared SSE filter so `_fanout`
|
||||
# sees root-terminal events in seq order with the projection
|
||||
# events. See `_is_root_terminal_lifecycle`.
|
||||
filters.append({"channels": ["lifecycle"]})
|
||||
return compute_union_filter(filters)
|
||||
|
||||
def observe_applied_through_seq(self, seq: Any) -> None:
|
||||
|
||||
@@ -69,7 +69,7 @@ class SyncProtocolWebSocketTransport:
|
||||
|
||||
url = build_websocket_url(self._client.base_url, self._stream_path)
|
||||
handshake_headers = list(websocket_headers(self._default_headers))
|
||||
cookie_header = _cookie_header(self._client)
|
||||
cookie_header = _cookie_header(self._client, self._stream_path)
|
||||
if cookie_header:
|
||||
handshake_headers.append(("Cookie", cookie_header))
|
||||
# Pre-enter the WebSocket context manager so close() can reach the socket
|
||||
@@ -85,7 +85,17 @@ class SyncProtocolWebSocketTransport:
|
||||
def events() -> Iterator[Event]:
|
||||
nonlocal stream_error
|
||||
try:
|
||||
websocket.send(orjson.dumps(build_event_stream_body(params)).decode())
|
||||
# Wrap the initial subscribe in a ``subscription.subscribe``
|
||||
# Protocol command envelope so the server's WS endpoint
|
||||
# (see ``langgraph-api`` ``api/event_streaming.py``
|
||||
# ``_thread_websocket``) accepts it. Bare subscribe bodies
|
||||
# are rejected with ``invalid_argument``.
|
||||
subscribe_command = {
|
||||
"id": 1,
|
||||
"method": "subscription.subscribe",
|
||||
"params": build_event_stream_body(params),
|
||||
}
|
||||
websocket.send(orjson.dumps(subscribe_command).decode())
|
||||
for raw in websocket:
|
||||
if closed:
|
||||
return
|
||||
@@ -121,9 +131,18 @@ def _decode_frame(raw: str | bytes | bytearray | memoryview) -> Any:
|
||||
return orjson.loads(bytes(raw))
|
||||
|
||||
|
||||
def _cookie_header(client: httpx.Client) -> str | None:
|
||||
"""Build a `Cookie` header value from the httpx client's cookie jar."""
|
||||
cookies = dict(client.cookies)
|
||||
if not cookies:
|
||||
def _cookie_header(client: httpx.Client, path: str) -> str | None:
|
||||
"""Build a `Cookie` header for the WebSocket handshake.
|
||||
|
||||
Why pass `path`: `dict(client.cookies)` flattens the entire jar without
|
||||
domain/path filtering, so cookies set by responses from other origins would
|
||||
leak to the WS server. We delegate to `httpx.Cookies.set_cookie_header`,
|
||||
which applies the same `CookieJar` rules httpx uses for regular HTTP
|
||||
requests, scoping the result to `client.base_url` + `path`.
|
||||
"""
|
||||
if not list(client.cookies.jar):
|
||||
return None
|
||||
return "; ".join(f"{k}={v}" for k, v in cookies.items())
|
||||
target = client.base_url.copy_with(path=path)
|
||||
request = httpx.Request("GET", target)
|
||||
client.cookies.set_cookie_header(request)
|
||||
return request.headers.get("Cookie")
|
||||
|
||||
@@ -81,7 +81,7 @@ class ProtocolWebSocketTransport:
|
||||
try:
|
||||
url = build_websocket_url(self._client.base_url, self._stream_path)
|
||||
handshake_headers = list(websocket_headers(self._default_headers))
|
||||
cookie_header = _cookie_header(self._client)
|
||||
cookie_header = _cookie_header(self._client, self._stream_path)
|
||||
if cookie_header:
|
||||
handshake_headers.append(("Cookie", cookie_header))
|
||||
async with self._connect(
|
||||
@@ -92,9 +92,22 @@ class ProtocolWebSocketTransport:
|
||||
) as websocket:
|
||||
ws_holder["ws"] = websocket
|
||||
try:
|
||||
await websocket.send(
|
||||
orjson.dumps(build_event_stream_body(params)).decode()
|
||||
)
|
||||
# The server's WS endpoint (``ApiWebSocketRoute`` in
|
||||
# ``langgraph-api`` ``api/event_streaming.py``) treats
|
||||
# every inbound frame as a Protocol command and
|
||||
# rejects bare subscribe bodies with
|
||||
# ``invalid_argument``. Wrap the initial subscribe
|
||||
# in a ``subscription.subscribe`` command envelope.
|
||||
# The id is constant (one auto-subscribe per WS
|
||||
# connection); the resulting success response is
|
||||
# delivered to the event queue and ignored by the
|
||||
# SDK fanout (no ``method`` field).
|
||||
subscribe_command = {
|
||||
"id": 1,
|
||||
"method": "subscription.subscribe",
|
||||
"params": build_event_stream_body(params),
|
||||
}
|
||||
await websocket.send(orjson.dumps(subscribe_command).decode())
|
||||
if not ready.done():
|
||||
ready.set_result(None)
|
||||
async for raw in websocket:
|
||||
@@ -188,9 +201,18 @@ def _decode_frame(
|
||||
return payload
|
||||
|
||||
|
||||
def _cookie_header(client: httpx.AsyncClient) -> str | None:
|
||||
"""Build a `Cookie` header value from the httpx client's cookie jar."""
|
||||
cookies = dict(client.cookies)
|
||||
if not cookies:
|
||||
def _cookie_header(client: httpx.AsyncClient, path: str) -> str | None:
|
||||
"""Build a `Cookie` header for the WebSocket handshake.
|
||||
|
||||
Why pass `path`: `dict(client.cookies)` flattens the entire jar without
|
||||
domain/path filtering, so cookies set by responses from other origins would
|
||||
leak to the WS server. We delegate to `httpx.Cookies.set_cookie_header`,
|
||||
which applies the same `CookieJar` rules httpx uses for regular HTTP
|
||||
requests, scoping the result to `client.base_url` + `path`.
|
||||
"""
|
||||
if not list(client.cookies.jar):
|
||||
return None
|
||||
return "; ".join(f"{k}={v}" for k, v in cookies.items())
|
||||
target = client.base_url.copy_with(path=path)
|
||||
request = httpx.Request("GET", target)
|
||||
client.cookies.set_cookie_header(request)
|
||||
return request.headers.get("Cookie")
|
||||
|
||||
@@ -53,8 +53,11 @@ dev = [
|
||||
include = ["langgraph_sdk"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "--strict-markers --strict-config --durations=5 -vv"
|
||||
addopts = "--strict-markers --strict-config --durations=5 -vv -m 'not integration'"
|
||||
asyncio_mode = "auto"
|
||||
markers = [
|
||||
"integration: end-to-end tests that require a running langgraph-api stack at http://localhost:2024. Excluded from `make test` by default; opt in with `pytest -m integration` (and the autouse fixture skips if the API is unreachable).",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
default-groups = ['dev']
|
||||
@@ -85,7 +88,13 @@ ignore = [
|
||||
"B904", # raise without from inside except (sometimes intentional)
|
||||
"SIM102", # nested if statements (sometimes clearer)
|
||||
]
|
||||
per-file-ignores = { "tests/**" = ["S101", "B017"] }
|
||||
per-file-ignores = { "tests/**" = ["S101", "B017"], "integration/**" = ["S101", "T20", "B017", "ARG001", "ARG002"] }
|
||||
|
||||
[tool.ty.src]
|
||||
# The `integration/` graphs run inside the docker image (with `deepagents`
|
||||
# and other graph-only deps installed there) and are not part of the SDK
|
||||
# package surface, so we don't typecheck them in the sdk-py venv.
|
||||
exclude = ["integration"]
|
||||
|
||||
[tool.ty.rules]
|
||||
no-matching-overload = "ignore"
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Shared fixtures for the integration suite.
|
||||
|
||||
These tests require a running langgraph-api server at `LANGGRAPH_INTEGRATION_URL`
|
||||
(defaults to `http://localhost:2024`). Stand it up via the docker stack in
|
||||
`libs/sdk-py/integration/`:
|
||||
|
||||
cd libs/sdk-py/integration && docker compose up -d
|
||||
|
||||
The `integration` marker is registered in `pyproject.toml` and excluded by
|
||||
default in pytest's `addopts`; opt in with `pytest -m integration`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
BASE_URL = os.environ.get("LANGGRAPH_INTEGRATION_URL", "http://localhost:2024")
|
||||
ASSISTANT_ID = "agent"
|
||||
TOOLS_ASSISTANT_ID = "tools_agent"
|
||||
DEEP_AGENT_ASSISTANT_ID = "deep_agent"
|
||||
|
||||
EXPECTED_TERMINAL_ITEMS = ["streamed", "tool", "asked", "sub"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _require_running_api() -> None:
|
||||
"""Skip the whole integration suite if the API isn't reachable.
|
||||
|
||||
Autouse + session-scoped so a missing stack short-circuits before any
|
||||
test runs (no per-test connection timeouts piling up).
|
||||
"""
|
||||
try:
|
||||
resp = httpx.get(f"{BASE_URL}/ok", timeout=2.0)
|
||||
resp.raise_for_status()
|
||||
except Exception as err:
|
||||
pytest.skip(
|
||||
f"langgraph-api not reachable at {BASE_URL}: {err!r}. "
|
||||
f"Bring up the stack with `cd libs/sdk-py/integration && docker compose up -d`."
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_threads() -> AsyncIterator[tuple[object, httpx.AsyncClient]]:
|
||||
"""Build an async ThreadsClient. Yields `(threads, raw_httpx)` so tests can close raw."""
|
||||
from langgraph_sdk._async.http import HttpClient
|
||||
from langgraph_sdk._async.threads import ThreadsClient
|
||||
|
||||
raw = httpx.AsyncClient(base_url=BASE_URL, timeout=30.0)
|
||||
try:
|
||||
yield ThreadsClient(HttpClient(raw)), raw
|
||||
finally:
|
||||
await raw.aclose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sync_threads() -> Iterator[tuple[object, httpx.Client]]:
|
||||
"""Build a sync ThreadsClient. Yields `(threads, raw_httpx)` so tests can close raw."""
|
||||
from langgraph_sdk._sync.http import SyncHttpClient
|
||||
from langgraph_sdk._sync.threads import SyncThreadsClient
|
||||
|
||||
raw = httpx.Client(base_url=BASE_URL, timeout=30.0)
|
||||
try:
|
||||
yield SyncThreadsClient(SyncHttpClient(raw)), raw
|
||||
finally:
|
||||
raw.close()
|
||||
@@ -0,0 +1,120 @@
|
||||
"""`AssistantsClient` against the integration API.
|
||||
|
||||
Covers the CRUD round-trip (create / get / update / delete), search by
|
||||
metadata, and the graph introspection helpers (`get_graph`,
|
||||
`get_schemas`). Both async and sync.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from .conftest import ASSISTANT_ID
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def _async_assistants(raw):
|
||||
from langgraph_sdk._async.assistants import AssistantsClient
|
||||
from langgraph_sdk._async.http import HttpClient
|
||||
|
||||
return AssistantsClient(HttpClient(raw))
|
||||
|
||||
|
||||
def _sync_assistants(raw):
|
||||
from langgraph_sdk._sync.assistants import SyncAssistantsClient
|
||||
from langgraph_sdk._sync.http import SyncHttpClient
|
||||
|
||||
return SyncAssistantsClient(SyncHttpClient(raw))
|
||||
|
||||
|
||||
async def test_assistants_crud_async(async_threads) -> None:
|
||||
_, raw = async_threads
|
||||
client = _async_assistants(raw)
|
||||
created = await client.create(
|
||||
graph_id=ASSISTANT_ID,
|
||||
metadata={"suite": "integration", "label": "crud-async"},
|
||||
name="crud-async",
|
||||
)
|
||||
aid = created["assistant_id"]
|
||||
try:
|
||||
fetched = await client.get(aid)
|
||||
assert fetched["assistant_id"] == aid
|
||||
assert fetched["graph_id"] == ASSISTANT_ID
|
||||
|
||||
updated = await client.update(
|
||||
aid, metadata={"suite": "integration", "label": "crud-async-updated"}
|
||||
)
|
||||
assert updated["metadata"]["label"] == "crud-async-updated"
|
||||
|
||||
results = await client.search(metadata={"label": "crud-async-updated"})
|
||||
assert any(a["assistant_id"] == aid for a in results)
|
||||
finally:
|
||||
await client.delete(aid)
|
||||
|
||||
|
||||
def test_assistants_crud_sync(sync_threads) -> None:
|
||||
_, raw = sync_threads
|
||||
client = _sync_assistants(raw)
|
||||
created = client.create(
|
||||
graph_id=ASSISTANT_ID,
|
||||
metadata={"suite": "integration", "label": "crud-sync"},
|
||||
name="crud-sync",
|
||||
)
|
||||
aid = created["assistant_id"]
|
||||
try:
|
||||
fetched = client.get(aid)
|
||||
assert fetched["assistant_id"] == aid
|
||||
assert fetched["graph_id"] == ASSISTANT_ID
|
||||
|
||||
updated = client.update(
|
||||
aid, metadata={"suite": "integration", "label": "crud-sync-updated"}
|
||||
)
|
||||
assert updated["metadata"]["label"] == "crud-sync-updated"
|
||||
|
||||
results = client.search(metadata={"label": "crud-sync-updated"})
|
||||
assert any(a["assistant_id"] == aid for a in results)
|
||||
finally:
|
||||
client.delete(aid)
|
||||
|
||||
|
||||
async def test_assistants_graph_introspection_async(async_threads) -> None:
|
||||
_, raw = async_threads
|
||||
client = _async_assistants(raw)
|
||||
# Introspection endpoints require a UUID. langgraph-api auto-creates
|
||||
# one assistant per registered graph on startup; look it up by graph_id.
|
||||
matches = await client.search(graph_id=ASSISTANT_ID, limit=1)
|
||||
assert matches, f"no auto-created assistant for graph_id={ASSISTANT_ID!r}"
|
||||
aid = matches[0]["assistant_id"]
|
||||
|
||||
graph = await client.get_graph(aid)
|
||||
node_ids = [n["id"] for n in graph.get("nodes", [])]
|
||||
assert "stream_message" in node_ids
|
||||
assert "ask_human" in node_ids
|
||||
|
||||
graph_xray = await client.get_graph(aid, xray=True)
|
||||
assert "nodes" in graph_xray and "edges" in graph_xray
|
||||
|
||||
schemas = await client.get_schemas(aid)
|
||||
# Just verify the shape rather than exact field names (server-side
|
||||
# schema generation may evolve).
|
||||
assert "state_schema" in schemas
|
||||
|
||||
|
||||
def test_assistants_graph_introspection_sync(sync_threads) -> None:
|
||||
_, raw = sync_threads
|
||||
client = _sync_assistants(raw)
|
||||
matches = client.search(graph_id=ASSISTANT_ID, limit=1)
|
||||
assert matches, f"no auto-created assistant for graph_id={ASSISTANT_ID!r}"
|
||||
aid = matches[0]["assistant_id"]
|
||||
|
||||
graph = client.get_graph(aid)
|
||||
node_ids = [n["id"] for n in graph.get("nodes", [])]
|
||||
assert "stream_message" in node_ids
|
||||
assert "ask_human" in node_ids
|
||||
|
||||
graph_xray = client.get_graph(aid, xray=True)
|
||||
assert "nodes" in graph_xray and "edges" in graph_xray
|
||||
|
||||
schemas = client.get_schemas(aid)
|
||||
assert "state_schema" in schemas
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Mid-run cancellation via `runs.cancel(...)`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from .conftest import ASSISTANT_ID
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_CANCEL_GRACE_SECONDS = 10.0
|
||||
|
||||
|
||||
async def _cancel_after_first_event(
|
||||
runs_client: Any,
|
||||
thread_id: str,
|
||||
run_id_future: asyncio.Future[str],
|
||||
) -> None:
|
||||
run_id = await run_id_future
|
||||
await asyncio.sleep(0.1)
|
||||
with contextlib.suppress(Exception):
|
||||
await runs_client.cancel(thread_id, run_id, wait=False)
|
||||
|
||||
|
||||
async def test_cancel_async(async_threads) -> None:
|
||||
from langgraph_sdk._async.http import HttpClient
|
||||
from langgraph_sdk._async.runs import RunsClient
|
||||
|
||||
threads, raw = async_threads
|
||||
runs_client = RunsClient(HttpClient(raw))
|
||||
|
||||
async with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
run_id_future: asyncio.Future[str] = asyncio.get_running_loop().create_future()
|
||||
start_result = await thread.run.start(
|
||||
input={"messages": [], "value": "init", "items": []}
|
||||
)
|
||||
run_id = start_result.get("run_id")
|
||||
assert run_id, f"run.start returned no run_id: {start_result!r}"
|
||||
run_id_future.set_result(run_id)
|
||||
|
||||
canceller = asyncio.create_task(
|
||||
_cancel_after_first_event(runs_client, thread.thread_id, run_id_future)
|
||||
)
|
||||
|
||||
started = time.monotonic()
|
||||
iteration_error: BaseException | None = None
|
||||
try:
|
||||
async for _snap in thread.values:
|
||||
if time.monotonic() - started > _CANCEL_GRACE_SECONDS:
|
||||
raise AssertionError(
|
||||
f"values iterator did not terminate within "
|
||||
f"{_CANCEL_GRACE_SECONDS}s of cancel"
|
||||
)
|
||||
except BaseException as err:
|
||||
iteration_error = err
|
||||
|
||||
await canceller
|
||||
|
||||
persisted = await threads.get(thread.thread_id)
|
||||
status = persisted.get("status")
|
||||
assert iteration_error is None, (
|
||||
f"values iterator raised after cancel: {iteration_error!r}"
|
||||
)
|
||||
assert status != "success", (
|
||||
f"expected non-success terminal status after cancel, got {status!r}"
|
||||
)
|
||||
|
||||
|
||||
def _cancel_after_first_event_sync(
|
||||
runs_client: Any,
|
||||
thread_id: str,
|
||||
run_id_event: threading.Event,
|
||||
run_id_holder: dict[str, str],
|
||||
) -> None:
|
||||
run_id_event.wait(timeout=10.0)
|
||||
run_id = run_id_holder.get("run_id")
|
||||
if not run_id:
|
||||
return
|
||||
time.sleep(0.1)
|
||||
with contextlib.suppress(Exception):
|
||||
runs_client.cancel(thread_id, run_id, wait=False)
|
||||
|
||||
|
||||
def test_cancel_sync(sync_threads) -> None:
|
||||
from langgraph_sdk._sync.http import SyncHttpClient
|
||||
from langgraph_sdk._sync.runs import SyncRunsClient
|
||||
|
||||
threads, raw = sync_threads
|
||||
runs_client = SyncRunsClient(SyncHttpClient(raw))
|
||||
|
||||
with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
run_id_event = threading.Event()
|
||||
run_id_holder: dict[str, str] = {}
|
||||
start_result = thread.run.start(
|
||||
input={"messages": [], "value": "init", "items": []}
|
||||
)
|
||||
run_id = start_result.get("run_id")
|
||||
assert run_id, f"run.start returned no run_id: {start_result!r}"
|
||||
run_id_holder["run_id"] = run_id
|
||||
run_id_event.set()
|
||||
|
||||
canceller = threading.Thread(
|
||||
target=_cancel_after_first_event_sync,
|
||||
args=(runs_client, thread.thread_id, run_id_event, run_id_holder),
|
||||
daemon=True,
|
||||
name="cancel-worker",
|
||||
)
|
||||
canceller.start()
|
||||
|
||||
started = time.monotonic()
|
||||
iteration_error: BaseException | None = None
|
||||
try:
|
||||
for _snap in thread.values:
|
||||
if time.monotonic() - started > _CANCEL_GRACE_SECONDS:
|
||||
raise AssertionError(
|
||||
f"values iterator did not terminate within "
|
||||
f"{_CANCEL_GRACE_SECONDS}s of cancel"
|
||||
)
|
||||
except BaseException as err:
|
||||
iteration_error = err
|
||||
|
||||
canceller.join(timeout=5)
|
||||
|
||||
persisted = threads.get(thread.thread_id)
|
||||
status = persisted.get("status")
|
||||
assert iteration_error is None, (
|
||||
f"values iterator raised after cancel: {iteration_error!r}"
|
||||
)
|
||||
assert status != "success", (
|
||||
f"expected non-success terminal status after cancel, got {status!r}"
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Concurrent `threads.stream()` against the integration API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from .conftest import ASSISTANT_ID, EXPECTED_TERMINAL_ITEMS
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def _drive_one_async(threads: Any) -> dict[str, Any]:
|
||||
async with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
await thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
async for _ in thread.values:
|
||||
if thread.interrupted:
|
||||
break
|
||||
if thread.interrupted:
|
||||
await thread.run.respond("yes")
|
||||
|
||||
final = await thread.output
|
||||
return {"thread_id": thread.thread_id, "items": final.get("items")}
|
||||
|
||||
|
||||
async def test_concurrent_streams_async(async_threads) -> None:
|
||||
threads, _ = async_threads
|
||||
a, b = await asyncio.gather(_drive_one_async(threads), _drive_one_async(threads))
|
||||
assert a["items"] == EXPECTED_TERMINAL_ITEMS
|
||||
assert b["items"] == EXPECTED_TERMINAL_ITEMS
|
||||
assert a["thread_id"] != b["thread_id"], (
|
||||
f"concurrent streams collided on thread_id {a['thread_id']!r}"
|
||||
)
|
||||
|
||||
|
||||
def _drive_one_sync(
|
||||
threads: Any, label: str, results: dict[str, dict[str, Any]]
|
||||
) -> None:
|
||||
with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
for _ in thread.values:
|
||||
if thread.interrupted:
|
||||
break
|
||||
if thread.interrupted:
|
||||
thread.run.respond("yes")
|
||||
|
||||
final = thread.output
|
||||
results[label] = {"thread_id": thread.thread_id, "items": final.get("items")}
|
||||
|
||||
|
||||
def test_concurrent_streams_sync(sync_threads) -> None:
|
||||
threads, _ = sync_threads
|
||||
results: dict[str, dict[str, Any]] = {}
|
||||
workers = [
|
||||
threading.Thread(
|
||||
target=_drive_one_sync,
|
||||
args=(threads, label, results),
|
||||
daemon=True,
|
||||
name=f"sync-stream-{label}",
|
||||
)
|
||||
for label in ("A", "B")
|
||||
]
|
||||
for w in workers:
|
||||
w.start()
|
||||
for w in workers:
|
||||
w.join(timeout=60)
|
||||
assert not w.is_alive(), f"worker {w.name} did not finish within 60s"
|
||||
|
||||
a = results.get("A")
|
||||
b = results.get("B")
|
||||
assert a is not None and a["items"] == EXPECTED_TERMINAL_ITEMS
|
||||
assert b is not None and b["items"] == EXPECTED_TERMINAL_ITEMS
|
||||
assert a["thread_id"] != b["thread_id"], (
|
||||
f"concurrent streams collided on thread_id {a['thread_id']!r}"
|
||||
)
|
||||
@@ -0,0 +1,68 @@
|
||||
"""`CronClient` against the integration API.
|
||||
|
||||
Covers create / search / delete (no `update` since the surface accepts a
|
||||
sparse update and the round-trip is implicitly exercised by the others).
|
||||
The schedule fires in the future so the cron is never executed during
|
||||
the test; we tear it down before any tick can land.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from .conftest import ASSISTANT_ID
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def _async_crons(raw):
|
||||
from langgraph_sdk._async.cron import CronClient
|
||||
from langgraph_sdk._async.http import HttpClient
|
||||
|
||||
return CronClient(HttpClient(raw))
|
||||
|
||||
|
||||
def _sync_crons(raw):
|
||||
from langgraph_sdk._sync.cron import SyncCronClient
|
||||
from langgraph_sdk._sync.http import SyncHttpClient
|
||||
|
||||
return SyncCronClient(SyncHttpClient(raw))
|
||||
|
||||
|
||||
# Once a year, on Jan 1 at 00:00 UTC. Deterministic and well past any
|
||||
# test runtime.
|
||||
_DISTANT_SCHEDULE = "0 0 1 1 *"
|
||||
|
||||
|
||||
async def test_crons_create_search_delete_async(async_threads) -> None:
|
||||
_, raw = async_threads
|
||||
crons = _async_crons(raw)
|
||||
created = await crons.create(
|
||||
ASSISTANT_ID,
|
||||
schedule=_DISTANT_SCHEDULE,
|
||||
input={"messages": [], "value": "init", "items": []},
|
||||
metadata={"suite": "integration", "label": "crons-async"},
|
||||
)
|
||||
cron_id = created["cron_id"]
|
||||
try:
|
||||
results = await crons.search(limit=20)
|
||||
assert any(c["cron_id"] == cron_id for c in results)
|
||||
finally:
|
||||
await crons.delete(cron_id)
|
||||
|
||||
|
||||
def test_crons_create_search_delete_sync(sync_threads) -> None:
|
||||
_, raw = sync_threads
|
||||
crons = _sync_crons(raw)
|
||||
created = crons.create(
|
||||
ASSISTANT_ID,
|
||||
schedule=_DISTANT_SCHEDULE,
|
||||
input={"messages": [], "value": "init", "items": []},
|
||||
metadata={"suite": "integration", "label": "crons-sync"},
|
||||
)
|
||||
cron_id = created["cron_id"]
|
||||
try:
|
||||
results = crons.search(limit=20)
|
||||
assert any(c["cron_id"] == cron_id for c in results)
|
||||
finally:
|
||||
crons.delete(cron_id)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""`thread.extensions[name]` channel against the integration API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from .conftest import ASSISTANT_ID
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_EXPECTED_PRE_INTERRUPT_STEPS = [
|
||||
"stream_message",
|
||||
"stream_message",
|
||||
"call_tool",
|
||||
"call_tool",
|
||||
"ask_human",
|
||||
]
|
||||
|
||||
|
||||
async def test_extensions_async(async_threads) -> None:
|
||||
threads, _ = async_threads
|
||||
async with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
await thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
events: list[dict] = []
|
||||
async for event in thread.extensions["progress"]:
|
||||
events.append(event)
|
||||
|
||||
# Iterator exits at the `ask_human` interrupt via `_signal_paused`,
|
||||
# so we capture exactly the pre-interrupt progress sequence.
|
||||
steps = [e.get("step") for e in events]
|
||||
assert steps == _EXPECTED_PRE_INTERRUPT_STEPS, (
|
||||
f"unexpected step sequence: {steps}"
|
||||
)
|
||||
|
||||
|
||||
def test_extensions_sync(sync_threads) -> None:
|
||||
threads, _ = sync_threads
|
||||
with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
events: list[dict] = []
|
||||
for event in thread.extensions["progress"]:
|
||||
events.append(event)
|
||||
|
||||
steps = [e.get("step") for e in events]
|
||||
assert steps == _EXPECTED_PRE_INTERRUPT_STEPS, (
|
||||
f"unexpected step sequence: {steps}"
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""`thread.agent.get_tree` and `thread.extensions` cache identity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from .conftest import ASSISTANT_ID
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def test_get_tree_and_extensions_cache_async(async_threads) -> None:
|
||||
threads, _ = async_threads
|
||||
async with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
tree = await thread.agent.get_tree()
|
||||
assert tree, "expected non-empty tree"
|
||||
node_ids = [n["id"] for n in tree.get("nodes", [])]
|
||||
assert "stream_message" in node_ids
|
||||
assert "ask_human" in node_ids
|
||||
|
||||
tree_xray = await thread.agent.get_tree(xray=True)
|
||||
assert set(tree_xray) >= {"nodes", "edges"}
|
||||
|
||||
a = thread.extensions["progress"]
|
||||
b = thread.extensions["progress"]
|
||||
assert a is b, "expected cached projection instance on repeated access"
|
||||
|
||||
|
||||
def test_get_tree_and_extensions_cache_sync(sync_threads) -> None:
|
||||
threads, _ = sync_threads
|
||||
with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
tree = thread.agent.get_tree()
|
||||
assert tree, "expected non-empty tree"
|
||||
node_ids = [n["id"] for n in tree.get("nodes", [])]
|
||||
assert "stream_message" in node_ids
|
||||
assert "ask_human" in node_ids
|
||||
|
||||
tree_xray = thread.agent.get_tree(xray=True)
|
||||
assert set(tree_xray) >= {"nodes", "edges"}
|
||||
|
||||
a = thread.extensions["progress"]
|
||||
b = thread.extensions["progress"]
|
||||
assert a is b, "expected cached projection instance on repeated access"
|
||||
@@ -0,0 +1,45 @@
|
||||
"""`thread.interrupted` / `thread.interrupts` / `run.respond` lifecycle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from .conftest import ASSISTANT_ID, EXPECTED_TERMINAL_ITEMS
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def test_lifecycle_async(async_threads) -> None:
|
||||
threads, _ = async_threads
|
||||
async with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
await thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
async for _snap in thread.values:
|
||||
if thread.interrupted:
|
||||
break
|
||||
|
||||
assert thread.interrupted, "expected an interrupt"
|
||||
assert thread.interrupts, "expected interrupts list to be populated"
|
||||
|
||||
await thread.run.respond("yes")
|
||||
final = await thread.output
|
||||
assert "asked" in final.get("items", [])
|
||||
assert final.get("items") == EXPECTED_TERMINAL_ITEMS
|
||||
|
||||
|
||||
def test_lifecycle_sync(sync_threads) -> None:
|
||||
threads, _ = sync_threads
|
||||
with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
for _snap in thread.values:
|
||||
if thread.interrupted:
|
||||
break
|
||||
|
||||
assert thread.interrupted, "expected an interrupt"
|
||||
assert thread.interrupts, "expected interrupts list to be populated"
|
||||
|
||||
thread.run.respond("yes")
|
||||
final = thread.output
|
||||
assert "asked" in final.get("items", [])
|
||||
assert final.get("items") == EXPECTED_TERMINAL_ITEMS
|
||||
@@ -0,0 +1,37 @@
|
||||
"""`thread.messages` projection (outer iter + inner `.text` token deltas)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from .conftest import ASSISTANT_ID
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def test_messages_async(async_threads) -> None:
|
||||
threads, _ = async_threads
|
||||
async with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
await thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
# Drain the outer iterator first; iterating each inner `stream.text`
|
||||
# while the outer is suspended deadlocks.
|
||||
streams = [s async for s in thread.messages]
|
||||
assert streams, "expected at least one streamed message"
|
||||
|
||||
for stream in streams:
|
||||
text = "".join([t async for t in stream.text])
|
||||
assert text == "Hello, world!", f"unexpected message text: {text!r}"
|
||||
|
||||
|
||||
def test_messages_sync(sync_threads) -> None:
|
||||
threads, _ = sync_threads
|
||||
with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
streams = list(thread.messages)
|
||||
assert streams, "expected at least one streamed message"
|
||||
|
||||
for stream in streams:
|
||||
text = "".join(list(stream.text))
|
||||
assert text == "Hello, world!", f"unexpected message text: {text!r}"
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Mid-iteration SSE close + terminal-state recovery via REST."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import functools
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from .conftest import ASSISTANT_ID, EXPECTED_TERMINAL_ITEMS
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_INTERRUPT_WAIT_SECONDS = 5.0
|
||||
|
||||
|
||||
def _instrument_dedup_async(controller: Any) -> dict[str, int]:
|
||||
"""Wrap `_dedup_iter` so duplicate event_ids are counted (no asserts here)."""
|
||||
counter = {"drops": 0, "yields": 0}
|
||||
original = controller._dedup_iter.__func__ # type: ignore[attr-defined]
|
||||
|
||||
@functools.wraps(original)
|
||||
async def _counted(self, source): # type: ignore[no-untyped-def]
|
||||
async for event in source:
|
||||
event_id = event.get("event_id")
|
||||
if event_id is not None:
|
||||
if event_id in self._seen_event_ids:
|
||||
counter["drops"] += 1
|
||||
continue
|
||||
self._seen_event_ids.add(event_id)
|
||||
counter["yields"] += 1
|
||||
yield event
|
||||
|
||||
# ty doesn't see through `@functools.wraps` to the descriptor protocol; this
|
||||
# is the canonical method-binding pattern.
|
||||
controller._dedup_iter = _counted.__get__(controller, type(controller)) # ty: ignore[unresolved-attribute]
|
||||
return counter
|
||||
|
||||
|
||||
def _instrument_dedup_sync(controller: Any) -> dict[str, int]:
|
||||
counter = {"drops": 0, "yields": 0}
|
||||
original = controller._dedup_iter.__func__ # type: ignore[attr-defined]
|
||||
|
||||
@functools.wraps(original)
|
||||
def _counted(self, source): # type: ignore[no-untyped-def]
|
||||
for event in source:
|
||||
event_id = event.get("event_id")
|
||||
if event_id is not None:
|
||||
if event_id in self._seen_event_ids:
|
||||
counter["drops"] += 1
|
||||
continue
|
||||
self._seen_event_ids.add(event_id)
|
||||
counter["yields"] += 1
|
||||
yield event
|
||||
|
||||
# ty doesn't see through `@functools.wraps` to the descriptor protocol; this
|
||||
# is the canonical method-binding pattern.
|
||||
controller._dedup_iter = _counted.__get__(controller, type(controller)) # ty: ignore[unresolved-attribute]
|
||||
return counter
|
||||
|
||||
|
||||
async def test_close_mid_iteration_async(async_threads) -> None:
|
||||
"""A client-initiated SSE close mid-iteration must not corrupt durable state."""
|
||||
threads, _ = async_threads
|
||||
async with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
counter = _instrument_dedup_async(thread)
|
||||
await thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
snapshots: list[dict] = []
|
||||
dropped = False
|
||||
iteration_error: BaseException | None = None
|
||||
try:
|
||||
async for snap in thread.values:
|
||||
snapshots.append(snap)
|
||||
if not dropped and thread._shared_stream is not None:
|
||||
await thread._shared_stream.close()
|
||||
dropped = True
|
||||
except BaseException as err:
|
||||
iteration_error = err
|
||||
|
||||
# The values iterator exits via the None sentinel pushed by `close()`,
|
||||
# which can land before the lifecycle watcher observes `input.requested`.
|
||||
# Poll briefly so the interrupt has a chance to arrive on its own SSE
|
||||
# before we ask for terminal state.
|
||||
deadline = asyncio.get_running_loop().time() + _INTERRUPT_WAIT_SECONDS
|
||||
while not thread.interrupted and asyncio.get_running_loop().time() < deadline:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
if thread.interrupted:
|
||||
with contextlib.suppress(Exception):
|
||||
await thread.run.respond("yes")
|
||||
|
||||
final = await thread.output.with_timeout(_INTERRUPT_WAIT_SECONDS)
|
||||
assert dropped, "expected to drop the shared stream during iteration"
|
||||
assert snapshots, "expected at least one snapshot before the drop"
|
||||
assert iteration_error is None, (
|
||||
f"values iterator raised on stream close: {iteration_error!r}"
|
||||
)
|
||||
assert final.get("items") == EXPECTED_TERMINAL_ITEMS
|
||||
# Graceful close should not produce duplicate event_ids since the SDK
|
||||
# only reconnects (via `since=<cursor>`) on a non-cancelled `shared.done`.
|
||||
assert counter["drops"] == 0, (
|
||||
f"unexpected dedup activity (drops={counter['drops']}); "
|
||||
"no rotation occurred so no overlap was expected"
|
||||
)
|
||||
|
||||
|
||||
def test_close_mid_iteration_sync(sync_threads) -> None:
|
||||
threads, _ = sync_threads
|
||||
with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
controller = thread._controller
|
||||
counter = _instrument_dedup_sync(controller)
|
||||
thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
snapshots: list[dict] = []
|
||||
dropped = False
|
||||
iteration_error: BaseException | None = None
|
||||
try:
|
||||
for snap in thread.values:
|
||||
snapshots.append(snap)
|
||||
if (
|
||||
not dropped
|
||||
and controller is not None
|
||||
and controller._shared_stream is not None
|
||||
):
|
||||
controller._shared_stream.close()
|
||||
dropped = True
|
||||
except BaseException as err:
|
||||
iteration_error = err
|
||||
|
||||
deadline = time.monotonic() + _INTERRUPT_WAIT_SECONDS
|
||||
while not thread.interrupted and time.monotonic() < deadline:
|
||||
time.sleep(0.1)
|
||||
|
||||
if thread.interrupted:
|
||||
with contextlib.suppress(Exception):
|
||||
thread.run.respond("yes")
|
||||
|
||||
final = thread.output
|
||||
assert dropped, "expected to drop the shared stream during iteration"
|
||||
assert snapshots, "expected at least one snapshot before the drop"
|
||||
assert iteration_error is None, (
|
||||
f"values iterator raised on stream close: {iteration_error!r}"
|
||||
)
|
||||
assert final.get("items") == EXPECTED_TERMINAL_ITEMS
|
||||
assert counter["drops"] == 0, (
|
||||
f"unexpected dedup activity (drops={counter['drops']})"
|
||||
)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""`RunsClient` non-streaming surface.
|
||||
|
||||
`cancel` is covered in `test_cancel.py`. This file covers create / get /
|
||||
list / wait. The canonical `agent` graph interrupts at `ask_human`, so a
|
||||
plain `runs.create` lands in the `interrupted` state. We use
|
||||
`interrupt_before=["ask_human"]` so the run pauses before the interrupting
|
||||
node and reaches a deterministic non-success terminal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from .conftest import ASSISTANT_ID
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def _async_runs(raw):
|
||||
from langgraph_sdk._async.http import HttpClient
|
||||
from langgraph_sdk._async.runs import RunsClient
|
||||
|
||||
return RunsClient(HttpClient(raw))
|
||||
|
||||
|
||||
def _sync_runs(raw):
|
||||
from langgraph_sdk._sync.http import SyncHttpClient
|
||||
from langgraph_sdk._sync.runs import SyncRunsClient
|
||||
|
||||
return SyncRunsClient(SyncHttpClient(raw))
|
||||
|
||||
|
||||
async def test_runs_create_get_list_async(async_threads) -> None:
|
||||
threads, raw = async_threads
|
||||
runs = _async_runs(raw)
|
||||
thread = await threads.create(
|
||||
metadata={"suite": "integration", "label": "runs-async"}
|
||||
)
|
||||
tid = thread["thread_id"]
|
||||
try:
|
||||
created = await runs.create(
|
||||
tid,
|
||||
ASSISTANT_ID,
|
||||
input={"messages": [], "value": "init", "items": []},
|
||||
)
|
||||
run_id = created["run_id"]
|
||||
assert created["thread_id"] == tid
|
||||
|
||||
fetched = await runs.get(tid, run_id)
|
||||
assert fetched["run_id"] == run_id
|
||||
|
||||
listed = await runs.list(tid, limit=10)
|
||||
assert any(r["run_id"] == run_id for r in listed)
|
||||
finally:
|
||||
await threads.delete(tid)
|
||||
|
||||
|
||||
def test_runs_create_get_list_sync(sync_threads) -> None:
|
||||
threads, raw = sync_threads
|
||||
runs = _sync_runs(raw)
|
||||
thread = threads.create(metadata={"suite": "integration", "label": "runs-sync"})
|
||||
tid = thread["thread_id"]
|
||||
try:
|
||||
created = runs.create(
|
||||
tid,
|
||||
ASSISTANT_ID,
|
||||
input={"messages": [], "value": "init", "items": []},
|
||||
)
|
||||
run_id = created["run_id"]
|
||||
assert created["thread_id"] == tid
|
||||
|
||||
fetched = runs.get(tid, run_id)
|
||||
assert fetched["run_id"] == run_id
|
||||
|
||||
listed = runs.list(tid, limit=10)
|
||||
assert any(r["run_id"] == run_id for r in listed)
|
||||
finally:
|
||||
threads.delete(tid)
|
||||
|
||||
|
||||
async def test_runs_wait_async(async_threads) -> None:
|
||||
"""`wait` blocks until the run reaches a terminal state and returns its values."""
|
||||
threads, raw = async_threads
|
||||
runs = _async_runs(raw)
|
||||
thread = await threads.create(
|
||||
metadata={"suite": "integration", "label": "wait-async"}
|
||||
)
|
||||
tid = thread["thread_id"]
|
||||
try:
|
||||
# `interrupt_before` makes the run pause before `ask_human` rather
|
||||
# than running into the dynamic `interrupt(...)` inside it; the run
|
||||
# ends up in `interrupted` status with a deterministic terminal.
|
||||
result = await runs.wait(
|
||||
tid,
|
||||
ASSISTANT_ID,
|
||||
input={"messages": [], "value": "init", "items": []},
|
||||
interrupt_before=["ask_human"],
|
||||
)
|
||||
# The result is the terminal `values` payload for this run.
|
||||
assert isinstance(result, dict)
|
||||
assert "items" in result
|
||||
assert "streamed" in result["items"]
|
||||
assert "tool" in result["items"]
|
||||
finally:
|
||||
await threads.delete(tid)
|
||||
|
||||
|
||||
def test_runs_wait_sync(sync_threads) -> None:
|
||||
threads, raw = sync_threads
|
||||
runs = _sync_runs(raw)
|
||||
thread = threads.create(metadata={"suite": "integration", "label": "wait-sync"})
|
||||
tid = thread["thread_id"]
|
||||
try:
|
||||
result = runs.wait(
|
||||
tid,
|
||||
ASSISTANT_ID,
|
||||
input={"messages": [], "value": "init", "items": []},
|
||||
interrupt_before=["ask_human"],
|
||||
)
|
||||
assert isinstance(result, dict)
|
||||
assert "items" in result
|
||||
assert "streamed" in result["items"]
|
||||
assert "tool" in result["items"]
|
||||
finally:
|
||||
threads.delete(tid)
|
||||
@@ -0,0 +1,123 @@
|
||||
"""`StoreClient` against the integration API.
|
||||
|
||||
Covers the put / get / search / list_namespaces / delete round-trip
|
||||
under a unique-per-test namespace so concurrent runs don't collide.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def _async_store(raw):
|
||||
from langgraph_sdk._async.http import HttpClient
|
||||
from langgraph_sdk._async.store import StoreClient
|
||||
|
||||
return StoreClient(HttpClient(raw))
|
||||
|
||||
|
||||
def _sync_store(raw):
|
||||
from langgraph_sdk._sync.http import SyncHttpClient
|
||||
from langgraph_sdk._sync.store import SyncStoreClient
|
||||
|
||||
return SyncStoreClient(SyncHttpClient(raw))
|
||||
|
||||
|
||||
def _unique_namespace(label: str) -> list[str]:
|
||||
return ["test-integration", label, uuid.uuid4().hex[:12]]
|
||||
|
||||
|
||||
async def test_store_put_get_delete_async(async_threads) -> None:
|
||||
_, raw = async_threads
|
||||
store = _async_store(raw)
|
||||
ns = _unique_namespace("put-async")
|
||||
key = "doc-1"
|
||||
payload = {"title": "Hello", "body": "World"}
|
||||
|
||||
await store.put_item(ns, key=key, value=payload)
|
||||
try:
|
||||
fetched = await store.get_item(ns, key=key)
|
||||
assert fetched["value"] == payload
|
||||
assert fetched["namespace"] == ns
|
||||
assert fetched["key"] == key
|
||||
finally:
|
||||
await store.delete_item(ns, key=key)
|
||||
|
||||
missing = await store.get_item(ns, key=key)
|
||||
assert missing is None
|
||||
|
||||
|
||||
def test_store_put_get_delete_sync(sync_threads) -> None:
|
||||
_, raw = sync_threads
|
||||
store = _sync_store(raw)
|
||||
ns = _unique_namespace("put-sync")
|
||||
key = "doc-1"
|
||||
payload = {"title": "Hello", "body": "World"}
|
||||
|
||||
store.put_item(ns, key=key, value=payload)
|
||||
try:
|
||||
fetched = store.get_item(ns, key=key)
|
||||
assert fetched["value"] == payload
|
||||
assert fetched["namespace"] == ns
|
||||
assert fetched["key"] == key
|
||||
finally:
|
||||
store.delete_item(ns, key=key)
|
||||
|
||||
missing = store.get_item(ns, key=key)
|
||||
assert missing is None
|
||||
|
||||
|
||||
async def test_store_search_and_list_namespaces_async(async_threads) -> None:
|
||||
_, raw = async_threads
|
||||
store = _async_store(raw)
|
||||
ns = _unique_namespace("search-async")
|
||||
await store.put_item(ns, key="a", value={"kind": "alpha"})
|
||||
await store.put_item(ns, key="b", value={"kind": "beta"})
|
||||
try:
|
||||
search = await store.search_items(ns, limit=10)
|
||||
items = search.get("items", search) if isinstance(search, dict) else search
|
||||
keys = sorted(i["key"] for i in items)
|
||||
assert keys == ["a", "b"]
|
||||
|
||||
namespaces_result = await store.list_namespaces(prefix=ns[:1], limit=100)
|
||||
namespaces = (
|
||||
namespaces_result.get("namespaces", namespaces_result)
|
||||
if isinstance(namespaces_result, dict)
|
||||
else namespaces_result
|
||||
)
|
||||
assert any(list(found) == ns for found in namespaces), (
|
||||
f"namespace {ns!r} not in list_namespaces result"
|
||||
)
|
||||
finally:
|
||||
await store.delete_item(ns, key="a")
|
||||
await store.delete_item(ns, key="b")
|
||||
|
||||
|
||||
def test_store_search_and_list_namespaces_sync(sync_threads) -> None:
|
||||
_, raw = sync_threads
|
||||
store = _sync_store(raw)
|
||||
ns = _unique_namespace("search-sync")
|
||||
store.put_item(ns, key="a", value={"kind": "alpha"})
|
||||
store.put_item(ns, key="b", value={"kind": "beta"})
|
||||
try:
|
||||
search = store.search_items(ns, limit=10)
|
||||
items = search.get("items", search) if isinstance(search, dict) else search
|
||||
keys = sorted(i["key"] for i in items)
|
||||
assert keys == ["a", "b"]
|
||||
|
||||
namespaces_result = store.list_namespaces(prefix=ns[:1], limit=100)
|
||||
namespaces = (
|
||||
namespaces_result.get("namespaces", namespaces_result)
|
||||
if isinstance(namespaces_result, dict)
|
||||
else namespaces_result
|
||||
)
|
||||
assert any(list(found) == ns for found in namespaces), (
|
||||
f"namespace {ns!r} not in list_namespaces result"
|
||||
)
|
||||
finally:
|
||||
store.delete_item(ns, key="a")
|
||||
store.delete_item(ns, key="b")
|
||||
@@ -0,0 +1,52 @@
|
||||
"""`thread.subgraphs` discovery against `agent` and `deep_agent`.
|
||||
|
||||
`deep_agent` uses `FakeMessagesListChatModel` for both supervisor and
|
||||
researcher, so this suite is hermetic (no LLM API key required).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from .conftest import ASSISTANT_ID, DEEP_AGENT_ASSISTANT_ID
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def test_subgraphs_agent_async(async_threads) -> None:
|
||||
"""Plain nested `StateGraph.invoke` does not produce a scoped child handle."""
|
||||
threads, _ = async_threads
|
||||
async with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
await thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
handles = [h async for h in thread.subgraphs]
|
||||
# Documented behavior: plain nested invokes do not show up as scoped
|
||||
# child handles; the canonical signal is `create_deep_agent`.
|
||||
assert handles == []
|
||||
|
||||
|
||||
def test_subgraphs_agent_sync(sync_threads) -> None:
|
||||
threads, _ = sync_threads
|
||||
with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
handles = list(thread.subgraphs)
|
||||
assert handles == []
|
||||
|
||||
|
||||
async def test_subgraphs_deep_agent_async(async_threads) -> None:
|
||||
threads, _ = async_threads
|
||||
async with threads.stream(assistant_id=DEEP_AGENT_ASSISTANT_ID) as thread:
|
||||
await thread.run.start(
|
||||
input={"messages": [{"role": "user", "content": "research the v3 spec"}]},
|
||||
)
|
||||
handles = [h async for h in thread.subgraphs]
|
||||
assert handles, "deep_agent should produce at least one direct-child handle"
|
||||
|
||||
|
||||
def test_subgraphs_deep_agent_sync(sync_threads) -> None:
|
||||
threads, _ = sync_threads
|
||||
with threads.stream(assistant_id=DEEP_AGENT_ASSISTANT_ID) as thread:
|
||||
thread.run.start(
|
||||
input={"messages": [{"role": "user", "content": "research the v3 spec"}]},
|
||||
)
|
||||
handles = list(thread.subgraphs)
|
||||
assert handles, "deep_agent should produce at least one direct-child handle"
|
||||
@@ -0,0 +1,124 @@
|
||||
"""`ThreadsClient` non-streaming CRUD surface.
|
||||
|
||||
`stream` and `update_state` are covered elsewhere; this file covers
|
||||
create / get / delete / search / copy / get_history.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from .conftest import ASSISTANT_ID
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def test_threads_create_get_delete_async(async_threads) -> None:
|
||||
threads, _ = async_threads
|
||||
created = await threads.create(
|
||||
metadata={"suite": "integration", "label": "crud-async"}
|
||||
)
|
||||
tid = created["thread_id"]
|
||||
try:
|
||||
fetched = await threads.get(tid)
|
||||
assert fetched["thread_id"] == tid
|
||||
assert fetched["metadata"]["label"] == "crud-async"
|
||||
finally:
|
||||
await threads.delete(tid)
|
||||
|
||||
|
||||
def test_threads_create_get_delete_sync(sync_threads) -> None:
|
||||
threads, _ = sync_threads
|
||||
created = threads.create(metadata={"suite": "integration", "label": "crud-sync"})
|
||||
tid = created["thread_id"]
|
||||
try:
|
||||
fetched = threads.get(tid)
|
||||
assert fetched["thread_id"] == tid
|
||||
assert fetched["metadata"]["label"] == "crud-sync"
|
||||
finally:
|
||||
threads.delete(tid)
|
||||
|
||||
|
||||
async def test_threads_search_async(async_threads) -> None:
|
||||
threads, _ = async_threads
|
||||
created = await threads.create(
|
||||
metadata={"suite": "integration", "label": "search-async"}
|
||||
)
|
||||
tid = created["thread_id"]
|
||||
try:
|
||||
results = await threads.search(metadata={"label": "search-async"}, limit=10)
|
||||
assert any(t["thread_id"] == tid for t in results)
|
||||
finally:
|
||||
await threads.delete(tid)
|
||||
|
||||
|
||||
def test_threads_search_sync(sync_threads) -> None:
|
||||
threads, _ = sync_threads
|
||||
created = threads.create(metadata={"suite": "integration", "label": "search-sync"})
|
||||
tid = created["thread_id"]
|
||||
try:
|
||||
results = threads.search(metadata={"label": "search-sync"}, limit=10)
|
||||
assert any(t["thread_id"] == tid for t in results)
|
||||
finally:
|
||||
threads.delete(tid)
|
||||
|
||||
|
||||
async def test_threads_copy_async(async_threads) -> None:
|
||||
threads, _ = async_threads
|
||||
src = await threads.create(
|
||||
metadata={"suite": "integration", "label": "copy-async-src"}
|
||||
)
|
||||
src_id = src["thread_id"]
|
||||
try:
|
||||
copied = await threads.copy(src_id)
|
||||
copy_id = copied["thread_id"]
|
||||
try:
|
||||
assert copy_id != src_id
|
||||
finally:
|
||||
await threads.delete(copy_id)
|
||||
finally:
|
||||
await threads.delete(src_id)
|
||||
|
||||
|
||||
def test_threads_copy_sync(sync_threads) -> None:
|
||||
threads, _ = sync_threads
|
||||
src = threads.create(metadata={"suite": "integration", "label": "copy-sync-src"})
|
||||
src_id = src["thread_id"]
|
||||
try:
|
||||
copied = threads.copy(src_id)
|
||||
copy_id = copied["thread_id"]
|
||||
try:
|
||||
assert copy_id != src_id
|
||||
finally:
|
||||
threads.delete(copy_id)
|
||||
finally:
|
||||
threads.delete(src_id)
|
||||
|
||||
|
||||
async def test_threads_history_after_run_async(async_threads) -> None:
|
||||
"""A completed run produces at least one checkpoint in history."""
|
||||
threads, _ = async_threads
|
||||
async with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
await thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
async for _ in thread.values:
|
||||
if thread.interrupted:
|
||||
break
|
||||
if thread.interrupted:
|
||||
await thread.run.respond("yes")
|
||||
await thread.output
|
||||
history = await threads.get_history(thread.thread_id, limit=20)
|
||||
assert history, "expected at least one checkpoint after a completed run"
|
||||
|
||||
|
||||
def test_threads_history_after_run_sync(sync_threads) -> None:
|
||||
threads, _ = sync_threads
|
||||
with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
for _ in thread.values:
|
||||
if thread.interrupted:
|
||||
break
|
||||
if thread.interrupted:
|
||||
thread.run.respond("yes")
|
||||
_ = thread.output # force terminal-state fetch; value unused
|
||||
history = threads.get_history(thread.thread_id, limit=20)
|
||||
assert history, "expected at least one checkpoint after a completed run"
|
||||
@@ -0,0 +1,51 @@
|
||||
"""`thread.tool_calls` against the `tools_agent` graph."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from .conftest import TOOLS_ASSISTANT_ID
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def test_tools_async(async_threads) -> None:
|
||||
threads, _ = async_threads
|
||||
async with threads.stream(assistant_id=TOOLS_ASSISTANT_ID) as thread:
|
||||
await thread.run.start(
|
||||
input={"messages": [{"role": "human", "content": "search for v3"}]}
|
||||
)
|
||||
|
||||
# Drain the outer iterator first; iterating each handle's `.deltas`
|
||||
# while the outer is suspended deadlocks.
|
||||
handles = [h async for h in thread.tool_calls]
|
||||
assert handles, "expected at least one tool call handle"
|
||||
assert any(h.name == "search" for h in handles), "expected `search` tool call"
|
||||
|
||||
for handle in handles:
|
||||
deltas = "".join([d async for d in handle.deltas])
|
||||
output = await handle.output
|
||||
assert output.get("status") == "success", (
|
||||
f"tool {handle.name} non-success output: {output!r}"
|
||||
)
|
||||
# The `tools_agent` fake model returns the tool call args pre-built,
|
||||
# so the streamed args buffer is empty by design.
|
||||
assert isinstance(deltas, str)
|
||||
|
||||
|
||||
def test_tools_sync(sync_threads) -> None:
|
||||
threads, _ = sync_threads
|
||||
with threads.stream(assistant_id=TOOLS_ASSISTANT_ID) as thread:
|
||||
thread.run.start(
|
||||
input={"messages": [{"role": "human", "content": "search for v3"}]}
|
||||
)
|
||||
|
||||
handles = list(thread.tool_calls)
|
||||
assert handles, "expected at least one tool call handle"
|
||||
assert any(h.name == "search" for h in handles), "expected `search` tool call"
|
||||
|
||||
for handle in handles:
|
||||
deltas = "".join(list(handle.deltas))
|
||||
output = handle.output
|
||||
assert output.get("status") == "success"
|
||||
assert isinstance(deltas, str)
|
||||
@@ -0,0 +1,103 @@
|
||||
"""`threads.update_state(...)` during an interrupt persists the mutation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from langgraph_sdk.errors import ConflictError
|
||||
|
||||
from .conftest import ASSISTANT_ID
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_PATCHED_VALUE = "patched"
|
||||
_UPDATE_STATE_RETRY_BUDGET = 5.0
|
||||
|
||||
|
||||
async def _update_state_with_retry_async(threads, thread_id, values) -> None:
|
||||
"""`thread.interrupted` flips before the server commits the run row; retry briefly."""
|
||||
delay = 0.05
|
||||
deadline = asyncio.get_running_loop().time() + _UPDATE_STATE_RETRY_BUDGET
|
||||
last_err: Exception | None = None
|
||||
while asyncio.get_running_loop().time() < deadline:
|
||||
try:
|
||||
await threads.update_state(thread_id, values)
|
||||
return
|
||||
except ConflictError as err:
|
||||
last_err = err
|
||||
await asyncio.sleep(delay)
|
||||
delay = min(delay * 2, 0.5)
|
||||
raise AssertionError(
|
||||
f"update_state never accepted within {_UPDATE_STATE_RETRY_BUDGET}s: {last_err!r}"
|
||||
)
|
||||
|
||||
|
||||
def _update_state_with_retry_sync(threads, thread_id, values) -> None:
|
||||
delay = 0.05
|
||||
deadline = time.monotonic() + _UPDATE_STATE_RETRY_BUDGET
|
||||
last_err: Exception | None = None
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
threads.update_state(thread_id, values)
|
||||
return
|
||||
except ConflictError as err:
|
||||
last_err = err
|
||||
time.sleep(delay)
|
||||
delay = min(delay * 2, 0.5)
|
||||
raise AssertionError(
|
||||
f"update_state never accepted within {_UPDATE_STATE_RETRY_BUDGET}s: {last_err!r}"
|
||||
)
|
||||
|
||||
|
||||
async def test_update_state_async(async_threads) -> None:
|
||||
threads, _ = async_threads
|
||||
async with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
await thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
async for _ in thread.values:
|
||||
if thread.interrupted:
|
||||
break
|
||||
assert thread.interrupted, "expected interrupt before update_state"
|
||||
|
||||
pre_state = await threads.get_state(thread.thread_id)
|
||||
pre_value = (pre_state.get("values") or {}).get("value")
|
||||
# `stream_message` overwrites value="init" with "x" before the interrupt.
|
||||
assert pre_value == "x", f"unexpected pre-update value: {pre_value!r}"
|
||||
|
||||
await _update_state_with_retry_async(
|
||||
threads, thread.thread_id, {"value": _PATCHED_VALUE}
|
||||
)
|
||||
|
||||
post_state = await threads.get_state(thread.thread_id)
|
||||
post_value = (post_state.get("values") or {}).get("value")
|
||||
assert post_value == _PATCHED_VALUE, (
|
||||
f"update_state did not persist: value={post_value!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_update_state_sync(sync_threads) -> None:
|
||||
threads, _ = sync_threads
|
||||
with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
for _ in thread.values:
|
||||
if thread.interrupted:
|
||||
break
|
||||
assert thread.interrupted, "expected interrupt before update_state"
|
||||
|
||||
pre_state = threads.get_state(thread.thread_id)
|
||||
pre_value = (pre_state.get("values") or {}).get("value")
|
||||
assert pre_value == "x", f"unexpected pre-update value: {pre_value!r}"
|
||||
|
||||
_update_state_with_retry_sync(
|
||||
threads, thread.thread_id, {"value": _PATCHED_VALUE}
|
||||
)
|
||||
|
||||
post_state = threads.get_state(thread.thread_id)
|
||||
post_value = (post_state.get("values") or {}).get("value")
|
||||
assert post_value == _PATCHED_VALUE, (
|
||||
f"update_state did not persist: value={post_value!r}"
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""`thread.values` against the integration API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from .conftest import ASSISTANT_ID, EXPECTED_TERMINAL_ITEMS
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def test_values_async(async_threads) -> None:
|
||||
threads, _ = async_threads
|
||||
async with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
await thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
# `_signal_paused` pushes None to the values subscription on the
|
||||
# rising edge of `interrupted`, so this loop exits at the interrupt.
|
||||
snapshots: list[dict] = []
|
||||
async for snap in thread.values:
|
||||
snapshots.append(snap)
|
||||
|
||||
assert thread.interrupted, f"expected interrupt; got {len(snapshots)} snapshots"
|
||||
await thread.run.respond("yes")
|
||||
|
||||
final = await thread.output
|
||||
assert final.get("items") == EXPECTED_TERMINAL_ITEMS
|
||||
assert snapshots, "expected pre-interrupt snapshots"
|
||||
|
||||
|
||||
def test_values_sync(sync_threads) -> None:
|
||||
threads, _ = sync_threads
|
||||
with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
snapshots: list[dict] = []
|
||||
for snap in thread.values:
|
||||
snapshots.append(snap)
|
||||
|
||||
assert thread.interrupted, f"expected interrupt; got {len(snapshots)} snapshots"
|
||||
thread.run.respond("yes")
|
||||
|
||||
final = thread.output
|
||||
assert final.get("items") == EXPECTED_TERMINAL_ITEMS
|
||||
assert snapshots, "expected pre-interrupt snapshots"
|
||||
@@ -0,0 +1,51 @@
|
||||
"""WebSocket transport against the integration API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from .conftest import ASSISTANT_ID, EXPECTED_TERMINAL_ITEMS
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def test_websocket_async(async_threads) -> None:
|
||||
threads, _ = async_threads
|
||||
async with threads.stream(
|
||||
assistant_id=ASSISTANT_ID, transport="websocket"
|
||||
) as thread:
|
||||
from langgraph_sdk.stream.transport import ProtocolWebSocketTransport
|
||||
|
||||
assert isinstance(thread._transport, ProtocolWebSocketTransport)
|
||||
|
||||
await thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
snapshots: list[dict] = []
|
||||
async for snap in thread.values:
|
||||
snapshots.append(snap)
|
||||
|
||||
assert thread.interrupted, "expected interrupt over ws"
|
||||
await thread.run.respond("yes")
|
||||
|
||||
final = await thread.output
|
||||
assert final.get("items") == EXPECTED_TERMINAL_ITEMS
|
||||
|
||||
|
||||
def test_websocket_sync(sync_threads) -> None:
|
||||
threads, _ = sync_threads
|
||||
with threads.stream(assistant_id=ASSISTANT_ID, transport="websocket") as thread:
|
||||
from langgraph_sdk.stream.transport import SyncProtocolWebSocketTransport
|
||||
|
||||
assert isinstance(thread._transport, SyncProtocolWebSocketTransport)
|
||||
|
||||
thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
snapshots: list[dict] = []
|
||||
for snap in thread.values:
|
||||
snapshots.append(snap)
|
||||
|
||||
assert thread.interrupted, "expected interrupt over ws"
|
||||
thread.run.respond("yes")
|
||||
|
||||
final = thread.output
|
||||
assert final.get("items") == EXPECTED_TERMINAL_ITEMS
|
||||
@@ -21,24 +21,43 @@ def _base(seq: int, method: str, namespace: list[str], data: Any) -> dict[str, A
|
||||
}
|
||||
|
||||
|
||||
def _normalize_lifecycle_data(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Map test-fixture shorthand to the wire shape `langgraph-api` emits.
|
||||
|
||||
The server emits the lifecycle status as `data.event` with values
|
||||
`running` / `completed` / `failed` / `interrupted` (see
|
||||
`api/langgraph_api/event_streaming/event_normalizers.py::to_lifecycle_status`).
|
||||
The fixture historically accepted `phase=` and the legacy `"errored"`
|
||||
value; translate them so tests exercise the real wire format
|
||||
without touching every call site.
|
||||
"""
|
||||
normalized = dict(data)
|
||||
if "phase" in normalized and "event" not in normalized:
|
||||
normalized["event"] = normalized.pop("phase")
|
||||
if normalized.get("event") == "errored":
|
||||
normalized["event"] = "failed"
|
||||
return normalized
|
||||
|
||||
|
||||
def lifecycle_event(
|
||||
seq: int = 0, namespace: list[str] | None = None, **data: Any
|
||||
) -> dict[str, Any]:
|
||||
return _base(seq, "lifecycle", namespace or [], data or {"phase": "started"})
|
||||
payload = _normalize_lifecycle_data(data) if data else {"event": "started"}
|
||||
return _base(seq, "lifecycle", namespace or [], payload)
|
||||
|
||||
|
||||
def lifecycle_started_event(
|
||||
seq: int = 0, namespace: list[str] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Lifecycle event with `phase="started"`."""
|
||||
return _base(seq, "lifecycle", namespace or [], {"phase": "started"})
|
||||
"""Lifecycle event with `event="started"`."""
|
||||
return _base(seq, "lifecycle", namespace or [], {"event": "started"})
|
||||
|
||||
|
||||
def lifecycle_completed_event(
|
||||
seq: int = 0, namespace: list[str] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Lifecycle event with `phase="completed"`."""
|
||||
return _base(seq, "lifecycle", namespace or [], {"phase": "completed"})
|
||||
"""Lifecycle event with `event="completed"`."""
|
||||
return _base(seq, "lifecycle", namespace or [], {"event": "completed"})
|
||||
|
||||
|
||||
def lifecycle_errored_event(
|
||||
@@ -46,10 +65,8 @@ def lifecycle_errored_event(
|
||||
namespace: list[str] | None = None,
|
||||
error: str = "run errored",
|
||||
) -> dict[str, Any]:
|
||||
"""Lifecycle event with `phase="errored"` and an error message."""
|
||||
return _base(
|
||||
seq, "lifecycle", namespace or [], {"phase": "errored", "error": error}
|
||||
)
|
||||
"""Lifecycle event with `event="failed"` and an error message."""
|
||||
return _base(seq, "lifecycle", namespace or [], {"event": "failed", "error": error})
|
||||
|
||||
|
||||
def values_event(
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""In-process ASGI fake of the v3 protocol endpoints.
|
||||
|
||||
Used by transport and thread-streaming tests. Mirrors the production endpoints
|
||||
just closely enough to validate the client:
|
||||
Mirrors the production endpoints just closely enough to validate the client:
|
||||
|
||||
- POST /threads/{thread_id}/commands
|
||||
- POST /threads/{thread_id}/stream/events
|
||||
- GET /threads/{thread_id}/state
|
||||
- GET /assistants/{assistant_id}/graph
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -92,6 +92,12 @@ class FakeServer:
|
||||
self._stream_scripts: list[_StreamScript] = []
|
||||
self._command_response: dict[str, Any] | None = None
|
||||
self.transport: httpx.MockTransport = httpx.MockTransport(self._handle_request)
|
||||
self.graph_response: dict[str, Any] = {
|
||||
"nodes": [{"id": "agent", "type": "runnable", "data": {"name": "agent"}}],
|
||||
"edges": [],
|
||||
}
|
||||
self.graph_request_params: list[dict[str, str]] = []
|
||||
self.graph_request_headers: list[dict[str, str]] = []
|
||||
|
||||
def script(
|
||||
self,
|
||||
@@ -116,6 +122,10 @@ class FakeServer:
|
||||
"""Set the command envelope returned by /commands."""
|
||||
self._command_response = dict(response)
|
||||
|
||||
def set_graph(self, graph: dict[str, Any]) -> None:
|
||||
"""Store the graph returned by GET /assistants/{assistant_id}/graph."""
|
||||
self.graph_response = dict(graph)
|
||||
|
||||
def set_state(
|
||||
self,
|
||||
values: dict[str, Any],
|
||||
@@ -169,6 +179,11 @@ class FakeServer:
|
||||
self.state_request_headers.append(dict(request.headers))
|
||||
return JSONResponse(self.state)
|
||||
|
||||
async def assistant_graph(request: Request) -> Response:
|
||||
self.graph_request_params.append(dict(request.query_params))
|
||||
self.graph_request_headers.append(dict(request.headers))
|
||||
return JSONResponse(self.graph_response)
|
||||
|
||||
return Starlette(
|
||||
routes=[
|
||||
Route("/threads/{thread_id}/commands", commands, methods=["POST"]),
|
||||
@@ -182,6 +197,11 @@ class FakeServer:
|
||||
thread_state,
|
||||
methods=["GET"],
|
||||
),
|
||||
Route(
|
||||
"/assistants/{assistant_id}/graph",
|
||||
assistant_graph,
|
||||
methods=["GET"],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -44,6 +44,12 @@ class SyncFakeServer:
|
||||
self.transport = httpx.MockTransport(self._handle)
|
||||
self._stream_scripts: list[SyncStreamScript] = []
|
||||
self._command_response: dict[str, Any] | None = None
|
||||
self.graph_response: dict[str, Any] = {
|
||||
"nodes": [{"id": "agent", "type": "runnable", "data": {"name": "agent"}}],
|
||||
"edges": [],
|
||||
}
|
||||
self.graph_request_params: list[dict[str, str]] = []
|
||||
self.graph_request_headers: list[dict[str, str]] = []
|
||||
|
||||
def script(
|
||||
self,
|
||||
@@ -60,6 +66,9 @@ class SyncFakeServer:
|
||||
self._stream_scripts = list(scripts)
|
||||
self.scripted_events = []
|
||||
|
||||
def set_graph(self, graph: dict[str, Any]) -> None:
|
||||
self.graph_response = dict(graph)
|
||||
|
||||
def script_command_response(self, response: dict[str, Any]) -> None:
|
||||
self._command_response = dict(response)
|
||||
|
||||
@@ -109,6 +118,10 @@ class SyncFakeServer:
|
||||
headers={"content-type": "text/event-stream"},
|
||||
stream=_SseByteStream(script),
|
||||
)
|
||||
if path.endswith("/graph") and "/assistants/" in path:
|
||||
self.graph_request_params.append(dict(request.url.params))
|
||||
self.graph_request_headers.append(dict(request.headers))
|
||||
return httpx.Response(200, json=self.graph_response)
|
||||
if path.endswith("/state"):
|
||||
self.state_request_count += 1
|
||||
self.state_request_headers.append(dict(request.headers))
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from langgraph_sdk._async.http import HttpClient
|
||||
from langgraph_sdk._async.threads import ThreadsClient
|
||||
from streaming._events import custom_event, lifecycle_completed_event
|
||||
from streaming._fake_server import FakeServer
|
||||
|
||||
|
||||
async def test_extension_projection_yields_matching_custom_payloads():
|
||||
fake = FakeServer()
|
||||
fake.script(
|
||||
[
|
||||
custom_event(seq=1, name="progress", step=1),
|
||||
custom_event(seq=2, name="metrics", tokens=12),
|
||||
custom_event(seq=3, name="progress", step=2),
|
||||
lifecycle_completed_event(seq=4),
|
||||
]
|
||||
)
|
||||
transport = httpx.ASGITransport(app=fake.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw:
|
||||
threads = ThreadsClient(HttpClient(raw))
|
||||
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
|
||||
await thread.run.start(input={})
|
||||
payloads = [payload async for payload in thread.extensions["progress"]]
|
||||
|
||||
assert payloads == [
|
||||
{"name": "progress", "step": 1},
|
||||
{"name": "progress", "step": 2},
|
||||
]
|
||||
assert any(
|
||||
"custom:progress" in body.get("channels", [])
|
||||
for body in fake.stream_request_bodies
|
||||
)
|
||||
|
||||
|
||||
async def test_extension_projection_supports_namespace_scope_on_subgraph_handle():
|
||||
fake = FakeServer()
|
||||
fake.script(
|
||||
[
|
||||
custom_event(seq=1, name="progress", namespace=["worker:abc"], step=1),
|
||||
custom_event(seq=2, name="progress", namespace=[], step=0),
|
||||
lifecycle_completed_event(seq=3),
|
||||
]
|
||||
)
|
||||
transport = httpx.ASGITransport(app=fake.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw:
|
||||
from langgraph_sdk._async.stream import ScopedStreamHandle
|
||||
|
||||
threads = ThreadsClient(HttpClient(raw))
|
||||
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
|
||||
await thread.run.start(input={})
|
||||
handle = ScopedStreamHandle(
|
||||
thread=thread,
|
||||
path=("worker:abc",),
|
||||
graph_name="worker",
|
||||
trigger_call_id=None,
|
||||
)
|
||||
payloads = [payload async for payload in handle.extensions["progress"]]
|
||||
|
||||
assert payloads == [{"name": "progress", "step": 1}]
|
||||
@@ -347,8 +347,12 @@ async def test_send_command_applied_through_seq_seeds_shared_stream_since():
|
||||
await thread.run.start(input={})
|
||||
_ = [event async for event in thread.subscribe(["values"])]
|
||||
|
||||
# The shared SSE filter is a union of subscription params plus
|
||||
# ``lifecycle`` (added by ``_compute_current_union`` so the fanout
|
||||
# consumer can detect root-terminal events for projection-iterator
|
||||
# termination). Match any request whose channels include ``values``.
|
||||
values_requests = [
|
||||
b for b in fake.stream_request_bodies if b.get("channels") == ["values"]
|
||||
b for b in fake.stream_request_bodies if "values" in (b.get("channels") or [])
|
||||
]
|
||||
assert len(values_requests) == 1
|
||||
assert values_requests[0]["since"] == 17
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from langgraph_sdk._sync.http import SyncHttpClient
|
||||
from langgraph_sdk._sync.threads import SyncThreadsClient
|
||||
from streaming._events import custom_event, lifecycle_completed_event
|
||||
from streaming._sync_fake_server import SyncFakeServer
|
||||
|
||||
|
||||
def test_sync_extension_projection_yields_matching_custom_payloads():
|
||||
fake = SyncFakeServer()
|
||||
fake.script(
|
||||
[
|
||||
custom_event(seq=1, name="progress", step=1),
|
||||
custom_event(seq=2, name="metrics", tokens=12),
|
||||
custom_event(seq=3, name="progress", step=2),
|
||||
lifecycle_completed_event(seq=4),
|
||||
]
|
||||
)
|
||||
with httpx.Client(transport=fake.transport, base_url="http://test") as raw:
|
||||
threads = SyncThreadsClient(SyncHttpClient(raw))
|
||||
with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
|
||||
thread.run.start(input={})
|
||||
payloads = list(thread.extensions["progress"])
|
||||
|
||||
assert payloads == [
|
||||
{"name": "progress", "step": 1},
|
||||
{"name": "progress", "step": 2},
|
||||
]
|
||||
assert "custom:progress" in fake.stream_request_bodies[-1]["channels"]
|
||||
|
||||
|
||||
def test_sync_extension_projection_supports_namespace_scope_on_subgraph_handle():
|
||||
fake = SyncFakeServer()
|
||||
fake.script(
|
||||
[
|
||||
custom_event(seq=1, name="progress", namespace=["worker:abc"], step=1),
|
||||
custom_event(seq=2, name="progress", namespace=[], step=0),
|
||||
lifecycle_completed_event(seq=3),
|
||||
]
|
||||
)
|
||||
with httpx.Client(transport=fake.transport, base_url="http://test") as raw:
|
||||
from langgraph_sdk._sync.stream import SyncScopedStreamHandle
|
||||
|
||||
threads = SyncThreadsClient(SyncHttpClient(raw))
|
||||
with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
|
||||
thread.run.start(input={})
|
||||
handle = SyncScopedStreamHandle(
|
||||
thread=thread,
|
||||
path=("worker:abc",),
|
||||
graph_name="worker",
|
||||
trigger_call_id=None,
|
||||
)
|
||||
payloads = list(handle.extensions["progress"])
|
||||
|
||||
assert payloads == [{"name": "progress", "step": 1}]
|
||||
@@ -44,8 +44,12 @@ def test_sync_send_command_applied_through_seq_seeds_shared_stream_since():
|
||||
thread.run.start(input={})
|
||||
assert list(thread.subscribe(["values"])) == []
|
||||
|
||||
# The shared SSE filter is a union of subscription params plus
|
||||
# ``lifecycle`` (added by ``_compute_current_union`` for projection-
|
||||
# iterator termination on root-terminal). Match any request whose
|
||||
# channels include ``values``.
|
||||
values_requests = [
|
||||
b for b in fake.stream_request_bodies if b.get("channels") == ["values"]
|
||||
b for b in fake.stream_request_bodies if "values" in (b.get("channels") or [])
|
||||
]
|
||||
assert len(values_requests) == 1
|
||||
assert values_requests[0]["since"] == 17
|
||||
|
||||
@@ -9,6 +9,7 @@ import uuid
|
||||
from collections.abc import Iterator
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from langgraph_sdk._sync.http import SyncHttpClient
|
||||
from langgraph_sdk._sync.threads import SyncThreadsClient
|
||||
@@ -354,6 +355,56 @@ def test_close_unblocks_active_subscription_before_lifecycle_join():
|
||||
)
|
||||
|
||||
|
||||
def test_sync_thread_agent_get_tree_fetches_assistant_graph():
|
||||
fake = SyncFakeServer()
|
||||
fake.set_graph(
|
||||
{
|
||||
"nodes": [{"id": "agent", "type": "runnable", "data": {"name": "agent"}}],
|
||||
"edges": [{"source": "agent", "target": "__end__"}],
|
||||
}
|
||||
)
|
||||
with httpx.Client(transport=fake.transport, base_url="http://test") as raw:
|
||||
threads = SyncThreadsClient(SyncHttpClient(raw))
|
||||
with threads.stream(
|
||||
thread_id="t-1",
|
||||
assistant_id="agent",
|
||||
headers={"X-Custom-Header": "my-value"},
|
||||
) as thread:
|
||||
graph = thread.agent.get_tree(xray=True)
|
||||
|
||||
assert graph["nodes"][0]["id"] == "agent"
|
||||
assert graph["edges"] == [{"source": "agent", "target": "__end__"}]
|
||||
assert fake.graph_request_params == [{"xray": "true"}]
|
||||
assert fake.graph_request_headers[0].get("x-custom-header") == "my-value"
|
||||
|
||||
|
||||
def test_sync_thread_agent_get_tree_raises_after_close():
|
||||
with httpx.Client(base_url="http://test") as raw:
|
||||
threads = SyncThreadsClient(SyncHttpClient(raw))
|
||||
stream = threads.stream(thread_id="t-1", assistant_id="agent")
|
||||
stream.close()
|
||||
with pytest.raises(RuntimeError, match="closed"):
|
||||
stream.agent.get_tree()
|
||||
|
||||
|
||||
def test_sync_extensions_projection_empty_name_raises():
|
||||
with httpx.Client(base_url="http://test") as raw:
|
||||
threads = SyncThreadsClient(SyncHttpClient(raw))
|
||||
stream = threads.stream(thread_id="t-1", assistant_id="agent")
|
||||
with pytest.raises(ValueError, match="non-empty"):
|
||||
stream.extensions[""]
|
||||
|
||||
|
||||
def test_sync_extensions_projection_closed_stream_yields_nothing():
|
||||
with httpx.Client(base_url="http://test") as raw:
|
||||
threads = SyncThreadsClient(SyncHttpClient(raw))
|
||||
# Enter and immediately exit so _controller is set but _closed is True.
|
||||
with threads.stream(thread_id="t-1", assistant_id="agent") as stream:
|
||||
pass
|
||||
payloads = list(stream.extensions["progress"])
|
||||
assert payloads == []
|
||||
|
||||
|
||||
def test_sync_threads_stream_mints_uuid4_when_thread_id_none():
|
||||
with httpx.Client(base_url="http://test") as raw:
|
||||
threads = SyncThreadsClient(SyncHttpClient(raw))
|
||||
@@ -440,3 +491,114 @@ def test_sync_threads_stream_rejects_unknown_transport_option():
|
||||
assistant_id="agent",
|
||||
transport="bogus", # ty: ignore[invalid-argument-type]
|
||||
)
|
||||
|
||||
|
||||
def test_v3_streaming_sync_surface_smoke():
|
||||
from streaming._events import (
|
||||
custom_event,
|
||||
lifecycle_completed_event,
|
||||
message_finish_event,
|
||||
message_start_event,
|
||||
message_text_delta_event,
|
||||
message_text_finish_event,
|
||||
tool_finished_event,
|
||||
tool_started_event,
|
||||
values_event,
|
||||
)
|
||||
|
||||
fake = SyncFakeServer()
|
||||
fake.set_state({"final": True})
|
||||
# Single script — projections consume events in parallel threads so all
|
||||
# subscriptions are registered before SSE rotation could drop events.
|
||||
# Mirrors the async smoke test's `asyncio.gather` pattern.
|
||||
fake.script(
|
||||
[
|
||||
values_event(seq=1, values={"step": 1}),
|
||||
message_start_event(seq=2, message_id="msg-1"),
|
||||
message_text_delta_event(seq=3, text="hi", message_id="msg-1"),
|
||||
message_text_finish_event(seq=4, text="hi", message_id="msg-1"),
|
||||
message_finish_event(seq=5, message_id="msg-1"),
|
||||
tool_started_event(seq=6, tool_call_id="call-1", tool_name="search"),
|
||||
tool_finished_event(seq=7, tool_call_id="call-1", output={"ok": True}),
|
||||
custom_event(seq=8, name="progress", step=1),
|
||||
lifecycle_completed_event(seq=9),
|
||||
]
|
||||
)
|
||||
with httpx.Client(transport=fake.transport, base_url="http://test") as raw:
|
||||
threads = SyncThreadsClient(SyncHttpClient(raw))
|
||||
with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
|
||||
start = thread.run.start(
|
||||
input={"messages": [{"role": "user", "content": "hi"}]}
|
||||
)
|
||||
|
||||
# Gate every reconcile_stream call on a barrier so that all four
|
||||
# projection threads register their subscriptions before any
|
||||
# reconcile widens (or rotates) the shared SSE. This mirrors the
|
||||
# async smoke test's `asyncio.gather` pattern: every subscription
|
||||
# is registered before the first SSE opens; one SSE covers all
|
||||
# consumers and `_seen_event_ids` covers any subsequent reconnect.
|
||||
controller = thread._controller
|
||||
assert controller is not None
|
||||
barrier = threading.Barrier(4)
|
||||
real_reconcile = controller.reconcile_stream
|
||||
|
||||
def _gated_reconcile(candidate_filter):
|
||||
barrier.wait(timeout=10)
|
||||
return real_reconcile(candidate_filter)
|
||||
|
||||
controller.reconcile_stream = _gated_reconcile # ty: ignore[invalid-assignment]
|
||||
|
||||
results: dict[str, object] = {}
|
||||
errors: list[BaseException] = []
|
||||
|
||||
def _run_values() -> None:
|
||||
try:
|
||||
for v in thread.values:
|
||||
results["values"] = v
|
||||
return
|
||||
except BaseException as err: # pragma: no cover - propagated
|
||||
errors.append(err)
|
||||
|
||||
def _run_messages() -> None:
|
||||
try:
|
||||
results["messages"] = list(thread.messages)
|
||||
except BaseException as err: # pragma: no cover - propagated
|
||||
errors.append(err)
|
||||
|
||||
def _run_tools() -> None:
|
||||
try:
|
||||
results["tools"] = list(thread.tool_calls)
|
||||
except BaseException as err: # pragma: no cover - propagated
|
||||
errors.append(err)
|
||||
|
||||
def _run_progress() -> None:
|
||||
try:
|
||||
results["progress"] = list(thread.extensions["progress"])
|
||||
except BaseException as err: # pragma: no cover - propagated
|
||||
errors.append(err)
|
||||
|
||||
workers = [
|
||||
threading.Thread(target=_run_values),
|
||||
threading.Thread(target=_run_messages),
|
||||
threading.Thread(target=_run_tools),
|
||||
threading.Thread(target=_run_progress),
|
||||
]
|
||||
for w in workers:
|
||||
w.start()
|
||||
for w in workers:
|
||||
w.join(timeout=10)
|
||||
assert not w.is_alive(), "smoke worker thread hung"
|
||||
controller.reconcile_stream = real_reconcile # ty: ignore[invalid-assignment]
|
||||
assert not errors, errors
|
||||
final = thread.output
|
||||
|
||||
assert start == {"run_id": "run-1"}
|
||||
assert results["values"] == fake.state["values"]
|
||||
messages_result = results["messages"]
|
||||
assert isinstance(messages_result, list)
|
||||
assert [str(m.text) for m in messages_result] == ["hi"] # ty: ignore[unresolved-attribute]
|
||||
tools_result = results["tools"]
|
||||
assert isinstance(tools_result, list)
|
||||
assert tools_result[0].name == "search" # ty: ignore[unresolved-attribute]
|
||||
assert results["progress"] == [{"name": "progress", "step": 1}]
|
||||
assert final == {"final": True}
|
||||
|
||||
@@ -65,9 +65,13 @@ def test_sync_websocket_sends_subscribe_body_and_yields_events():
|
||||
handle.close()
|
||||
|
||||
assert orjson.loads(socket.sent[0]) == {
|
||||
"channels": ["values"],
|
||||
"namespaces": [[]],
|
||||
"since": 7,
|
||||
"id": 1,
|
||||
"method": "subscription.subscribe",
|
||||
"params": {
|
||||
"channels": ["values"],
|
||||
"namespaces": [[]],
|
||||
"since": 7,
|
||||
},
|
||||
}
|
||||
assert received == [event]
|
||||
assert err is None
|
||||
@@ -197,7 +201,7 @@ def test_sync_websocket_controller_reconnects_with_since_after_drop():
|
||||
assert first["seq"] == 1
|
||||
assert second["seq"] == 2
|
||||
assert end is None
|
||||
assert orjson.loads(second_socket.sent[0])["since"] == 1
|
||||
assert orjson.loads(second_socket.sent[0])["params"]["since"] == 1
|
||||
|
||||
|
||||
def test_sync_ws_transport_forwards_ping_kwargs():
|
||||
|
||||
@@ -4,6 +4,7 @@ import asyncio
|
||||
import contextlib
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -24,6 +25,57 @@ from streaming._events import (
|
||||
from streaming._fake_server import FakeServer
|
||||
|
||||
|
||||
async def test_thread_agent_get_tree_fetches_assistant_graph():
|
||||
fake = FakeServer()
|
||||
fake.set_graph(
|
||||
{
|
||||
"nodes": [{"id": "agent", "type": "runnable", "data": {"name": "agent"}}],
|
||||
"edges": [{"source": "agent", "target": "__end__"}],
|
||||
}
|
||||
)
|
||||
transport = httpx.ASGITransport(app=fake.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw:
|
||||
threads = ThreadsClient(HttpClient(raw))
|
||||
async with threads.stream(
|
||||
thread_id="t-1",
|
||||
assistant_id="agent",
|
||||
headers={"X-Custom-Header": "my-value"},
|
||||
) as thread:
|
||||
graph = await thread.agent.get_tree(xray=True)
|
||||
|
||||
assert graph["nodes"][0]["id"] == "agent"
|
||||
assert graph["edges"] == [{"source": "agent", "target": "__end__"}]
|
||||
assert fake.graph_request_params == [{"xray": "true"}]
|
||||
assert fake.graph_request_headers[0].get("x-custom-header") == "my-value"
|
||||
|
||||
|
||||
async def test_thread_agent_get_tree_raises_after_close():
|
||||
async with httpx.AsyncClient(base_url="http://test") as raw:
|
||||
threads = ThreadsClient(HttpClient(raw))
|
||||
stream = threads.stream(thread_id="t-1", assistant_id="agent")
|
||||
await stream.close()
|
||||
with pytest.raises(RuntimeError, match="closed"):
|
||||
await stream.agent.get_tree()
|
||||
|
||||
|
||||
async def test_extensions_projection_empty_name_raises():
|
||||
async with httpx.AsyncClient(base_url="http://test") as raw:
|
||||
threads = ThreadsClient(HttpClient(raw))
|
||||
stream = threads.stream(thread_id="t-1", assistant_id="agent")
|
||||
with pytest.raises(ValueError, match="non-empty"):
|
||||
stream.extensions[""]
|
||||
|
||||
|
||||
async def test_extensions_projection_closed_stream_yields_nothing():
|
||||
async with httpx.AsyncClient(base_url="http://test") as raw:
|
||||
threads = ThreadsClient(HttpClient(raw))
|
||||
# Enter and immediately exit so _controller is set but _closed is True.
|
||||
async with threads.stream(thread_id="t-1", assistant_id="agent") as stream:
|
||||
pass
|
||||
payloads = [p async for p in stream.extensions["progress"]]
|
||||
assert payloads == []
|
||||
|
||||
|
||||
async def test_thread_stream_stores_thread_id_and_assistant_id():
|
||||
async with httpx.AsyncClient(base_url="http://test") as client:
|
||||
stream = AsyncThreadStream(
|
||||
@@ -416,7 +468,7 @@ async def test_events_property_returns_fresh_iterator_each_access():
|
||||
async def test_fresh_thread_happy_path_end_to_end():
|
||||
"""User passes no thread_id; SDK mints one and uses it in all URLs.
|
||||
|
||||
Validates the thread-stream surface end-to-end:
|
||||
Validates core surface end-to-end:
|
||||
- uuid4 minted at client.threads.stream()
|
||||
- run.start posted to /threads/<minted-id>/commands
|
||||
- events SSE opened at /threads/<minted-id>/stream/events
|
||||
@@ -705,7 +757,7 @@ async def test_terminal_lifecycle_clear_acquires_interrupts_lock():
|
||||
"method": "lifecycle",
|
||||
"params": {
|
||||
"namespace": [],
|
||||
"data": {"phase": "completed"},
|
||||
"data": {"event": "completed"},
|
||||
},
|
||||
"seq": 99,
|
||||
"event_id": "evt-99",
|
||||
@@ -846,3 +898,76 @@ async def test_threads_stream_rejects_unknown_transport_option():
|
||||
assistant_id="agent",
|
||||
transport="bogus", # ty: ignore[invalid-argument-type]
|
||||
)
|
||||
|
||||
|
||||
async def test_v3_streaming_async_surface_smoke():
|
||||
import asyncio
|
||||
|
||||
from streaming._events import (
|
||||
custom_event,
|
||||
lifecycle_completed_event,
|
||||
message_finish_event,
|
||||
message_start_event,
|
||||
message_text_delta_event,
|
||||
message_text_finish_event,
|
||||
tool_finished_event,
|
||||
tool_started_event,
|
||||
values_event,
|
||||
)
|
||||
|
||||
fake = FakeServer()
|
||||
fake.set_state({"final": True})
|
||||
fake.script(
|
||||
[
|
||||
values_event(seq=1, values={"step": 1}),
|
||||
message_start_event(seq=2, message_id="msg-1"),
|
||||
message_text_delta_event(seq=3, text="hi", message_id="msg-1"),
|
||||
message_text_finish_event(seq=4, text="hi", message_id="msg-1"),
|
||||
message_finish_event(seq=5, message_id="msg-1"),
|
||||
tool_started_event(seq=6, tool_call_id="call-1", tool_name="search"),
|
||||
tool_finished_event(seq=7, tool_call_id="call-1", output={"ok": True}),
|
||||
custom_event(seq=8, name="progress", step=1),
|
||||
lifecycle_completed_event(seq=9),
|
||||
]
|
||||
)
|
||||
async with httpx.AsyncClient(
|
||||
transport=fake.transport, base_url="http://test"
|
||||
) as raw:
|
||||
threads = ThreadsClient(HttpClient(raw))
|
||||
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
|
||||
start = await thread.run.start(
|
||||
input={"messages": [{"role": "user", "content": "hi"}]}
|
||||
)
|
||||
|
||||
# Start all consumers concurrently so their subscriptions are all
|
||||
# registered before the first SSE reconciliation. This means ONE
|
||||
# shared SSE opens with the union filter, dedup never rejects events.
|
||||
async def _get_first_values() -> Any:
|
||||
async for v in thread.values:
|
||||
return v
|
||||
|
||||
async def _get_message_streams():
|
||||
return [s async for s in thread.messages]
|
||||
|
||||
async def _get_tool_calls():
|
||||
return [call async for call in thread.tool_calls]
|
||||
|
||||
async def _get_progress():
|
||||
return [p async for p in thread.extensions["progress"]]
|
||||
|
||||
first_values, message_streams, tool_calls, progress = await asyncio.gather(
|
||||
_get_first_values(),
|
||||
_get_message_streams(),
|
||||
_get_tool_calls(),
|
||||
_get_progress(),
|
||||
)
|
||||
# stream.text is already fully accumulated after gather completes.
|
||||
message_texts = [await s.text for s in message_streams]
|
||||
final = await thread.output
|
||||
|
||||
assert start == {"run_id": "run-1"}
|
||||
assert first_values == fake.state["values"]
|
||||
assert message_texts == ["hi"]
|
||||
assert tool_calls[0].name == "search"
|
||||
assert progress == [{"name": "progress", "step": 1}]
|
||||
assert final == {"final": True}
|
||||
|
||||
@@ -295,9 +295,13 @@ async def test_websocket_sends_subscribe_body_and_yields_events():
|
||||
await handle.close()
|
||||
|
||||
assert orjson.loads(socket.sent[0]) == {
|
||||
"channels": ["values"],
|
||||
"namespaces": [[]],
|
||||
"since": 7,
|
||||
"id": 1,
|
||||
"method": "subscription.subscribe",
|
||||
"params": {
|
||||
"channels": ["values"],
|
||||
"namespaces": [[]],
|
||||
"since": 7,
|
||||
},
|
||||
}
|
||||
assert received == [event]
|
||||
assert err is None
|
||||
@@ -456,7 +460,7 @@ async def test_websocket_controller_reconnects_with_since_after_drop():
|
||||
assert second["seq"] == 2
|
||||
assert end is None
|
||||
assert len(sent_urls) == 2
|
||||
assert orjson.loads(second_socket.sent[0])["since"] == 1
|
||||
assert orjson.loads(second_socket.sent[0])["params"]["since"] == 1
|
||||
|
||||
|
||||
async def test_async_close_sends_normal_close_frame():
|
||||
|
||||
Reference in New Issue
Block a user