This commit is contained in:
kgod
2026-05-26 21:02:17 +08:00
commit 8697477a53
10000 changed files with 1541403 additions and 0 deletions
@@ -0,0 +1,23 @@
"""langgraph.prebuilt exposes a higher-level API for creating and executing agents and tools."""
from langgraph.prebuilt._tool_call_transformer import ToolCallTransformer
from langgraph.prebuilt.chat_agent_executor import create_react_agent
from langgraph.prebuilt.tool_node import (
InjectedState,
InjectedStore,
ToolNode,
ToolRuntime,
tools_condition,
)
from langgraph.prebuilt.tool_validator import ValidationNode
__all__ = [
"create_react_agent",
"ToolNode",
"ToolCallTransformer",
"tools_condition",
"ValidationNode",
"InjectedState",
"InjectedStore",
"ToolRuntime",
]
@@ -0,0 +1,117 @@
"""In-process handle for a single tool call's streaming execution.
Mirrors the shape of `ChatModelStream` from langchain-core but simpler —
a tool has one output channel, no content-block multiplexing. Populated
by `ToolCallTransformer` as `tool-started` / `tool-output-delta` /
`tool-finished` / `tool-error` events flow in on the `tools` channel.
"""
from __future__ import annotations
from collections.abc import AsyncIterator, Iterator
from typing import Any
from langgraph.stream.stream_channel import StreamChannel
class ToolCallStream:
"""Scoped view of a single tool call's lifecycle.
Yielded on `run.tool_calls` once per `tool-started` event. Fields
are populated as events arrive:
- `tool_call_id`, `tool_name`, `input`: stable from the start event.
- `output_deltas`: a `StreamChannel` of delta chunks. Iterate (sync or
async) to consume partial output in arrival order.
- `output`: terminal payload from `tool-finished`, or `None` if the
call failed or is still in flight.
- `error`: terminal error string from `tool-error`, or `None` if the
call succeeded or is still in flight.
- `completed`: True once a terminal event (`tool-finished` or
`tool-error`) has been observed.
`ToolCallStream` is not meant to be constructed by end users — it's
produced by `ToolCallTransformer` as events flow through the mux.
"""
def __init__(
self,
tool_call_id: str,
tool_name: str,
input: dict[str, Any] | None = None,
) -> None:
"""Initialize a fresh handle for a tool call.
Args:
tool_call_id: The `tool_call_id` from the AIMessage.
tool_name: The tool's name.
input: The tool's input arguments (as reported by
`on_tool_start`), or `None` if none were captured.
"""
self.tool_call_id = tool_call_id
self.tool_name = tool_name
self.input = input
self._output_deltas: StreamChannel[Any] = StreamChannel()
self.output: Any = None
self.error: str | None = None
self.completed = False
@property
def output_deltas(self) -> StreamChannel[Any]:
"""The channel of streamed `tool-output-delta` payloads.
Iterate (sync or async depending on how the run was started)
to consume partial output in arrival order. The log closes when
the tool finishes or errors.
"""
return self._output_deltas
def _bind(self, *, is_async: bool) -> None:
"""Bind the deltas log to sync or async iteration.
Called by `ToolCallTransformer` when constructing this handle so
the log matches the enclosing mux's mode.
"""
self._output_deltas._bind(is_async=is_async)
def _push_delta(self, delta: Any) -> None:
self._output_deltas.push(delta)
def _finish(self, output: Any) -> None:
self.output = output
self.completed = True
self._output_deltas.close()
def _fail(self, message: str) -> None:
self.error = message
self.completed = True
self._output_deltas.close()
def __iter__(self) -> Iterator[Any]:
"""Iterate delta chunks synchronously.
Equivalent to `iter(self.output_deltas)`. Raises `TypeError` if
the underlying log is bound to async mode.
"""
return iter(self._output_deltas)
def __aiter__(self) -> AsyncIterator[Any]:
"""Iterate delta chunks asynchronously.
Equivalent to `aiter(self.output_deltas)`. Raises `TypeError`
if the underlying log is bound to sync mode.
"""
return self._output_deltas.__aiter__()
def __repr__(self) -> str:
status = (
"completed"
if self.completed and self.error is None
else "failed"
if self.completed
else "running"
)
return (
f"ToolCallStream(tool_call_id={self.tool_call_id!r}, "
f"tool_name={self.tool_name!r}, status={status})"
)
@@ -0,0 +1,135 @@
"""Transformer that projects `tools` channel events into `ToolCallStream`s."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import Any
from langgraph.stream._types import ProtocolEvent, StreamTransformer
from langgraph.stream.stream_channel import StreamChannel
from langgraph.prebuilt._tool_call_stream import ToolCallStream
class ToolCallTransformer(StreamTransformer):
"""Project `tools` channel events into `ToolCallStream` handles.
Each `tool-started` event spawns a `ToolCallStream`, pushed onto
`run.tool_calls`. Subsequent `tool-output-delta` events append to
that stream's deltas log; `tool-finished` and `tool-error` close it.
Native transformer — the `tool_calls` projection is exposed as a
direct attribute on the run stream.
A nameless `StreamChannel[ToolCallStream]` is used (no protocol
auto-forwarding) because the live handles are not serializable and
should not be injected into the main event log. Wire consumers
subscribe to the `tools` channel instead, where the raw protocol
events flow through untouched by this transformer (`process`
returns `True`).
Registered explicitly by users at compile time via
`builder.compile(transformers=[ToolCallTransformer])` — not a
default built-in, so the `tools` channel is user-opt-in.
"""
_native = True
required_stream_modes = ("tools",)
def __init__(self, scope: tuple[str, ...] = ()) -> None:
super().__init__(scope)
self._log: StreamChannel[ToolCallStream] = StreamChannel()
self._active: dict[str, ToolCallStream] = {}
self._is_async = False
self._pump_fn: Callable[[], bool] | None = None
self._apump_fn: Callable[[], Awaitable[bool]] | None = None
def init(self) -> dict[str, Any]:
return {"tool_calls": self._log}
def _bind_pump(self, fn: Callable[[], bool]) -> None:
"""Wire the sync pull callback onto this transformer.
Called by `StreamMux.bind_pump`. Stored so each new
`ToolCallStream` created by `process` can wire its deltas log
for pump-driven iteration.
"""
self._pump_fn = fn
self._is_async = False
def _bind_apump(self, fn: Callable[[], Awaitable[bool]]) -> None:
"""Async counterpart to `_bind_pump`."""
self._apump_fn = fn
self._is_async = True
def _new_stream(
self,
tool_call_id: str,
tool_name: str,
tool_input: dict[str, Any] | None,
) -> ToolCallStream:
stream = ToolCallStream(tool_call_id, tool_name, tool_input)
stream._bind(is_async=self._is_async)
if self._apump_fn is not None:
stream._output_deltas._arequest_more = self._apump_fn
if self._pump_fn is not None:
stream._output_deltas._request_more = self._pump_fn
return stream
def process(self, event: ProtocolEvent) -> bool:
if event["method"] != "tools":
return True
# Only project events emitted at this transformer's scope. Subgraph
# events still flow through the parent's mux (the parent's main
# event log keeps them) but they belong to the child mini-mux's
# `tool_calls` projection, not the parent's.
if tuple(event["params"]["namespace"]) != self.scope:
return True
data = event["params"]["data"]
tool_call_id = data.get("tool_call_id")
if tool_call_id is None:
return True
event_type = data.get("event")
stream: ToolCallStream | None
if event_type == "tool-started":
stream = self._new_stream(
tool_call_id,
data.get("tool_name", ""),
data.get("input"),
)
self._active[tool_call_id] = stream
self._log.push(stream)
elif event_type == "tool-output-delta":
stream = self._active.get(tool_call_id)
if stream is not None:
stream._push_delta(data.get("delta"))
elif event_type == "tool-finished":
stream = self._active.pop(tool_call_id, None)
if stream is not None:
stream._finish(data.get("output"))
elif event_type == "tool-error":
stream = self._active.pop(tool_call_id, None)
if stream is not None:
stream._fail(data.get("message", ""))
# Pass-through — wire consumers subscribe to the `tools` channel
# directly and reconstruct handles client-side.
return True
def finalize(self) -> None:
"""Close any still-active tool streams left open at run end."""
for stream in self._active.values():
if not stream.completed:
stream._finish(None)
self._active.clear()
def fail(self, err: BaseException) -> None:
"""Fail any still-active tool streams when the run errors."""
message = str(err)
for stream in self._active.values():
if not stream.completed:
stream._fail(message)
self._active.clear()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,105 @@
from typing import Literal
from langgraph.warnings import LangGraphDeprecatedSinceV10
from typing_extensions import TypedDict, deprecated
@deprecated(
"HumanInterruptConfig has been moved to `langchain.agents.interrupt`. Please update your import to `from langchain.agents.interrupt import HumanInterruptConfig`.",
category=LangGraphDeprecatedSinceV10,
)
class HumanInterruptConfig(TypedDict):
"""Configuration that defines what actions are allowed for a human interrupt.
This controls the available interaction options when the graph is paused for human input.
Attributes:
allow_ignore: Whether the human can choose to ignore/skip the current step
allow_respond: Whether the human can provide a text response/feedback
allow_edit: Whether the human can edit the provided content/state
allow_accept: Whether the human can accept/approve the current state
"""
allow_ignore: bool
allow_respond: bool
allow_edit: bool
allow_accept: bool
@deprecated(
"ActionRequest has been moved to `langchain.agents.interrupt`. Please update your import to `from langchain.agents.interrupt import ActionRequest`.",
category=LangGraphDeprecatedSinceV10,
)
class ActionRequest(TypedDict):
"""Represents a request for human action within the graph execution.
Contains the action type and any associated arguments needed for the action.
Attributes:
action: The type or name of action being requested (e.g., `"Approve XYZ action"`)
args: Key-value pairs of arguments needed for the action
"""
action: str
args: dict
@deprecated(
"HumanInterrupt has been moved to `langchain.agents.interrupt`. Please update your import to `from langchain.agents.interrupt import HumanInterrupt`.",
category=LangGraphDeprecatedSinceV10,
)
class HumanInterrupt(TypedDict):
"""Represents an interrupt triggered by the graph that requires human intervention.
This is passed to the `interrupt` function when execution is paused for human input.
Attributes:
action_request: The specific action being requested from the human
config: Configuration defining what actions are allowed
description: Optional detailed description of what input is needed
Example:
```python
# Extract a tool call from the state and create an interrupt request
request = HumanInterrupt(
action_request=ActionRequest(
action="run_command", # The action being requested
args={"command": "ls", "args": ["-l"]} # Arguments for the action
),
config=HumanInterruptConfig(
allow_ignore=True, # Allow skipping this step
allow_respond=True, # Allow text feedback
allow_edit=False, # Don't allow editing
allow_accept=True # Allow direct acceptance
),
description="Please review the command before execution"
)
# Send the interrupt request and get the response
response = interrupt([request])[0]
```
"""
action_request: ActionRequest
config: HumanInterruptConfig
description: str | None
class HumanResponse(TypedDict):
"""The response provided by a human to an interrupt, which is returned when graph execution resumes.
Attributes:
type: The type of response:
- `'accept'`: Approves the current state without changes
- `'ignore'`: Skips/ignores the current step
- `'response'`: Provides text feedback or instructions
- `'edit'`: Modifies the current state/content
args: The response payload:
- `None`: For ignore/accept actions
- `str`: For text responses
- `ActionRequest`: For edit actions with updated content
"""
type: Literal["accept", "ignore", "response", "edit"]
args: None | str | ActionRequest
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,221 @@
"""This module provides a ValidationNode class that can be used to validate tool calls
in a langchain graph. It applies a pydantic schema to tool_calls in the models' outputs,
and returns a ToolMessage with the validated content. If the schema is not valid, it
returns a ToolMessage with the error message. The ValidationNode can be used in a
StateGraph with a "messages" key. If multiple tool calls are requested, they will be run in parallel.
"""
from collections.abc import Callable, Sequence
from typing import (
Any,
cast,
)
from langchain_core.messages import (
AIMessage,
AnyMessage,
ToolCall,
ToolMessage,
)
from langchain_core.runnables import (
RunnableConfig,
)
from langchain_core.runnables.config import get_executor_for_config
from langchain_core.tools import BaseTool, create_schema_from_function
from langchain_core.utils.pydantic import is_basemodel_subclass
from langgraph._internal._runnable import RunnableCallable
from langgraph.warnings import LangGraphDeprecatedSinceV10
from pydantic import BaseModel, ValidationError
from pydantic.v1 import BaseModel as BaseModelV1
from pydantic.v1 import ValidationError as ValidationErrorV1
from typing_extensions import deprecated
def _default_format_error(
error: BaseException,
call: ToolCall,
schema: type[BaseModel] | type[BaseModelV1],
) -> str:
"""Default error formatting function."""
return f"{repr(error)}\n\nRespond after fixing all validation errors."
@deprecated(
"ValidationNode is deprecated. Please use `create_agent` from `langchain.agents` with custom tool error handling.",
category=LangGraphDeprecatedSinceV10,
)
class ValidationNode(RunnableCallable):
"""A node that validates all tools requests from the last `AIMessage`.
It can be used either in `StateGraph` with a `'messages'` key.
!!! note
This node does not actually **run** the tools, it only validates the tool calls,
which is useful for extraction and other use cases where you need to generate
structured output that conforms to a complex schema without losing the original
messages and tool IDs (for use in multi-turn conversations).
Returns:
(Union[Dict[str, List[ToolMessage]], Sequence[ToolMessage]]): A list of
`ToolMessage` objects with the validated content or error messages.
Example:
```python title="Example usage for re-prompting the model to generate a valid response:"
from typing import Literal, Annotated
from typing_extensions import TypedDict
from langchain_anthropic import ChatAnthropic
from pydantic import BaseModel, field_validator
from langgraph.graph import END, START, StateGraph
from langgraph.prebuilt import ValidationNode
from langgraph.graph.message import add_messages
class SelectNumber(BaseModel):
a: int
@field_validator("a")
def a_must_be_meaningful(cls, v):
if v != 37:
raise ValueError("Only 37 is allowed")
return v
builder = StateGraph(Annotated[list, add_messages])
llm = ChatAnthropic(model="claude-3-5-haiku-latest").bind_tools([SelectNumber])
builder.add_node("model", llm)
builder.add_node("validation", ValidationNode([SelectNumber]))
builder.add_edge(START, "model")
def should_validate(state: list) -> Literal["validation", "__end__"]:
if state[-1].tool_calls:
return "validation"
return END
builder.add_conditional_edges("model", should_validate)
def should_reprompt(state: list) -> Literal["model", "__end__"]:
for msg in state[::-1]:
# None of the tool calls were errors
if msg.type == "ai":
return END
if msg.additional_kwargs.get("is_error"):
return "model"
return END
builder.add_conditional_edges("validation", should_reprompt)
graph = builder.compile()
res = graph.invoke(("user", "Select a number, any number"))
# Show the retry logic
for msg in res:
msg.pretty_print()
```
"""
def __init__(
self,
schemas: Sequence[BaseTool | type[BaseModel] | Callable],
*,
format_error: Callable[[BaseException, ToolCall, type[BaseModel]], str]
| None = None,
name: str = "validation",
tags: list[str] | None = None,
) -> None:
"""Initialize the ValidationNode.
Args:
schemas: A list of schemas to validate the tool calls with. These can be
any of the following:
- A pydantic BaseModel class
- A BaseTool instance (the args_schema will be used)
- A function (a schema will be created from the function signature)
format_error: A function that takes an exception, a ToolCall, and a schema
and returns a formatted error string. By default, it returns the
exception repr and a message to respond after fixing validation errors.
name: The name of the node.
tags: A list of tags to add to the node.
"""
super().__init__(self._func, None, name=name, tags=tags, trace=False)
self._format_error = format_error or _default_format_error
self.schemas_by_name: dict[str, type[BaseModel]] = {}
for schema in schemas:
if isinstance(schema, BaseTool):
if schema.args_schema is None:
raise ValueError(
f"Tool {schema.name} does not have an args_schema defined."
)
elif not isinstance(
schema.args_schema, type
) or not is_basemodel_subclass(schema.args_schema):
raise ValueError(
"Validation node only works with tools that have a pydantic BaseModel args_schema. "
f"Got {schema.name} with args_schema: {schema.args_schema}."
)
self.schemas_by_name[schema.name] = schema.args_schema
elif isinstance(schema, type) and issubclass(
schema, (BaseModel, BaseModelV1)
):
self.schemas_by_name[schema.__name__] = cast(type[BaseModel], schema)
elif callable(schema):
base_model = create_schema_from_function("Validation", schema)
self.schemas_by_name[schema.__name__] = base_model
else:
raise ValueError(
f"Unsupported input to ValidationNode. Expected BaseModel, tool or function. Got: {type(schema)}."
)
def _get_message(
self, input: list[AnyMessage] | dict[str, Any]
) -> tuple[str, AIMessage]:
"""Extract the last AIMessage from the input."""
if isinstance(input, list):
output_type = "list"
messages: list = input
elif messages := input.get("messages", []):
output_type = "dict"
else:
raise ValueError("No message found in input")
message: AnyMessage = messages[-1]
if not isinstance(message, AIMessage):
raise ValueError("Last message is not an AIMessage")
return output_type, message
def _func(
self, input: list[AnyMessage] | dict[str, Any], config: RunnableConfig
) -> Any:
"""Validate and run tool calls synchronously."""
output_type, message = self._get_message(input)
def run_one(call: ToolCall) -> ToolMessage:
schema = self.schemas_by_name[call["name"]]
try:
if issubclass(schema, BaseModel):
output = schema.model_validate(call["args"])
content = output.model_dump_json()
elif issubclass(schema, BaseModelV1):
output = schema.validate(call["args"])
content = output.json()
else:
raise ValueError(
f"Unsupported schema type: {type(schema)}. Expected BaseModel or BaseModelV1."
)
return ToolMessage(
content=content,
name=call["name"],
tool_call_id=cast(str, call["id"]),
)
except (ValidationError, ValidationErrorV1) as e:
return ToolMessage(
content=self._format_error(e, call, schema),
name=call["name"],
tool_call_id=cast(str, call["id"]),
additional_kwargs={"is_error": True},
)
with get_executor_for_config(config) as executor:
outputs = [*executor.map(run_one, message.tool_calls)]
if output_type == "list":
return outputs
else:
return {"messages": outputs}