Commit Graph
63 Commits
Author SHA1 Message Date
Elior Nataf LackritzandGitHub ea5f9cc9fb chore: enforce PLC0415 in tests for the remaining packages (#8547)
Follow-up to #8540, which turned on `PLC0415` (import-outside-top-level)
for checkpoint-postgres and checkpoint-sqlite. This does the remaining
six packages: checkpoint, checkpoint-conformance, langgraph, prebuilt,
cli, sdk-py.

Scoped to tests, per @sydney-runkle's call on #8540: library code is
exempted with `per-file-ignores`, since it still has deferred imports
nobody has reviewed and mixing that in would make this hard to read.

## What changed

Function-level imports across 56 test files moved to module level. Nine
could not move and carry an explicit `# noqa: PLC0415` with a reason:

| File | Why it stays local |
|---|---|
| `libs/langgraph/tests/test_deprecation.py` (4) | the import has to run
inside `pytest.warns` for the warning to be observed |
| `libs/langgraph/tests/test_serde_allowlist.py` | try/except guard,
skips when langchain_core is absent |
| `libs/langgraph/tests/test_delta_channel_benchmark.py` | optional
psycopg probe |
| `libs/checkpoint/tests/test_conformance_delta.py` (3) | protected by a
module-level `pytest.importorskip`; hoisting past the guard turns a skip
into a collection error |

That last one is the trap: an import moved above `pytest.importorskip`
silently defeats the guard. I hit it locally and it turned the skip into
a `ModuleNotFoundError` at collection. Every file with an `importorskip`
or `except ImportError` was checked by hand for this.

## Verification

`make lint` and `make test` in each of the six:

| Package | Tests |
|---|---|
| checkpoint | 156 passed, 17 skipped |
| checkpoint-conformance | 1 passed |
| langgraph | 1968 passed, 4 skipped |
| prebuilt | 284 passed |
| cli | 336 passed |
| sdk-py | 493 passed |

Also confirmed the rule actually fires: a throwaway test file with a
function-level import is flagged in all six packages, and the source
exemption holds.
2026-08-07 09:40:18 -04:00
Elior Nataf LackritzandGitHub f22af6248c chore: enable RUF100 and clear unused noqa directives (#8546)
Follow-up to review on #8540, where a stale `# noqa: E402` slipped past
me and Sydney spotted it by eye. This turns on the rule that catches
that automatically.

`RUF100` flags a `noqa` that suppresses nothing. `sdk-py` already had it
through its blanket `RUF` selection; this adds it to the other seven
packages and clears what it finds.

### The 33 it flags, all autofixed

**Blanket `# noqa` on docstring-closing lines** (4, in
`checkpoint-postgres` and `checkpoint-sqlite`). `E501` is in
`lint.ignore` for those packages, so nothing was being suppressed:

```diff
-        """  # noqa
+        """
```

**`# noqa: F821` on `anext(aiter_)`** (2). Left over from Python 3.9
support. `anext` became a builtin in 3.10, which is the floor now, so
`F821` no longer fires:

```diff
-                    anext(aiter_),  # type: ignore[arg-type]  # noqa: F821
+                    anext(aiter_),  # type: ignore[arg-type]
```

**Suppressions naming rules the package does not enable** (27), across
`langgraph`, `prebuilt` and `checkpoint-sqlite`: `FBT001`, `FBT002`,
`TC002`, `BLE001`, `ANN001`, `ANN002`, `ANN003`, `E501`, `F401`. Mostly
copied between packages whose rule sets differ.

### One measurement note

If you check these numbers yourself, use `--extend-select`:

```
ruff check --select RUF100 .          # 81, misleading
ruff check --extend-select RUF100 .   # 33, real
```

With a bare `--select`, ruff treats every other rule as disabled, so
every suppression for another rule looks unused. I quoted 81 before
catching that.

### Verified

`checkpoint-sqlite` 118 passed, `prebuilt` 284 passed, `langgraph` 1968
passed, `checkpoint-postgres` 264 passed on PG 15 and 16. `make lint`
clean in every package.

Independent of #8540 and #8537, so it can land in any order.
2026-08-06 17:38:31 -04:00
Christian BromannandGitHub ea44df3476 fix(langgraph): keep tool results out of v3 messages (#7838)
## Summary

- Filter `ToolMessage` from v3 `run.messages` streaming (handler +
`MessagesTransformer`) so tool results do not appear as chat text
deltas.
- Normalize `ToolCallStream.output` in `ToolCallTransformer` so live and
serialized `ToolMessage` payloads resolve to raw `content`.
- Add regression tests for message filtering and tool-output unwrapping.

<details>
<summary>Reproducible script</summary>

```python
"""Repro: v3 tool results must not leak through run.messages."""

from __future__ import annotations

import asyncio
from collections.abc import Callable, Sequence
from typing import Any
from uuid import uuid4

from deepagents import create_deep_agent
from langchain_core.callbacks import CallbackManagerForLLMRun
from langchain_core.language_models import BaseChatModel, LanguageModelInput
from langchain_core.messages import AIMessage, HumanMessage, ToolCall, ToolMessage
from langchain_core.outputs import ChatGeneration, ChatResult
from langchain_core.runnables import Runnable
from langchain_core.tools import BaseTool, tool
from pydantic import Field

TOOL_RESULT_SENTINEL = "[]"


class ScriptedChatModel(BaseChatModel):
    responses: list[AIMessage] = Field(default_factory=list)
    tools: Sequence[dict[str, Any] | type | Callable | BaseTool] = ()
    _idx: int = 0

    @property
    def _llm_type(self) -> str:
        return "scripted"

    def _generate(
        self,
        messages: Sequence[Any],
        stop: list[str] | None = None,
        run_manager: CallbackManagerForLLMRun | None = None,
        **kwargs: Any,
    ) -> ChatResult:
        del messages, stop, run_manager, kwargs
        idx = min(self._idx, len(self.responses) - 1)
        self._idx += 1
        return ChatResult(generations=[ChatGeneration(message=self.responses[idx])])

    def bind_tools(
        self,
        tools: Sequence[dict[str, Any] | type | Callable | BaseTool],
        *,
        tool_choice: str | None = None,
        **kwargs: Any,
    ) -> Runnable[LanguageModelInput, AIMessage]:
        del tool_choice, kwargs
        self.tools = tools
        return self


@tool
def list_items() -> str:
    """List available items."""
    return TOOL_RESULT_SENTINEL


def _tool_call_message() -> AIMessage:
    return AIMessage(
        content="",
        tool_calls=[
            ToolCall(id="call_list", name="list_items", args={}),
        ],
    )


def _extract_text_delta(event: Any) -> str | None:
    if isinstance(event, dict):
        if event.get("event") != "content-block-delta":
            return None
        delta = event.get("delta")
        if isinstance(delta, dict) and delta.get("type") == "text-delta":
            text = delta.get("text")
            return text if isinstance(text, str) else None
    elif getattr(event, "event", None) == "content-block-delta":
        delta = getattr(event, "delta", None)
        if isinstance(delta, dict) and delta.get("type") == "text-delta":
            text = delta.get("text")
            return text if isinstance(text, str) else None
        text = getattr(delta, "text", None)
        return text if isinstance(text, str) else None
    return None


async def _tool_output(tool_call: Any) -> Any:
    output = getattr(tool_call, "output", None)
    if callable(output):
        return await output()
    if hasattr(output, "__await__"):
        return await output
    return output


async def main() -> None:
    model = ScriptedChatModel(
        responses=[
            _tool_call_message(),
            AIMessage(content="No items found."),
        ]
    )
    agent = create_deep_agent(model=model, tools=[list_items])
    run = await agent.astream_events(
        {"messages": [HumanMessage(content="List items")]},
        version="v3",
        configurable={"thread_id": str(uuid4())},
        recursion_limit=50,
    )

    async def collect_message_texts() -> list[str]:
        texts: list[str] = []
        async for message_stream in run.messages:
            async for event in message_stream:
                text = _extract_text_delta(event)
                if text is not None:
                    texts.append(text)
        return texts

    async def collect_tool_outputs() -> list[Any]:
        outputs: list[Any] = []
        async for tool_call in run.tool_calls:
            outputs.append(await _tool_output(tool_call))
        return outputs

    message_texts, tool_outputs, final_state = await asyncio.gather(
        collect_message_texts(),
        collect_tool_outputs(),
        run.output(),
    )

    final_messages = final_state["messages"]
    tool_message = next((m for m in final_messages if isinstance(m, ToolMessage)), None)

    print("run.messages text deltas:", message_texts)
    print("run.tool_calls outputs:", tool_outputs)
    print("final state message roles:", [m.type for m in final_messages])

    if TOOL_RESULT_SENTINEL in message_texts:
        raise AssertionError("Tool result leaked through run.messages.")
    if TOOL_RESULT_SENTINEL not in tool_outputs:
        raise AssertionError("Tool output was not surfaced through run.tool_calls.")
    if tool_message is None or tool_message.tool_call_id != "call_list":
        raise AssertionError("Final state does not contain the expected ToolMessage.")

    print("Reproduction passed: tool output stayed out of run.messages.")


if __name__ == "__main__":
    asyncio.run(main())
```

</details>

<details>
<summary>Current behavior</summary>

```text
run.messages text deltas: ['[]', 'No items found.']
run.tool_calls outputs: [ToolMessage(content='[]', ...)]
final state message roles: ['human', 'ai', 'tool', 'ai']

AssertionError: Tool result leaked through run.messages.
```

</details>

<details>
<summary>Expected behavior</summary>

```text
run.messages text deltas: ['No items found.']
run.tool_calls outputs: ['[]']
final state message roles: ['human', 'ai', 'tool', 'ai']
Reproduction passed: tool output stayed out of run.messages.
```

</details>

## Test plan

- [ ] `uv run --project libs/langgraph pytest
libs/langgraph/tests/test_stream_messages_transformer.py`
- [ ] `uv run --project libs/prebuilt pytest
libs/prebuilt/tests/test_tool_call_transformer.py`
- [ ] `uv run --project libs/langgraph ruff check` (touched files)
- [ ] `uv run --project libs/prebuilt ruff check` (touched files)

Related:
[langchain-ai/langchainjs#10900](https://github.com/langchain-ai/langchainjs/pull/10900)
2026-05-19 11:36:16 -04:00
Nick HollonandGitHub 15113c0f60 fix(prebuilt): scope ToolCallTransformer projection to its own namespace (#7681) 2026-05-01 13:29:42 -04:00
Nick HollonandGitHub f2bd3224f0 feat(langgraph): dispatch stream_events(version='v3') on Pregel (#7677) 2026-05-01 11:30:32 -04:00
Nick HollonandGitHub de9b7c61c3 fix(langgraph): arrival-ordered interleave for StreamChannel projections (#7643) 2026-04-30 10:41:43 -04:00
Eugene YurtsevandGitHub 9c1d65695e fix(prebuilt): default ToolRuntime tools to empty list (#7650)
Makes `ToolRuntime.tools` default to an empty list when not provided,
which avoids requiring callers and tests to pass it explicitly. Adds a
focused regression test covering direct `ToolRuntime` construction
without `tools`.

Created with [Deep Agents
CLI](https://docs.langchain.com/oss/python/deepagents/cli/overview)
using gpt-5.4 (provider: openai).
2026-04-30 01:07:00 +00:00
Nick HollonandGitHub 5af4c5addf refactor(langgraph,prebuilt): merge EventLog into StreamChannel with optional name (#7637) 2026-04-28 18:43:51 -04:00
Nick HollonandGitHub f4388df77f feat(langgraph): add streaming transformer infrastructure and tests (#7519) 2026-04-28 20:29:21 +00:00
f4aee546ad fix(prebuilt): hydrate ToolNode state from channels via pregel helpers (#7594)
## Summary

When `ToolNode` receives a bare `[tool_call]` list via the Send API (the
dispatch shape `create_agent` will use once langchain-ai/langchain#36960
lands), hydrate `ToolRuntime.state` from the current channel values
instead of requiring the dispatcher to inline the full agent state dict
into every `Send.arg`.

Motivation: the paired langchain PR drops the `ToolCallWithContext`
wrapper from `create_agent`'s tool dispatch, which eliminates an O(N²)
storage term on `__pregel_tasks` checkpoint writes. Without this
companion change there would be no path for the tool node to see the
graph state.

## What changed

- `libs/prebuilt/langgraph/prebuilt/tool_node.py` — `_extract_state`
grows a third branch for list-form input. When the input is a list whose
last entry is a `ToolCall` dict, read the current channel values via
`CONFIG_KEY_READ` and return them as the state dict.

The full new logic is four lines inline in `_extract_state`:

```python
read = config.get(CONF, {}).get(CONFIG_KEY_READ)
if read is None:
    return {}
# Pregel installs CONFIG_KEY_READ as
# `functools.partial(local_read, scratchpad, channels, managed, task)`.
channels = read.args[1]
return cast("dict[str, Any]", read(list(channels), False))
```

- No changes to the pregel read machinery (`local_read`, `ChannelRead`).
- Only channel values are read; managed values have their own injection
path (`ToolRuntime.context`, `InjectedContext`) and were never in the
pre-fix inlined state dict, so we don't add them here.
- Falls back to `{}` when invoked outside a Pregel context (e.g. direct
`ToolNode(...).invoke([tool_call])` from a test harness), which
preserves existing `ToolNode` direct-invocation test behavior.

- `libs/prebuilt/tests/test_on_tool_call.py` — two new tests covering
the list-form hydration path (sync + async). They build a
`functools.partial` that matches Pregel's real `CONFIG_KEY_READ` shape
and assert `ToolRuntime.state` reflects the current channel values.

## Why it's safe

- **Same snapshot semantics as before.** `Send` is emitted at
end-of-super-step-N; consumed at start-of-super-step-N+1. Channels at
that point reflect every write from super-step N (including the new
AIMessage the tool calls originated from). Parallel tool tasks in the
tools super-step all read the same values since sibling writes don't
land until end-of-super-step.
- **Legacy `ToolCallWithContext` path preserved.** External dispatchers
that still inline state continue to work unchanged — `_extract_state`
checks that branch first.

## Test plan

- [x] `make test` in `libs/prebuilt` — **204 pass**
- [x] Two new hydration tests (sync + async) green
- [x] `make format` / `make lint` / `mypy` clean

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 09:52:23 -04:00
45246f6c74 feat(prebuilt): allow ToolNode tools to return list[Command | ToolMessage] (#7596)
## Summary

Extends `ToolNode` so that a single tool invocation can return
`list[Command | ToolMessage]` instead of only a single `Command` or
`ToolMessage`. This brings `ToolNode`'s per-tool-call contract in line
with the rest of LangGraph, where nodes can already return multiple
Commands.

Depends on langchain-ai/langchain#36963 which allows
`list[ToolOutputMixin]` to pass through `BaseTool._format_output`
unchanged.

## Changes

### `libs/prebuilt/langgraph/prebuilt/tool_node.py`

**New list-return gate in `_execute_tool_sync` / `_execute_tool_async`**
— After the existing `Command` and `ToolMessage` checks, a new branch
accepts `list[Command | ToolMessage]` and routes it through
`_validate_tool_command_list`. Lists with non-`Command`/`ToolMessage`
elements raise `TypeError`. Both sync and async paths are updated
symmetrically.

**`_validate_tool_command_list`** — Enforces the terminating-ToolMessage
rule: exactly one `ToolMessage` in the list must carry `tool_call_id ==
<outer_id>` (top-level or nested inside a `Command.update["messages"]`).
Zero or multiple terminators raise `_MissingToolMessageError`.
Individual Commands in the list are validated via the existing
`_validate_tool_command`; when a Command lacks the terminator (which is
allowed since the list-level check handles it), the
`_MissingToolMessageError` is caught and the already-normalized command
from the exception is used.

**`_MissingToolMessageError`** — A `ValueError` subclass raised by
`_validate_tool_command` (and `_validate_tool_command_list`) when no
matching `ToolMessage` is found. Carries the already-normalized command
so callers can recover without re-doing deepcopy/message-conversion
work. Using a typed exception avoids brittle string-matching on error
messages.

**`_combine_tool_outputs`** — Flattens list entries at the top of the
method so downstream combiner logic (parent-`goto` accumulation,
ToolMessage wrapping) is unchanged.

**Response processing moved inside try/except** — In both sync and async
execute methods, the response validation (Command/ToolMessage/list
checks) now runs inside the existing error-handling try block, so
validation errors from the list path go through `_handle_tool_errors`
like other tool errors.

**Return type signatures** widened on `_execute_tool_sync`,
`_execute_tool_async`, `_run_one`, `_arun_one` to include `list[Command
| ToolMessage]`.

### `libs/prebuilt/tests/test_tool_node.py`

New tests covering: valid list returns (top-level terminator, nested
terminator, parent-goto + terminator), regression tests for single
Command/ToolMessage returns, invalid cases (no terminator, multiple
terminators), async parity, integration with mixed list/non-list tool
calls, and `_handle_tool_errors` interaction.

---------

Co-authored-by: Sydney Runkle <sydneymarierunkle@gmail.com>
2026-04-23 13:37:45 -07:00
Eugene YurtsevandGitHub b674dd4622 feat(prebuilt): expose available tools on ToolRuntime (#7512) 2026-04-17 16:54:16 -04:00
f093702e4e fix(prebuilt): handle injected NotRequired keys (#7392)
Resolves https://github.com/langchain-ai/langchain/issues/35585

This would previously raise KeyError:
```python
from typing import Annotated

from langchain_core.tools import tool
from langchain.agents import create_agent
from typing_extensions import NotRequired
from langgraph.prebuilt import InjectedState
from langchain.agents import AgentState


class CustomAgentState(AgentState):
    city: NotRequired[str]


@tool
def get_weather(city: Annotated[str | None, InjectedState("city")] = None) -> str:
    """Get weather for a given city."""
    if city is None:
        city = "Boston"
    return f"It's always sunny in {city}!"


agent = create_agent(
    model="claude-sonnet-4-6",
    tools=[get_weather],
    system_prompt="You are a helpful assistant",
    state_schema=CustomAgentState,
)

input_message = {
    "role": "user",
    "content": "What's the weather?",
}

result = agent.invoke({"messages": [input_message]})
for m in result["messages"]:
    m.pretty_print()
```

---------

Co-authored-by: Sydney Runkle <sydneymarierunkle@gmail.com>
2026-04-16 13:06:33 +00:00
Sydney RunkleandGitHub 39288e6111 feat: enhance runtime w/ more execution information (#7363)
## Summary

Enhances `ExecutionInfo` and `Runtime` to surface richer execution
context and introduces `ServerInfo` for LangGraph Server metadata.

### `ExecutionInfo` expansion

Converted from `NamedTuple` to a frozen `dataclass`. Added identity
fields populated during task preparation in `_algo.py`:

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `checkpoint_id` | `str` | required | Checkpoint ID for the current
execution |
| `checkpoint_ns` | `str` | required | Checkpoint namespace for the
current execution |
| `task_id` | `str` | required | Task ID for the current execution |
| `thread_id` | `str \| None` | `None` | Thread ID (None without a
checkpointer) |
| `run_id` | `str \| None` | `None` | Run ID (None when not provided in
config) |
| `node_attempt` | `int` | `1` | Current node execution attempt number
(1-indexed) |
| `node_first_attempt_time` | `float \| None` | `None` | Unix timestamp
for when the first attempt started |

`checkpoint_id`, `checkpoint_ns`, and `task_id` are required (no
defaults) — they are always populated during task preparation in
`_algo.py`. `Runtime.execution_info` is `None` until that point.

### New `ServerInfo` type

Frozen dataclass with `assistant_id: str`, `graph_id: str`, and optional
`user: BaseUser | None`. Populated from config metadata (`assistant_id`,
`graph_id`) and `configurable["langgraph_auth_user"]` via
`_build_server_info()` in `pregel/main.py`.

User detection uses `isinstance(BaseUser)` with a `hasattr("identity")`
fallback — needed because the server's `ProxyUser` provides
`permissions` via `__getattr__`, which Python's `runtime_checkable`
Protocol check doesn't see.

### `Runtime` changes

- `execution_info` is now `ExecutionInfo | None` (default `None`), set
during task prep
- Added `server_info: ServerInfo | None` field, wired through `merge()`
and `override()`

### `ToolNode` / `ToolRuntime` forwarding

`execution_info` and `server_info` are forwarded from `Runtime` to
`ToolRuntime` so tools can access execution and server context.

### New public API surface

```python
from langgraph.runtime import BaseUser, ExecutionInfo, Runtime, ServerInfo, get_runtime
```

## Test plan

- [x] `ExecutionInfo` defaults, patch, and frozen behavior
- [x] Integration tests verifying identity fields are populated in sync
and async execution
- [x] Retry tests confirming identity fields persist and `node_attempt`
increments
- [x] `ServerInfo` construction, frozen behavior, and `Runtime.merge`
precedence
- [x] `server_info` populated from config metadata and
`langgraph_auth_user` (including starlette-style proxy user)
- [x] `server_info` is `None` when no server metadata present
- [x] `ToolRuntime` forwarding of `execution_info` and `server_info`

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-04-02 19:38:03 +00:00
Sydney RunkleandGitHub 57b314c5d7 fix: tool node injection bug (#7391)
ensuring that injected args can only be injected by LC code, not LLMs :)

this does not appear to be an actual security concern because it falls
outside of documented usage, the tool node doesn't inject arbitrary args
2026-04-02 11:19:24 -04:00
b73b2d19eb fix: inject ToolRuntime for dynamically registered tools (#6874)
Fixes https://github.com/langchain-ai/langchain/issues/35305

Co-authored-by: Shivangi Sharma <shivangi.sharma7004@gmail.com>
2026-02-19 17:39:57 +00:00
Sydney RunkleandGitHub c1b3598ca8 feat: support dynamic tool calling via tool override in wrap_model_call (#6711)
Allow overriding `tool` impl in `wrap_tool_call` in order to support
dynamically registered tools via middleware
2026-01-22 15:53:43 +00:00
cb2faa7dda fix(prebuilt): support generic type arguments for ToolRuntime injection (#6509)
**Description:**
This PR fixes an issue where injection types (like `ToolRuntime`) were
not recognized by `ToolNode` when used with generic type arguments
(e.g., `ToolRuntime[MyContext]`).

Previously, the `_is_injection` check relied solely on `isinstance` and
`issubclass`, which fail for `typing._GenericAlias` objects. This update
adds a check using `typing.get_origin()` to correctly identify the base
class of generic types, ensuring the runtime is injected correctly even
when type hints are present.

**Issue:** Fixes #6465

**Dependencies:** None

**Twitter handle:** @SidharthRajmoh2

---------

Co-authored-by: Sydney Runkle <54324534+sydney-runkle@users.noreply.github.com>
2026-01-12 18:43:45 +00:00
Imvikram99andGitHub 87cb509528 feat(checkpoint): Validate checkpointer type at compile time (#6586)
Description: Catch invalid checkpointer objects early by validating any
checkpointer argument before compilation/execution, raising a clear
TypeError that instructs users to pass a proper BaseCheckpointSaver
(e.g., AsyncPostgresSaver) instead of stores like AsyncPostgresStore.
Includes shared validation logic and a regression test so we don’t see
AttributeError: 'AsyncPostgresStore' object has no attribute
'get_next_version' again.

Issue: Fixes #6585

Dependencies: None

Twitter handle: none
2025-12-15 07:22:47 +00:00
Caspar BroekhuizenandGitHub 2284a54b64 fix: interrupt stream mode values (#6475)
This PR improves the consistency of interrupt streaming. 

- when streaming with stream_mode values, the stream chunk now contains
the entire state alongside the interrupt:

```python
class State(TypedDict):
    robot_input: str
# at this point in time robot_input is already set to  "beep boop i am a robot"
app.stream(..., stream_mode="values")
# before
{"__interrupt__": (Interrupt(value="interrupt",))}}
# after
{"robot_input": "beep boop i am a robot", "__interrupt__": (Interrupt(value="interrupt"))}
```

- when streaming with stream_mode=["values", "updates"], interrupts are
surfaced in both an update stream chunk and the value stream chunk, when
previously we keep interrupt in values only if we request values mode
only

```python
class State(TypedDict):
    robot_input: str
# at this point in time robot_input is already set to  "beep boop i am a robot"
app.stream(..., stream_mode=["values", "updates"])
# before (interrupt would only emit on update chunk, there would be no values chunk)
("updates", {"__interrupt__": (Interrupt(value="interrupt",))}})
# after
("updates", {"__interrupt__": (Interrupt(value="interrupt",))}})
("values", {"robot_input": "beep boop i am a robot", "__interrupt__": (Interrupt(value="interrupt"))})
```

For housekeeping: this PR improves on this revert:
https://github.com/langchain-ai/langgraph/pull/6141
2025-11-20 14:23:09 -08:00
Sydney RunkleandGitHub 2164b7daa3 fix: refactor injection logic to respect function signatures (#6468)
## overview

The main purpose of this is to respect tool signatures that request
injected args (like `ToolRuntime`) even when the explicitly specified
`args_schema` does not.

Ex in the following example, we should still inject `runtime` despite
its absence in `ArgsSchema`

```py
class ArgsSchema(BaseModel):
    some_arg: int = Field(...)

@tool(args_schema=ArgsSchema)
def my_tool(some_arg: int, runtime: ToolRuntime): ...
```

This is accompanied by
https://github.com/langchain-ai/langchain/pull/34051 which has tests
that pass w/ this change. This tests injection w/ `create_agent` (more
end to end than tests added in
https://github.com/langchain-ai/langchain/pull/33999.

This unblocks the injection of `ToolRuntime` into MCP tools which is
exciting bc that exposes tool call id and state, which we previously
were unable to do.

## other benefits

* Cleaner code structure w/ more helpful docs about injected args.
* Nice perf boost, we're no longer inspecting the annotations of a
tool's schema 3 different times to detect store, state, and runtime
injections.

## additional notes

1. I could see a world where we want more of this logic to reside on the
tools themselves, but tools don't now about LG specific injection types
(like `ToolRuntime`, hence having this logic here for now).
2. We could separately add validation for the case where something is
specified in `args_schema` and not in the function signature (probably
at the tool level though).
2025-11-20 11:37:54 -05:00
Sydney RunkleandGitHub 6d20a0b9c7 fix: deprecate setattr on ToolCallRequest (#6462)
* one alternative considered was setting `frozen=True` on the dataclass,
but this is breaking, so a deprecation is a nicer approach
2025-11-19 13:12:11 -05:00
Sydney RunkleandGitHub 4ac1c628ee chore: port tool node improvements back to langgraph (#6321)
namespace decisions

```
langgraph.prebuilt
  ├── ToolRuntime  # new
# all of the other stuff that was already there

langgraph.prebuilt.tool_node
  ├── ToolNode
  ├── ToolCallRequest  # new
  ├── ToolRuntime  # new
  ├── InjectedState
  ├── InjectedStore
  ├── ToolCallWrapper
  ├── AsyncToolCallWrapper
  ├── tools_condition
```
```
langchain.tools
  ├── ToolRuntime  # now from langgraph.prebuilt
  ├── InjectedState  # now from langgraph.prebuilt
  ├── InjectedStore  # now from langgraph.prebuilt
  ├── ToolException
  ├── tool
  ├── BaseTool
  ├── InjectedToolArg
  ├── InjectedToolCallId
```
2025-10-29 09:58:06 -07:00
Sydney RunkleandGitHub 2c3e380a35 feat: adding cursory Python 3.14 support (#6298)
* catching error thrown by asyncio
* using 2nd check for annotations given Pydantic 2.12 changes
* skipping tests for remote graph bc langgraph-api is dependent on
`jsonschema-rs`
* skipping tests w/ pydantic v1 models

```bash
hint: This usually indicates a problem with the package or the build environment.
  help: `jsonschema-rs` (v0.29.1) was included because `langgraph:dev` (v1.0.0rc1) depends on `langgraph-cli[inmem]` which
        depends on `langgraph-api` (v0.4.29) which depends on `jsonschema-rs`
```

not yet testing for free threaded python, that'll be much more involved!

ended up separating lint / testing deps during this process bc I was
getting a ton of not required deps while testing that were complicating
things :/
2025-10-17 08:26:52 -04:00
Sydney RunkleandGitHub 2d3121a17c chore: drop Python 3.9 (and syntax) (#6289)
* `strict=False` is the default, pyupgrade to min version 3.10 adds this
to be explicit w/ behavior
2025-10-16 20:17:46 -04:00
8b55dff7a5 chore(deps): upgrade dependencies with uv lock --upgrade (#6146)
This PR updates the dependencies in all Python packages using `uv lock
--upgrade`.

This is an automated PR created by the UV Lock Upgrade workflow.

To make tests pass:
* linting fixes
* whitespace fixes in snapshots

---------

Co-authored-by: sydney-runkle <54324534+sydney-runkle@users.noreply.github.com>
Co-authored-by: Sydney Runkle <sydneymarierunkle@gmail.com>
2025-09-14 19:36:43 -04:00
Sam CrowderandGitHub 16b363fbb0 feat(langgraph): implement redis node level cache (#5834)
###   Description

Adds Redis as a supported cache backend for LangGraph node-level
caching, enabling distributed caching across multiple processes/servers.
This implementation follows the same patterns as existing InMemoryCache
and SqliteCache.

###  Key changes
  - New RedisCache class implementing the BaseCache interface
  - Support for TTL-based expiration and batch operations
  - Worker-specific cache prefixes for parallel test isolation

###  Dependencies

  - redis package (already included in dev dependencies)

### Test Plan

- Unit tests: Added Redis cache tests covering basic operations, TTL,
batch operations, and error handling
- Integration tests: Redis cache integrated into existing LangGraph test
suite, tested with all checkpointer combinations
2025-08-11 09:19:34 -07:00
4571b708d9 fix(langgraph): support emitting messages from subgraphs when messages mode explicitly requested (#5836)
Reproduces:
https://github.com/langchain-ai/langgraph/issues/5249#issuecomment-3156519635
Caused after this change:
https://github.com/langchain-ai/langgraph/pull/4843

Fix to allow emitting messages from subgraphs if the subgraphs
explicitly used a stream mode "messages".

```python

def node_in_parent(...):
   # subgraph was called as a function.
   # messages are explicitly requested.
   for event in subgraph.stream(..., stream_mode="messages"):
      # something is done with `event`
   return ...

# subgraphs = False!
parent_graph.invoke(..., subgraphs=False)
```

The code above should continue to work correctly regardless of the value
of subgraphs as streaming messages was requested explicitly in the
parent node!

---------

Co-authored-by: Sydney Runkle <54324534+sydney-runkle@users.noreply.github.com>
2025-08-07 10:10:52 -04:00
Sydney RunkleandGitHub e365b2b8bd fix(prebuilt): raise on additional deprecated kwargs (#5848) 2025-08-06 21:08:42 +00:00
Sydney RunkleandGitHub 781a115f92 fix(prebuilt): assign context_schema to config_schema with correct condition (#5746) 2025-07-30 20:08:45 +00:00
8cea8ae1de fix(prebuilt): update ToolNode to allow Command update to remove all messages (#5678)
## Description

Previously, when a tool returned `Command` to update the graph's state,
the `_validate_tool_command` method in `ToolNode` would raise a
`ValueError` if the `messages_update` list contained only a
`RemoveMessage(id=REMOVE_ALL_MESSAGES)` object. This was because the
validation logic expected a matching `ToolMessage` for the tool call and
did not account for this specific state-clearing scenario.

This commit modifies the validation logic to check if the
`messages_update` list contains a single
`RemoveMessage(id=REMOVE_ALL_MESSAGES)` element. If this condition is
met, the `ToolMessage` validation is bypassed, allowing a tool to clear
the entire message history without causing a validation error.

A new test case, `test_tool_node_command_remove_all_messages`, has been
added to `tests/test_tool_node.py` to verify this change and prevent
future regressions.

## Example

Here is a self-contained example that illustrates the problem and the
fix. Without this change, the code block for `Example 2` would raise a
`ValueError`.

```python
from typing import Annotated, List

from langchain_core.messages import (
    AIMessage,
    AnyMessage,
    HumanMessage,
    RemoveMessage,
    ToolMessage,
)
from langchain_core.tools import InjectedToolCallId, tool
from langchain_openai import ChatOpenAI
from langgraph.graph import END, StateGraph, add_messages
from langgraph.graph.message import REMOVE_ALL_MESSAGES
from langgraph.prebuilt import InjectedState, ToolNode
from langgraph.types import Command
from pydantic import BaseModel, Field


# Agent state tracks current and all messages
class AgentState(BaseModel):
    messages: Annotated[List[AnyMessage], add_messages] = Field(
        default_factory=list, description="Current conversation messages."
    )
    all_messages: Annotated[List[AnyMessage], add_messages] = Field(
        default_factory=list, description="All messages, including removed ones."
    )


# Tool to clear history if long enough, otherwise returns a warning
@tool
def clear_history_tool(
    state: Annotated[AgentState, InjectedState],
    tool_call_id: Annotated[str, InjectedToolCallId],
):
    """Clears message history if it's long enough."""
    if len(state.messages) < 3:
        return Command(
            update={
                "messages": [
                    ToolMessage(
                        "History is not long enough to be cleared. Please try again.",
                        tool_call_id=tool_call_id,
                    )
                ]
            }
        )
    else:
        return Command(
            update={
                "messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES)],
                "all_messages": state.messages
                + [
                    ToolMessage(
                        "History has been successfully cleared.",
                        tool_call_id=tool_call_id,
                    )
                ],
            }
        )


# Bind the tool to the model
model = ChatOpenAI(model="gpt-4o-mini").bind_tools([clear_history_tool])


def model_node(state: AgentState):
    return {"messages": [model.invoke(state.messages)]}


# Build the agent graph
graph_builder = StateGraph(AgentState)
graph_builder.add_node("model", model_node)
graph_builder.add_node("tools", ToolNode([clear_history_tool]))
graph_builder.set_entry_point("model")
graph_builder.add_edge("model", "tools")
graph_builder.add_edge("tools", END)
graph = graph_builder.compile()


def print_messages(header, messages):
    print(f"\n{header}")
    for message in messages:
        message.pretty_print()


### Example 1: Not enough history to clear
state_1 = AgentState(
    messages=[HumanMessage(content="Please clear my message history.")]
)
output_1 = graph.invoke(state_1)
print_messages("First call: State 'messages'", output_1["messages"])
print_messages("First call: State 'all_messages'", output_1["all_messages"])

### Example 2: History is cleared
state_2 = AgentState(
    messages=[
        HumanMessage(content="Will this PR get merged?"),
        AIMessage(content="Maybe, if it's good enough."),
        HumanMessage(content="Please clear my message history."),
    ]
)
# Without the changes in this PR, the following line will raise a ValueError
output_2 = graph.invoke(state_2)
print_messages("Second call: State 'messages'", output_2["messages"])
print_messages("Second call: State 'all_messages'", output_2["all_messages"])
```

### Outputs

*Without the changes in this PR:*

```
First call: State 'messages'
================================ Human Message =================================

Please clear my message history.
================================== Ai Message ==================================
Tool Calls:
  clear_history_tool (ba421ac3-1e1a-4208-a8f6-c5500ee0abcc)
 Call ID: ba421ac3-1e1a-4208-a8f6-c5500ee0abcc
  Args:
================================= Tool Message =================================
Name: clear_history_tool

History is not long enough to be cleared. Please try again.

First call: State 'all_messages'


Traceback (most recent call last):
  File "main.py", line 114, in <module>
    output_2 = graph.invoke(state_2)
               ^^^^^^^^^^^^^^^^^^^^^
  File ".venv/lib/python3.11/site-packages/langgraph/pregel/__init__.py", line 2844, in invoke
    for chunk in self.stream(
  File ".venv/lib/python3.11/site-packages/langgraph/pregel/__init__.py", line 2534, in stream
    for _ in runner.tick(
  File ".venv/lib/python3.11/site-packages/langgraph/prebuilt/tool_node.py", line 241, in _func
    outputs = [
              ^
  File ".venv/lib/python3.11/concurrent/futures/_base.py", line 619, in result_iterator
    yield _result_or_cancel(fs.pop())
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".venv/lib/python3.11/concurrent/futures/_base.py", line 317, in _result_or_cancel
    return fut.result(timeout)
           ^^^^^^^^^^^^^^^^^^^
  File ".venv/lib/python3.11/concurrent/futures/_base.py", line 449, in result
    return self.__get_result()
           ^^^^^^^^^^^^^^^^^^^
  File ".venv/lib/python3.11/concurrent/futures/_base.py", line 401, in __get_result
    raise self._exception
  File ".venv/lib/python3.11/concurrent/futures/thread.py", line 58, in run
    result = self.fn(*self.args, **self.kwargs)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".venv/lib/python3.11/site-packages/langchain_core/runnables/config.py", line 555, in _wrapped_fn
    return contexts.pop().run(fn, *args)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".venv/lib/python3.11/site-packages/langgraph/prebuilt/tool_node.py", line 353, in _run_one
    return self._validate_tool_command(response, call, input_type)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".venv/lib/python3.11/site-packages/langgraph/prebuilt/tool_node.py", line 616, in _validate_tool_command
    raise ValueError(
ValueError: Expected to have a matching ToolMessage in Command.update for tool 'clear_history_tool', got: [RemoveMessage(content='', additional_kwargs={}, response_metadata={}, id='__remove_all__')]. Every tool call (LLM requesting to call a tool) in the message history MUST have a corresponding ToolMessage. You can fix it by modifying the tool to return `Command(update={"messages": [ToolMessage("Success", tool_call_id=tool_call_id), ...]}, ...)`.
```

*With the changes in this PR:*

```
First call: State 'messages'
================================ Human Message =================================

Please clear my message history.
================================== Ai Message ==================================
Tool Calls:
  clear_history_tool (ba421ac3-1e1a-4208-a8f6-c5500ee0abcc)
 Call ID: ba421ac3-1e1a-4208-a8f6-c5500ee0abcc
  Args:
================================= Tool Message =================================
Name: clear_history_tool

History is not long enough to be cleared. Please try again.

First call: State 'all_messages'


Second call: State 'messages'

Second call: State 'all_messages'
================================ Human Message =================================

Will this PR get merged?
================================== Ai Message ==================================

Maybe, if it's good enough.
================================ Human Message =================================

Please clear my message history.
================================== Ai Message ==================================
Tool Calls:
  clear_history_tool (499b1be3-6df1-493f-85e5-8d7e429dead8)
 Call ID: 499b1be3-6df1-493f-85e5-8d7e429dead8
  Args:
================================= Tool Message =================================
Name: clear_history_tool

History has been successfully cleared.
```

## Twitter handle

[@samuelpullely](https://x.com/samuelpullely)

---------

Co-authored-by: Eugene Yurtsev <eyurtsev@gmail.com>
2025-07-29 20:30:42 +00:00
f6aa19709e feat(prebuilt): Add dynamic model to create_react_agent (#5651)
This PR allows a developer to change the model configuration at run time based on context. This includes that list of tools available to the model to call.

```python
def create_react_agent(
    model: Union[
        str, 
	LanguageModelLike,
        Callable[[SateLike, Runtime...], BaseChatModel], # <--- New
    ],
    tools: Union[
      Sequence[Union[BaseTool, Callable, dict[str, Any]]], ToolNode]
    ],
    *,
....


llm = init_chat_model(...)

def prepare_model(state, runtime):
   selected_tool_names = func(state, context)
   return llm.bind(tools=selected_tool_names)

create_react_agent(
  prepare_model,
  tools=all_known_tools
)
```

## Semantics

1. `tools` = are the known tools, used to configure ToolNode and will
configure:
    1. model provided as string
    2. model provided as BaseChatModel (if it has no tools bound to it)
2. If a user provides a dynamic model (callable), the user is
responsible for binding tools


Alternative considered:

1. Passing `Callable[[SateLike, Config...], list[BaseTool]]` to tools
2. Passing `Callable[[SateLike, Config...], list[str]]` to a tool
selector

Both have the issue that there's non obvious interplay between tool
selection and dynamic models. (i.e., if we want to introduce dynamic
models at in the future, the API will become tricky to explain)

---------

Co-authored-by: Sydney Runkle <54324534+sydney-runkle@users.noreply.github.com>
2025-07-25 14:48:15 -04:00
Sydney RunkleandGitHub 90ba4c5205 Merge branch 'main' into v1 2025-07-21 10:06:23 -04:00
Eugene YurtsevandGitHub f63bec8578 chore(prebuilt): restructure tool node and tool injection logic (#5562)
* Cleaning up the underlying tool injection logic which is happening in
multiple locations.
* State was being injected into the ToolCall via Send in two places in
create react agent and the logic doesn't belong there, the actual
injection should be happening inside the ToolNode where there's
awareness of what run time parameters the tool accepts.

Change is required to unblock:
https://github.com/langchain-ai/langgraph/pull/5537
2025-07-18 09:59:14 -04:00
open-swe[bot]GitHubopen-swe-dev[bot] <open-swe-dev@users.noreply.github.com>Eugene Yurtsev
f87608a16f chore(prebuilt): Remove dead code from prebuilt tests (#5555)
Fixes #5554

This PR removes unused utility classes and functions from the prebuilt
tests directory to clean up dead code.

Changes include:
- Removed unused classes from `libs/prebuilt/tests/any_str.py`:
  - Deleted FloatBetween, AnyDict, AnyVersion, and UnsortedSequence
  - Kept only AnyStr class

- Removed unused functions from `libs/prebuilt/tests/messages.py`:
  - Deleted _AnyIdDocument and _AnyIdAIMessageChunk
  - Kept _AnyIdHumanMessage and _AnyIdToolMessage

- Removed unused classes from `libs/prebuilt/tests/memory_assert.py`:
- Deleted NoopSerializer, MemorySaverAssertCheckpointMetadata, and
MemorySaverNoPending
  - Kept MemorySaverAssertImmutable

Verification:
- Manually checked for no remaining references to removed code
- Maintained existing import structures
- Preserved functionality of the prebuilt test suite

The changes reduce code complexity and remove unnecessary utility
classes that were not being used in the test suite.

---------

Co-authored-by: open-swe-dev[bot] <open-swe-dev@users.noreply.github.com>
Co-authored-by: Eugene Yurtsev <eyurtsev@gmail.com>
2025-07-17 18:45:10 +00:00
Sydney Runkle c6d674cd3e Merge branch 'main' into v1 2025-07-16 18:32:07 -04:00
Eugene YurtsevandGitHub d64447c4c2 chore(prebuilt): Allow testing fast (#5533)
Allow testing fast
2025-07-16 15:56:28 +00:00
Sydney RunkleandGitHub d1710e2eac change[langgraph]: clean up Interrupt interface for v1 (#5405) 2025-07-09 14:03:08 -04:00
Sydney RunkleandGitHub 8c4e698c5a langgraph[change]: solidify public/private differentiations (#5252)
* public interfaces for channels
* public interfaces for func
* public interfaces for graph
* pi for managed
* first pass public interface for top level modules
* first pass at private for utils -> _internal
* private interface for pregel
* scratchpad/stream protocol move
* docs update
* backwards compat for runnable
* deprecation warning for send and interrupt
* deprecation for pregel import
2025-07-02 16:48:53 -04:00
William FHandGitHub 7fda427601 fix: [prebuilt] Checks for pre-bound model with builtin tools (#5203) 2025-06-26 01:34:21 +00:00
Nuno Campos a1c856c088 Reduce extraneous keys in checkpoint.metadata
- Leave it up to each checkpointer implementation to decide whether to merge in configurable/metadata (previously PregelLoop would do some of this always)
- Never copy over internal langgraph keys into checkpoint.metadata (these are redundant/misleading to include)
2025-06-17 17:40:18 -07:00
Nuno CamposandGitHub 1134017d07 Preparation for 0.5 release: langgraph-checkpoint (#5124)
Prepare langgraph-checkpoint for 0.5

- Given we have no upper bound on langgraph-checkpoint dep need to undo all changes in langgraph-checkpoint that might break previous versions of langgraph
2025-06-16 21:57:11 +00:00
Sydney RunkleandGitHub 0171e9a323 fix(langgraph): remove deprecated output usage in favor of output_schema (#5095)
use output_schema
2025-06-13 12:34:39 -04:00
Nuno CamposandGitHub 76661f4c9c Remove Checkpoint.writes (#4822) 2025-05-28 14:04:45 -07:00
Nuno CamposandGitHub c32677c92d Remove old checkpoint test fixtures (#4814) 2025-05-28 14:03:12 -07:00
Sydney RunkleandGitHub 19ca6b416b fix: post_model_hook should inject state + store into tool calls if necessary (#4842) 2025-05-27 17:42:20 +00:00
Nuno Campos 936176eb21 Remove Checkpoint.writes
- This has been superseded by saving the individual writes of each task through put_writes()
- Removing this speeds up checkpoint operations as it was duplicating data saved elsewhere already
2025-05-25 19:06:46 -07:00
Nuno Campos bc17c3522b Remove old checkpoint test fixtures
- Now all tests fully migrated to more recent sync_checkpointer and async_checkpointer fixtures for parametrising on checkpointer
- Use sync/async_store fixtures where tests used only in memory store
- Remove unused "should snapshot" check for older versions of langchain core no longer tested against
2025-05-24 14:27:13 -07:00
Sydney RunkleandGitHub 87fa661ac0 temp: remove ToolInterruptNode code (#4792)
remove tool node specific code for post_model_hook release
2025-05-22 13:28:59 -04:00
Sydney RunkleandGitHub 06607e08ea prebuilts hitl: fix branching logic + add structural snapshot tests (#4767) 2025-05-21 09:29:59 -04:00