diff --git a/.github/workflows/_sdk_integration_test.yml b/.github/workflows/_sdk_integration_test.yml new file mode 100644 index 000000000..ed874db5b --- /dev/null +++ b/.github/workflows/_sdk_integration_test.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b2643494..9aab80b8a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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() diff --git a/libs/sdk-py/CHANGELOG.md b/libs/sdk-py/CHANGELOG.md new file mode 100644 index 000000000..32c29f0e1 --- /dev/null +++ b/libs/sdk-py/CHANGELOG.md @@ -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`. diff --git a/libs/sdk-py/MIGRATION.md b/libs/sdk-py/MIGRATION.md new file mode 100644 index 000000000..9c9742ff0 --- /dev/null +++ b/libs/sdk-py/MIGRATION.md @@ -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). diff --git a/libs/sdk-py/README.md b/libs/sdk-py/README.md index 3f9e51cdd..6b7b35f4c 100644 --- a/libs/sdk-py/README.md +++ b/libs/sdk-py/README.md @@ -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 +``` diff --git a/libs/sdk-py/integration/Dockerfile b/libs/sdk-py/integration/Dockerfile new file mode 100644 index 000000000..4c5a3d04a --- /dev/null +++ b/libs/sdk-py/integration/Dockerfile @@ -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 diff --git a/libs/sdk-py/integration/docker-compose.yml b/libs/sdk-py/integration/docker-compose.yml new file mode 100644 index 000000000..580139ce2 --- /dev/null +++ b/libs/sdk-py/integration/docker-compose.yml @@ -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 diff --git a/libs/sdk-py/integration/graph/__init__.py b/libs/sdk-py/integration/graph/__init__.py new file mode 100644 index 000000000..c9c3dd7f4 --- /dev/null +++ b/libs/sdk-py/integration/graph/__init__.py @@ -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"] diff --git a/libs/sdk-py/integration/graph/deep_agent.py b/libs/sdk-py/integration/graph/deep_agent.py new file mode 100644 index 000000000..5dc6d73a4 --- /dev/null +++ b/libs/sdk-py/integration/graph/deep_agent.py @@ -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", +) diff --git a/libs/sdk-py/integration/graph/streaming_graph.py b/libs/sdk-py/integration/graph/streaming_graph.py new file mode 100644 index 000000000..c71765fc6 --- /dev/null +++ b/libs/sdk-py/integration/graph/streaming_graph.py @@ -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], +) diff --git a/libs/sdk-py/integration/graph/tools_agent.py b/libs/sdk-py/integration/graph/tools_agent.py new file mode 100644 index 000000000..505c311dd --- /dev/null +++ b/libs/sdk-py/integration/graph/tools_agent.py @@ -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", +) diff --git a/libs/sdk-py/integration/langgraph.json b/libs/sdk-py/integration/langgraph.json new file mode 100644 index 000000000..53a34ee8c --- /dev/null +++ b/libs/sdk-py/integration/langgraph.json @@ -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": {} +} diff --git a/libs/sdk-py/integration/scripts/_common.py b/libs/sdk-py/integration/scripts/_common.py new file mode 100644 index 000000000..49aa33e23 --- /dev/null +++ b/libs/sdk-py/integration/scripts/_common.py @@ -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 diff --git a/libs/sdk-py/integration/scripts/test_cancel.py b/libs/sdk-py/integration/scripts/test_cancel.py new file mode 100644 index 000000000..ad757d0b9 --- /dev/null +++ b/libs/sdk-py/integration/scripts/test_cancel.py @@ -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() diff --git a/libs/sdk-py/integration/scripts/test_concurrent.py b/libs/sdk-py/integration/scripts/test_concurrent.py new file mode 100644 index 000000000..62e18c1a9 --- /dev/null +++ b/libs/sdk-py/integration/scripts/test_concurrent.py @@ -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() diff --git a/libs/sdk-py/integration/scripts/test_extensions.py b/libs/sdk-py/integration/scripts/test_extensions.py new file mode 100644 index 000000000..1bb2ec08e --- /dev/null +++ b/libs/sdk-py/integration/scripts/test_extensions.py @@ -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() diff --git a/libs/sdk-py/integration/scripts/test_helpers.py b/libs/sdk-py/integration/scripts/test_helpers.py new file mode 100644 index 000000000..1da8ce0d7 --- /dev/null +++ b/libs/sdk-py/integration/scripts/test_helpers.py @@ -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() diff --git a/libs/sdk-py/integration/scripts/test_lifecycle.py b/libs/sdk-py/integration/scripts/test_lifecycle.py new file mode 100644 index 000000000..7c7226456 --- /dev/null +++ b/libs/sdk-py/integration/scripts/test_lifecycle.py @@ -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() diff --git a/libs/sdk-py/integration/scripts/test_messages.py b/libs/sdk-py/integration/scripts/test_messages.py new file mode 100644 index 000000000..a713031c1 --- /dev/null +++ b/libs/sdk-py/integration/scripts/test_messages.py @@ -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() diff --git a/libs/sdk-py/integration/scripts/test_reconnect.py b/libs/sdk-py/integration/scripts/test_reconnect.py new file mode 100644 index 000000000..5b3753fa8 --- /dev/null +++ b/libs/sdk-py/integration/scripts/test_reconnect.py @@ -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() diff --git a/libs/sdk-py/integration/scripts/test_subgraphs.py b/libs/sdk-py/integration/scripts/test_subgraphs.py new file mode 100644 index 000000000..109a57c63 --- /dev/null +++ b/libs/sdk-py/integration/scripts/test_subgraphs.py @@ -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() diff --git a/libs/sdk-py/integration/scripts/test_tools.py b/libs/sdk-py/integration/scripts/test_tools.py new file mode 100644 index 000000000..e4bc33c6e --- /dev/null +++ b/libs/sdk-py/integration/scripts/test_tools.py @@ -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() diff --git a/libs/sdk-py/integration/scripts/test_update_state.py b/libs/sdk-py/integration/scripts/test_update_state.py new file mode 100644 index 000000000..5265d22b3 --- /dev/null +++ b/libs/sdk-py/integration/scripts/test_update_state.py @@ -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() diff --git a/libs/sdk-py/integration/scripts/test_values.py b/libs/sdk-py/integration/scripts/test_values.py new file mode 100644 index 000000000..abec78378 --- /dev/null +++ b/libs/sdk-py/integration/scripts/test_values.py @@ -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() diff --git a/libs/sdk-py/integration/scripts/test_websocket.py b/libs/sdk-py/integration/scripts/test_websocket.py new file mode 100644 index 000000000..f31668271 --- /dev/null +++ b/libs/sdk-py/integration/scripts/test_websocket.py @@ -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() diff --git a/libs/sdk-py/langgraph_sdk/_async/stream.py b/libs/sdk-py/langgraph_sdk/_async/stream.py index 70a86d0fe..d574f5794 100644 --- a/libs/sdk-py/langgraph_sdk/_async/stream.py +++ b/libs/sdk-py/langgraph_sdk/_async/stream.py @@ -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:``. 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 ) diff --git a/libs/sdk-py/langgraph_sdk/_sync/stream.py b/libs/sdk-py/langgraph_sdk/_sync/stream.py index 0aaccdf47..f987e794b 100644 --- a/libs/sdk-py/langgraph_sdk/_sync/stream.py +++ b/libs/sdk-py/langgraph_sdk/_sync/stream.py @@ -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 ) diff --git a/libs/sdk-py/langgraph_sdk/stream/sync_controller.py b/libs/sdk-py/langgraph_sdk/stream/sync_controller.py index 63af76a6b..165c7bfdf 100644 --- a/libs/sdk-py/langgraph_sdk/stream/sync_controller.py +++ b/libs/sdk-py/langgraph_sdk/stream/sync_controller.py @@ -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: diff --git a/libs/sdk-py/langgraph_sdk/stream/transport/sync_ws.py b/libs/sdk-py/langgraph_sdk/stream/transport/sync_ws.py index 864a5967a..ec1289420 100644 --- a/libs/sdk-py/langgraph_sdk/stream/transport/sync_ws.py +++ b/libs/sdk-py/langgraph_sdk/stream/transport/sync_ws.py @@ -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") diff --git a/libs/sdk-py/langgraph_sdk/stream/transport/ws.py b/libs/sdk-py/langgraph_sdk/stream/transport/ws.py index 3a9371820..71bcf9411 100644 --- a/libs/sdk-py/langgraph_sdk/stream/transport/ws.py +++ b/libs/sdk-py/langgraph_sdk/stream/transport/ws.py @@ -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") diff --git a/libs/sdk-py/pyproject.toml b/libs/sdk-py/pyproject.toml index fd857d456..2e3f36ac6 100644 --- a/libs/sdk-py/pyproject.toml +++ b/libs/sdk-py/pyproject.toml @@ -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" diff --git a/libs/sdk-py/tests/integration/__init__.py b/libs/sdk-py/tests/integration/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/libs/sdk-py/tests/integration/conftest.py b/libs/sdk-py/tests/integration/conftest.py new file mode 100644 index 000000000..283c72d43 --- /dev/null +++ b/libs/sdk-py/tests/integration/conftest.py @@ -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() diff --git a/libs/sdk-py/tests/integration/test_assistants.py b/libs/sdk-py/tests/integration/test_assistants.py new file mode 100644 index 000000000..d71787732 --- /dev/null +++ b/libs/sdk-py/tests/integration/test_assistants.py @@ -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 diff --git a/libs/sdk-py/tests/integration/test_cancel.py b/libs/sdk-py/tests/integration/test_cancel.py new file mode 100644 index 000000000..995f1b26a --- /dev/null +++ b/libs/sdk-py/tests/integration/test_cancel.py @@ -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}" + ) diff --git a/libs/sdk-py/tests/integration/test_concurrent.py b/libs/sdk-py/tests/integration/test_concurrent.py new file mode 100644 index 000000000..e07836a0b --- /dev/null +++ b/libs/sdk-py/tests/integration/test_concurrent.py @@ -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}" + ) diff --git a/libs/sdk-py/tests/integration/test_crons.py b/libs/sdk-py/tests/integration/test_crons.py new file mode 100644 index 000000000..90d81b4ca --- /dev/null +++ b/libs/sdk-py/tests/integration/test_crons.py @@ -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) diff --git a/libs/sdk-py/tests/integration/test_extensions.py b/libs/sdk-py/tests/integration/test_extensions.py new file mode 100644 index 000000000..04b0c94d4 --- /dev/null +++ b/libs/sdk-py/tests/integration/test_extensions.py @@ -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}" + ) diff --git a/libs/sdk-py/tests/integration/test_helpers.py b/libs/sdk-py/tests/integration/test_helpers.py new file mode 100644 index 000000000..0e57ba27f --- /dev/null +++ b/libs/sdk-py/tests/integration/test_helpers.py @@ -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" diff --git a/libs/sdk-py/tests/integration/test_lifecycle.py b/libs/sdk-py/tests/integration/test_lifecycle.py new file mode 100644 index 000000000..d249cc50f --- /dev/null +++ b/libs/sdk-py/tests/integration/test_lifecycle.py @@ -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 diff --git a/libs/sdk-py/tests/integration/test_messages.py b/libs/sdk-py/tests/integration/test_messages.py new file mode 100644 index 000000000..30ce9f1e0 --- /dev/null +++ b/libs/sdk-py/tests/integration/test_messages.py @@ -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}" diff --git a/libs/sdk-py/tests/integration/test_reconnect.py b/libs/sdk-py/tests/integration/test_reconnect.py new file mode 100644 index 000000000..97a3f0de8 --- /dev/null +++ b/libs/sdk-py/tests/integration/test_reconnect.py @@ -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=`) 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']})" + ) diff --git a/libs/sdk-py/tests/integration/test_runs.py b/libs/sdk-py/tests/integration/test_runs.py new file mode 100644 index 000000000..8a6404530 --- /dev/null +++ b/libs/sdk-py/tests/integration/test_runs.py @@ -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) diff --git a/libs/sdk-py/tests/integration/test_store.py b/libs/sdk-py/tests/integration/test_store.py new file mode 100644 index 000000000..bb80be63f --- /dev/null +++ b/libs/sdk-py/tests/integration/test_store.py @@ -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") diff --git a/libs/sdk-py/tests/integration/test_subgraphs.py b/libs/sdk-py/tests/integration/test_subgraphs.py new file mode 100644 index 000000000..dda35f380 --- /dev/null +++ b/libs/sdk-py/tests/integration/test_subgraphs.py @@ -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" diff --git a/libs/sdk-py/tests/integration/test_threads_crud.py b/libs/sdk-py/tests/integration/test_threads_crud.py new file mode 100644 index 000000000..c2dc23d25 --- /dev/null +++ b/libs/sdk-py/tests/integration/test_threads_crud.py @@ -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" diff --git a/libs/sdk-py/tests/integration/test_tools.py b/libs/sdk-py/tests/integration/test_tools.py new file mode 100644 index 000000000..47ee1c538 --- /dev/null +++ b/libs/sdk-py/tests/integration/test_tools.py @@ -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) diff --git a/libs/sdk-py/tests/integration/test_update_state.py b/libs/sdk-py/tests/integration/test_update_state.py new file mode 100644 index 000000000..2387eb3c0 --- /dev/null +++ b/libs/sdk-py/tests/integration/test_update_state.py @@ -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}" + ) diff --git a/libs/sdk-py/tests/integration/test_values.py b/libs/sdk-py/tests/integration/test_values.py new file mode 100644 index 000000000..9c7d489a1 --- /dev/null +++ b/libs/sdk-py/tests/integration/test_values.py @@ -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" diff --git a/libs/sdk-py/tests/integration/test_websocket.py b/libs/sdk-py/tests/integration/test_websocket.py new file mode 100644 index 000000000..39d5dfa50 --- /dev/null +++ b/libs/sdk-py/tests/integration/test_websocket.py @@ -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 diff --git a/libs/sdk-py/tests/streaming/_events.py b/libs/sdk-py/tests/streaming/_events.py index 201dda5db..ba0d752e2 100644 --- a/libs/sdk-py/tests/streaming/_events.py +++ b/libs/sdk-py/tests/streaming/_events.py @@ -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( diff --git a/libs/sdk-py/tests/streaming/_fake_server.py b/libs/sdk-py/tests/streaming/_fake_server.py index 49e4ca0ce..50a7b1abe 100644 --- a/libs/sdk-py/tests/streaming/_fake_server.py +++ b/libs/sdk-py/tests/streaming/_fake_server.py @@ -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"], + ), ] ) diff --git a/libs/sdk-py/tests/streaming/_sync_fake_server.py b/libs/sdk-py/tests/streaming/_sync_fake_server.py index eb1715c8d..6b133fc92 100644 --- a/libs/sdk-py/tests/streaming/_sync_fake_server.py +++ b/libs/sdk-py/tests/streaming/_sync_fake_server.py @@ -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)) diff --git a/libs/sdk-py/tests/streaming/test_extensions_projection.py b/libs/sdk-py/tests/streaming/test_extensions_projection.py new file mode 100644 index 000000000..16a8fd0c1 --- /dev/null +++ b/libs/sdk-py/tests/streaming/test_extensions_projection.py @@ -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}] diff --git a/libs/sdk-py/tests/streaming/test_shared_stream.py b/libs/sdk-py/tests/streaming/test_shared_stream.py index 1557c02aa..1a96a91bd 100644 --- a/libs/sdk-py/tests/streaming/test_shared_stream.py +++ b/libs/sdk-py/tests/streaming/test_shared_stream.py @@ -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 diff --git a/libs/sdk-py/tests/streaming/test_sync_extensions_projection.py b/libs/sdk-py/tests/streaming/test_sync_extensions_projection.py new file mode 100644 index 000000000..808846aee --- /dev/null +++ b/libs/sdk-py/tests/streaming/test_sync_extensions_projection.py @@ -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}] diff --git a/libs/sdk-py/tests/streaming/test_sync_shared_stream.py b/libs/sdk-py/tests/streaming/test_sync_shared_stream.py index 448913999..456241384 100644 --- a/libs/sdk-py/tests/streaming/test_sync_shared_stream.py +++ b/libs/sdk-py/tests/streaming/test_sync_shared_stream.py @@ -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 diff --git a/libs/sdk-py/tests/streaming/test_sync_thread_stream.py b/libs/sdk-py/tests/streaming/test_sync_thread_stream.py index 63be80953..c594eddeb 100644 --- a/libs/sdk-py/tests/streaming/test_sync_thread_stream.py +++ b/libs/sdk-py/tests/streaming/test_sync_thread_stream.py @@ -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} diff --git a/libs/sdk-py/tests/streaming/test_sync_transport_ws.py b/libs/sdk-py/tests/streaming/test_sync_transport_ws.py index c8f73dc5b..72584b325 100644 --- a/libs/sdk-py/tests/streaming/test_sync_transport_ws.py +++ b/libs/sdk-py/tests/streaming/test_sync_transport_ws.py @@ -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(): diff --git a/libs/sdk-py/tests/streaming/test_thread_stream.py b/libs/sdk-py/tests/streaming/test_thread_stream.py index 43500c852..959915b16 100644 --- a/libs/sdk-py/tests/streaming/test_thread_stream.py +++ b/libs/sdk-py/tests/streaming/test_thread_stream.py @@ -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//commands - events SSE opened at /threads//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} diff --git a/libs/sdk-py/tests/streaming/test_transport_ws.py b/libs/sdk-py/tests/streaming/test_transport_ws.py index 32a2bf0f8..8844b75e0 100644 --- a/libs/sdk-py/tests/streaming/test_transport_ws.py +++ b/libs/sdk-py/tests/streaming/test_transport_ws.py @@ -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():