Compare commits

..
110 Commits
Author SHA1 Message Date
Eugene YurtsevandGitHub 72e418e4d0 release(prebuilt): 0.6.1 (#5713)
Release 0.6.1 allowing ToolNode to handle Command that removes all
messages
2025-07-29 20:39:42 +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
Sydney RunkleandGitHub 85d2c7623a release(langgraph): 0.6.1 (#5712) 2025-07-29 20:25:49 +00:00
Sydney RunkleandGitHub 7436777e7d fix(langgraph): enforce config injection even when optional (#5708) 2025-07-29 16:11:36 -04:00
Sydney Runkle 479373bd81 lint 2025-07-29 15:19:44 -04:00
Sydney Runkle dd91819c92 enforce config injection 2025-07-29 15:13:51 -04:00
Sydney RunkleandGitHub b07964c98e fix(langgraph): always use parent runtime info if available (#5707) 2025-07-29 15:05:54 -04:00
Sydney Runkle 163d14f812 typo 2025-07-29 14:59:14 -04:00
Sydney Runkle c0185f04e5 more robust tests 2025-07-29 14:58:16 -04:00
Sydney Runkle 82b31c9ffd nits 2025-07-29 14:54:58 -04:00
Sydney Runkle aade865727 remove unintentional import 2025-07-29 14:50:11 -04:00
Sydney Runkle 184bcacb53 use parent runtime 2025-07-29 14:47:05 -04:00
Eugene YurtsevandGitHub d68bac3865 chore(docs): Support custom link titles (#5706)
Support custom link titles for custom link syntax
2025-07-29 14:17:03 -04:00
Sydney RunkleandGitHub 824c309035 docs: update context conceptual page (#5696) 2025-07-29 11:06:08 -04:00
Sydney Runkle fdfd06056e move tip 2025-07-29 11:00:40 -04:00
469ebd3492 Apply suggestions from code review
Co-authored-by: Lauren Hirata Singh <lauren@langchain.dev>
2025-07-29 10:58:59 -04:00
Sydney Runkle 474fb7b33e consolidate 2025-07-29 10:22:50 -04:00
Eugene YurtsevandGitHub 416da06d6b feat(docs): manually insert fill in most of the magic links (#5702)
These were done "manually" using openai. Likely error prone. We'll need to validate all the links.
2025-07-29 14:22:45 +00:00
Sydney Runkle 2297271863 refining tips 2025-07-29 10:19:48 -04:00
4910830efe Apply suggestions from code review
Co-authored-by: Lauren Hirata Singh <lauren@langchain.dev>
2025-07-29 10:04:49 -04:00
Eugene YurtsevandGitHub 027bb0a1b8 feat(docs): Support cross language "auto links" (#5699)
Introduces a syntax for cross-reference links that work across language
and change behavior depending on which scope they appear in.


```markdown
@[interrupt]

:::python
@[StateGraph]
:::

:::js
@[create_react_agent]
:::

```

Can be compiled to

```markdown

# a link that changes based on global context or compile target
<div> ... </div>  -> `interrupt` in global context

:::python
[StateGraph](link to python cross reference)
:::

:::js
[create_react_agent](link to js cross reference)
:::
```



TODO:

- [x] fix broken unit test
- [x] no f strings in logger (it's a sin)
- [x] remove cross-refs.txt (we'll instead start updating the cross link
map)
2025-07-29 10:00:23 -04:00
Sydney RunkleandGitHub cd30b9cfea docs: Add better definition for max_concurrency (#5701) 2025-07-29 08:39:46 -04:00
Lauren Hirata Singh 5eb9826c46 docs: Add better definition for max_concurrency 2025-07-29 07:15:47 -04:00
Lauren Hirata SinghandGitHub fa43b4694a Apply suggestions from code review 2025-07-29 07:03:12 -04:00
Sydney Runkle 4d80f4b1a5 a few more nits 2025-07-28 19:11:30 -04:00
Sydney Runkle 70185d350e adding xlinks 2025-07-28 19:02:47 -04:00
Sydney Runkle 89efd6e915 formatting 2025-07-28 18:50:37 -04:00
Lance Martin d88ca6f649 Update 2025-07-28 15:15:45 -07:00
Sydney RunkleandGitHub fadbe7d710 fix(docs): clarify value of context (#5689) 2025-07-28 16:38:02 -04:00
Sydney Runkle c861614337 Merge branch 'sr/more-context-for-context' of https://github.com/langchain-ai/langgraph into sr/more-context-for-context 2025-07-28 16:34:54 -04:00
Sydney Runkle c020f01425 final nits 2025-07-28 16:34:13 -04:00
14949c81c8 Apply suggestions from code review
Co-authored-by: Lauren Hirata Singh <lauren@langchain.dev>
2025-07-28 16:22:16 -04:00
Sydney Runkle 3af857922a formatting for bullets 2025-07-28 16:19:27 -04:00
Sydney Runkle 7ec049e0c7 note on window 2025-07-28 16:15:56 -04:00
Sydney Runkle 1923ff8d85 first pass 2025-07-28 16:15:14 -04:00
Sydney RunkleandGitHub 4b9b7d0b1c fix(docs): better docs for resuming multiple interrupts (#5688) 2025-07-28 16:05:22 -04:00
624247a51f Update docs/docs/agents/context.md
Co-authored-by: Eugene Yurtsev <eyurtsev@gmail.com>
2025-07-28 15:31:36 -04:00
Sydney Runkle b21b595927 Merge branch 'sr/more-docs' of https://github.com/langchain-ai/langgraph into sr/more-docs 2025-07-28 15:21:24 -04:00
Sydney Runkle f4633a0015 single example 2025-07-28 15:20:32 -04:00
Sam CrowderandGitHub 86017c010c docs: [LangGraph Server Changelog Bot] Changelog updates for new version(s) (#5686) 2025-07-28 12:17:20 -07:00
Sydney Runkle 2115cffc94 notes on context 2025-07-28 15:17:05 -04:00
03726f9bc6 Update docs/docs/how-tos/human_in_the_loop/add-human-in-the-loop.md
Co-authored-by: Eugene Yurtsev <eyurtsev@gmail.com>
2025-07-28 15:14:37 -04:00
Sydney Runkle 509dfd1f21 better hitl multi interrupt resume docs 2025-07-28 15:05:57 -04:00
Sam Crowder efca21070d Update changelog via LangGraph Server Changelog Bot 2025-07-28 10:10:50 -07:00
Sam CrowderandGitHub dba20d0577 docs: [LangGraph Server Changelog Bot] Changelog updates for new version(s) (#5680) 2025-07-28 07:51:52 -07:00
Sam Crowder 5145dac12b Update changelog via LangGraph Server Changelog Bot 2025-07-28 07:39:31 -07:00
Sydney RunkleandGitHub 440c7ff12a release(langgraph): v0.6.0 (#5684) 2025-07-28 09:11:43 -04:00
Sydney RunkleandGitHub 5eef290c4e fix(langgraph): backwards compat config utils (#5683) 2025-07-28 09:06:38 -04:00
Sydney Runkle a8b3746356 release prep v0.6 2025-07-28 09:05:23 -04:00
Sydney Runkle 7541331643 no top level file 2025-07-28 09:00:09 -04:00
Sydney Runkle 76814676c2 finalize utils 2025-07-28 08:57:46 -04:00
Sydney RunkleandGitHub 0804984f9d Merge branch 'main' into sr/config-utils 2025-07-28 08:54:49 -04:00
Sydney Runkle 8f11b6a003 ensure_config and patch_configurable 2025-07-28 08:53:10 -04:00
Sam Crowder aa6b122e4c Update changelog via LangGraph Server Changelog Bot 2025-07-27 08:43:43 -07:00
23491e5c9a docs: [LangGraph Server Changelog Bot] Changelog updates for new version(s) (#5676)
Automated changelog update created by the LangGraph Server Changelog
Bot.

Feel free to merge anytime.

---------

Co-authored-by: William FH <13333726+hinthornw@users.noreply.github.com>
2025-07-27 13:38:15 +00:00
Sydney Runkle 1491f30a07 ensure config also 2025-07-25 16:23:28 -04:00
Sydney RunkleandGitHub f63635d3c8 release: langgraph v0.6.0a1, langgraph-prebuilt v0.6.0a1 (#5671) 2025-07-25 15:53:08 -04:00
Sydney RunkleandGitHub 6672032568 chore: add backwards compat utils imports to make v0.6 migration easier (#5670) 2025-07-25 15:44:02 -04:00
Sydney Runkle 311ce7b04f alpha bumps 2025-07-25 15:41:51 -04:00
Sydney Runkle 14b732740e removal notice 2025-07-25 15:37:41 -04:00
Sydney Runkle 370825a48a lint 2025-07-25 15:36:56 -04:00
Sydney Runkle 264bae5a7e adding backwards compat utils imports to make my life easier 2025-07-25 15:35:48 -04: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 8495f6f95d chore(ci): harden release workflow (#5669) 2025-07-25 14:45:18 -04:00
Eugene Yurtsev 6710908d40 x 2025-07-25 14:37:03 -04:00
Eugene Yurtsev d24ad3d980 x 2025-07-25 14:34:49 -04:00
Eugene Yurtsev 2d8288fd0f reduce permissions 2025-07-25 14:14:07 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c1ef10a0ec chore: bump form-data from 4.0.1 to 4.0.4 in /docs (#5615)
Bumps [form-data](https://github.com/form-data/form-data) from 4.0.1 to
4.0.4.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/form-data/form-data/blob/master/CHANGELOG.md">form-data's
changelog</a>.</em></p>
<blockquote>
<h2><a
href="https://github.com/form-data/form-data/compare/v4.0.3...v4.0.4">v4.0.4</a>
- 2025-07-16</h2>
<h3>Commits</h3>
<ul>
<li>[meta] add <code>auto-changelog</code> <a
href="https://github.com/form-data/form-data/commit/811f68282fab0315209d0e2d1c44b6c32ea0d479"><code>811f682</code></a></li>
<li>[Tests] handle predict-v8-randomness failures in node &lt; 17 and
node &gt; 23 <a
href="https://github.com/form-data/form-data/commit/1d11a76434d101f22fdb26b8aef8615f28b98402"><code>1d11a76</code></a></li>
<li>[Fix] Switch to using <code>crypto</code> random for boundary values
<a
href="https://github.com/form-data/form-data/commit/3d1723080e6577a66f17f163ecd345a21d8d0fd0"><code>3d17230</code></a></li>
<li>[Tests] fix linting errors <a
href="https://github.com/form-data/form-data/commit/5e340800b5f8914213e4e0378c084aae71cfd73a"><code>5e34080</code></a></li>
<li>[meta] actually ensure the readme backup isn’t published <a
href="https://github.com/form-data/form-data/commit/316c82ba93fd4985af757b771b9a1f26d3b709ef"><code>316c82b</code></a></li>
<li>[Dev Deps] update <code>@ljharb/eslint-config</code> <a
href="https://github.com/form-data/form-data/commit/58c25d76406a5b0dfdf54045cf252563f2bbda8d"><code>58c25d7</code></a></li>
<li>[meta] fix readme capitalization <a
href="https://github.com/form-data/form-data/commit/2300ca19595b0ee96431e868fe2a40db79e41c61"><code>2300ca1</code></a></li>
</ul>
<h2><a
href="https://github.com/form-data/form-data/compare/v4.0.2...v4.0.3">v4.0.3</a>
- 2025-06-05</h2>
<h3>Fixed</h3>
<ul>
<li>[Fix] <code>append</code>: avoid a crash on nullish values <a
href="https://redirect.github.com/form-data/form-data/issues/577"><code>[#577](https://github.com/form-data/form-data/issues/577)</code></a></li>
</ul>
<h3>Commits</h3>
<ul>
<li>[eslint] use a shared config <a
href="https://github.com/form-data/form-data/commit/426ba9ac440f95d1998dac9a5cd8d738043b048f"><code>426ba9a</code></a></li>
<li>[eslint] fix some spacing issues <a
href="https://github.com/form-data/form-data/commit/20941917f0e9487e68c564ebc3157e23609e2939"><code>2094191</code></a></li>
<li>[Refactor] use <code>hasown</code> <a
href="https://github.com/form-data/form-data/commit/81ab41b46fdf34f5d89d7ff30b513b0925febfaa"><code>81ab41b</code></a></li>
<li>[Fix] validate boundary type in <code>setBoundary()</code> method <a
href="https://github.com/form-data/form-data/commit/8d8e4693093519f7f18e3c597d1e8df8c493de9e"><code>8d8e469</code></a></li>
<li>[Tests] add tests to check the behavior of <code>getBoundary</code>
with non-strings <a
href="https://github.com/form-data/form-data/commit/837b8a1f7562bfb8bda74f3fc538adb7a5858995"><code>837b8a1</code></a></li>
<li>[Dev Deps] remove unused deps <a
href="https://github.com/form-data/form-data/commit/870e4e665935e701bf983a051244ab928e62d58e"><code>870e4e6</code></a></li>
<li>[meta] remove local commit hooks <a
href="https://github.com/form-data/form-data/commit/e6e83ccb545a5619ed6cd04f31d5c2f655eb633e"><code>e6e83cc</code></a></li>
<li>[Dev Deps] update <code>eslint</code> <a
href="https://github.com/form-data/form-data/commit/4066fd6f65992b62fa324a6474a9292a4f88c916"><code>4066fd6</code></a></li>
<li>[meta] fix scripts to use prepublishOnly <a
href="https://github.com/form-data/form-data/commit/c4bbb13c0ef669916657bc129341301b1d331d75"><code>c4bbb13</code></a></li>
</ul>
<h2><a
href="https://github.com/form-data/form-data/compare/v4.0.1...v4.0.2">v4.0.2</a>
- 2025-02-14</h2>
<h3>Merged</h3>
<ul>
<li>[Fix] set <code>Symbol.toStringTag</code> when available <a
href="https://redirect.github.com/form-data/form-data/pull/573"><code>[#573](https://github.com/form-data/form-data/issues/573)</code></a></li>
<li>[Fix] set <code>Symbol.toStringTag</code> when available <a
href="https://redirect.github.com/form-data/form-data/pull/573"><code>[#573](https://github.com/form-data/form-data/issues/573)</code></a></li>
<li>fix (npmignore): ignore temporary build files <a
href="https://redirect.github.com/form-data/form-data/pull/532"><code>[#532](https://github.com/form-data/form-data/issues/532)</code></a></li>
<li>fix (npmignore): ignore temporary build files <a
href="https://redirect.github.com/form-data/form-data/pull/532"><code>[#532](https://github.com/form-data/form-data/issues/532)</code></a></li>
</ul>
<h3>Fixed</h3>
<ul>
<li>[Fix] set <code>Symbol.toStringTag</code> when available (<a
href="https://redirect.github.com/form-data/form-data/issues/573">#573</a>)
<a
href="https://redirect.github.com/form-data/form-data/issues/396"><code>[#396](https://github.com/form-data/form-data/issues/396)</code></a></li>
<li>[Fix] set <code>Symbol.toStringTag</code> when available (<a
href="https://redirect.github.com/form-data/form-data/issues/573">#573</a>)
<a
href="https://redirect.github.com/form-data/form-data/issues/396"><code>[#396](https://github.com/form-data/form-data/issues/396)</code></a></li>
<li>[Fix] set <code>Symbol.toStringTag</code> when available <a
href="https://redirect.github.com/form-data/form-data/issues/396"><code>[#396](https://github.com/form-data/form-data/issues/396)</code></a></li>
</ul>
<h3>Commits</h3>
<ul>
<li>Merge tags v2.5.3 and v3.0.3 <a
href="https://github.com/form-data/form-data/commit/92613b9208556eb4ebc482fdf599fae111626fb6"><code>92613b9</code></a></li>
<li>[Tests] migrate from travis to GHA <a
href="https://github.com/form-data/form-data/commit/806eda77740e6e3c67c7815afb216f2e1f187ba5"><code>806eda7</code></a></li>
<li>[Tests] migrate from travis to GHA <a
href="https://github.com/form-data/form-data/commit/8fdb3bc6b5d001f8909a9fca391d1d1d97ef1d79"><code>8fdb3bc</code></a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/form-data/form-data/commit/41996f5ac73a867046d48512cab62e64fc846dad"><code>41996f5</code></a>
v4.0.4</li>
<li><a
href="https://github.com/form-data/form-data/commit/316c82ba93fd4985af757b771b9a1f26d3b709ef"><code>316c82b</code></a>
[meta] actually ensure the readme backup isn’t published</li>
<li><a
href="https://github.com/form-data/form-data/commit/2300ca19595b0ee96431e868fe2a40db79e41c61"><code>2300ca1</code></a>
[meta] fix readme capitalization</li>
<li><a
href="https://github.com/form-data/form-data/commit/811f68282fab0315209d0e2d1c44b6c32ea0d479"><code>811f682</code></a>
[meta] add <code>auto-changelog</code></li>
<li><a
href="https://github.com/form-data/form-data/commit/5e340800b5f8914213e4e0378c084aae71cfd73a"><code>5e34080</code></a>
[Tests] fix linting errors</li>
<li><a
href="https://github.com/form-data/form-data/commit/1d11a76434d101f22fdb26b8aef8615f28b98402"><code>1d11a76</code></a>
[Tests] handle predict-v8-randomness failures in node &lt; 17 and node
&gt; 23</li>
<li><a
href="https://github.com/form-data/form-data/commit/58c25d76406a5b0dfdf54045cf252563f2bbda8d"><code>58c25d7</code></a>
[Dev Deps] update <code>@ljharb/eslint-config</code></li>
<li><a
href="https://github.com/form-data/form-data/commit/3d1723080e6577a66f17f163ecd345a21d8d0fd0"><code>3d17230</code></a>
[Fix] Switch to using <code>crypto</code> random for boundary
values</li>
<li><a
href="https://github.com/form-data/form-data/commit/d8d67dc8ac79285154edf7d3f57dbab593b9a146"><code>d8d67dc</code></a>
v4.0.3</li>
<li><a
href="https://github.com/form-data/form-data/commit/e6e83ccb545a5619ed6cd04f31d5c2f655eb633e"><code>e6e83cc</code></a>
[meta] remove local commit hooks</li>
<li>Additional commits viewable in <a
href="https://github.com/form-data/form-data/compare/v4.0.1...v4.0.4">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=form-data&package-manager=npm_and_yarn&previous-version=4.0.1&new-version=4.0.4)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/langchain-ai/langgraph/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-25 13:31:18 -04:00
Eugene YurtsevandGitHub a3d7b6f44e chore(checkpoint-sqlite): Release 2.0.11 (#5667)
Release new version
2025-07-25 17:26:26 +00:00
Eugene YurtsevandGitHub bc9d45b476 fix(checkpoint-sqlite): add validation to filter keys in sql store (#5666)
This PR adds validation to keys used in filtering logic in the SQLite store implementation.
2025-07-25 13:01:13 -04:00
Sydney RunkleandGitHub 7d3f0089aa docs: more thorough notes on v0.6 features and changes (#5623) 2025-07-25 09:33:53 -04:00
Sydney RunkleandGitHub ed678f4701 Merge branch 'main' into sr/version-added-for-context 2025-07-25 09:27:52 -04:00
Sydney Runkle 22411ba0fd lint 2025-07-25 09:27:34 -04:00
Sydney Runkle 5b9021ff37 typo 2025-07-25 09:25:26 -04:00
Sydney Runkle aaff464115 move deprecation note for config_schema 2025-07-25 09:23:59 -04:00
Sam CrowderandGitHub 3a23a256e2 docs: [LangGraph Server Changelog Bot] Changelog updates for new version(s) (#5657) 2025-07-24 20:37:10 -07:00
Sam Crowder f6857395b9 Update changelog via LangGraph Server Changelog Bot 2025-07-24 20:19:17 -07:00
William FHandGitHub cdaa7ba003 chore: typing for headers in remote graph (#5653) 2025-07-24 18:00:44 -07:00
Sydney Runkle 39745ed794 Merge branch 'sr/version-added-for-context' of https://github.com/langchain-ai/langgraph into sr/version-added-for-context 2025-07-24 17:31:25 -04:00
Sydney Runkle 738bb8a343 Merge branch 'sr/fixes-for-v6' into sr/version-added-for-context 2025-07-24 17:31:21 -04:00
Sydney RunkleandGitHub b2dde8d9af Merge branch 'main' into sr/version-added-for-context 2025-07-24 17:29:54 -04:00
Sydney Runkle 5312edc830 more runtime details 2025-07-24 17:28:58 -04:00
Sydney Runkle 276310e116 fix and interrupt 2025-07-24 16:54:20 -04:00
Sydney Runkle 39977ded8c runtime api ref 2025-07-24 16:36:51 -04:00
Sydney Runkle 439038fc3a deprecation for config_schema in docs 2025-07-24 16:23:42 -04:00
Sam CrowderandGitHub 2e5445c565 docs: [LangGraph Server Changelog Bot] Changelog updates for new version(s) (#5650) 2025-07-24 11:35:22 -07:00
Sam Crowder d38510ad03 Update changelog via LangGraph Server Changelog Bot 2025-07-24 09:18:21 -07:00
Sydney RunkleandGitHub 951486d107 release(prebuilt): 0.6.0 (#5648) 2025-07-24 10:38:24 -04:00
Sydney Runkle 745a1e7a29 bumping required prebuilt version 2025-07-24 10:27:20 -04:00
Sam CrowderandGitHub 24731d6a28 docs: [LangGraph Server Changelog Bot] Changelog updates for new version(s) (#5625) 2025-07-23 20:06:22 -07:00
Sam Crowder a4689a5d10 Update changelog via LangGraph Server Changelog Bot 2025-07-23 18:59:50 -07:00
langchain-infraandGitHub b05ce0bf60 release(cli): Release new CLI version with support for api-version flag (#5640) 2025-07-23 19:47:25 -04:00
David Asamu 4501e41991 uv sync 2025-07-24 00:36:02 +01:00
Asamu DavidandGitHub bc83287fc8 bump version number
new CLI version with support for api-version flag
2025-07-24 00:19:57 +01:00
Andrew NguonlyandGitHub 11547e1990 docs: Add more clarification about the LangSmith API key for LangGraph Platform deployments (#5635) 2025-07-23 11:39:39 -07:00
langchain-infraandGitHub fadd9c4577 feat(cli): add support for api-version (#5584) 2025-07-23 12:33:04 -04:00
Asamu DavidandGitHub fd2933a792 Merge branch 'main' into david/30-06/support-base-image-tag 2025-07-23 15:42:24 +01:00
David Asamu 40fa69f8ee lint and format fixes 2025-07-23 15:39:34 +01:00
Sam Crowder 0824161984 Update changelog via LangGraph Server Changelog Bot 2025-07-22 13:47:31 -07:00
Sam CrowderandGitHub b028f502e1 docs: [LangGraph Server Changelog Bot] Changelog updates for new version(s) (#5611) 2025-07-22 12:47:33 -07:00
William FHandGitHub 9dc3fed6b8 feat(langgraph): Support sending distributed tracing headers (#5619) 2025-07-22 12:47:23 -07:00
Sydney Runkle 0232201b7b improving docs for context 2025-07-22 13:28:27 -04:00
Sydney RunkleandGitHub 869b0f2de4 release(sdk-py): 0.2.0 (#5622) 2025-07-22 13:27:04 -04:00
Sydney Runkle 90e3adcd71 bump sdk version 2025-07-22 13:20:55 -04:00
Sam Crowder aa1bbe3d01 Update changelog via LangGraph Server Changelog Bot 2025-07-21 17:41:55 -07:00
David Asamu ec0a30008c more semantic variable name 2025-07-19 03:30:29 +01:00
Asamu DavidandGitHub 00c7909c27 Merge branch 'main' into david/30-06/support-base-image-tag 2025-07-19 03:20:09 +01:00
David Asamu 58d396fcf1 merge main 2025-07-19 03:17:17 +01:00
David Asamu 9f0abf014d format changed files 2025-07-19 03:06:17 +01:00
David Asamu 8d6cd15669 add api-version option 2025-07-19 02:55:47 +01:00
54 changed files with 2497 additions and 187 deletions
-9
View File
@@ -35,16 +35,7 @@ jobs:
with:
filter: "docs/docs/**"
# TODO: Uncomment this to run on PRs
# run-changed-notebooks:
# needs: get-changed-files
# uses: ./.github/workflows/run_notebooks.yml
# secrets: inherit
# with:
# changed-files: ${{ needs.get-changed-files.outputs.changed-files }}
deploy:
# needs: run-changed-notebooks
runs-on: ubuntu-latest
timeout-minutes: 10 # Job will be cancelled if it runs for more than 10 minutes
env:
+1
View File
@@ -39,6 +39,7 @@ jobs:
scheduler-kafka
sdk-py
docs
ci
requireScope: false
ignoreLabels: |
ignore-lint-pr-title
+3 -1
View File
@@ -137,7 +137,9 @@ jobs:
needs:
- build
- release-notes
permissions: write-all
permissions:
contents: read
id-token: write
uses: ./.github/workflows/_test_release.yml
with:
working-directory: ${{ inputs.working-directory }}
+181
View File
@@ -0,0 +1,181 @@
"""Logic to identify and transform cross-reference links in markdown files.
This module allows supporting custom markdown syntax for "autolinks". These are links
that will be transformed based on the current scope context, such as "global", "python",
or "js" into an appropriate markdown link format.
For example,
```markdown
@[StateGraph]
```
May be transformed into:
```markdown
[StateGraph](some_path/api-reference/state-graph.md)
```
The transformation value depends on the scope in which the link is used.
"""
import logging
import re
from typing import Optional
from _scripts.link_map import SCOPE_LINK_MAPS
logger = logging.getLogger(__name__)
def _transform_link(
link_name: str, scope: str, file_path: str, line_number: int, custom_title: Optional[str] = None
) -> Optional[str]:
"""Transform a cross-reference link based on the current scope.
Args:
link_name: The name of the link to transform (e.g., "StateGraph").
scope: The current scope context ("global", "python", "js", etc.).
file_path: The file path for error reporting.
line_number: The line number for error reporting.
custom_title: Optional custom title for the link. If None, uses link_name.
Returns:
A formatted markdown link if the link is found in the scope mapping,
None otherwise.
Example:
>>> _transform_link("StateGraph", "python", "file.md", 5)
"[StateGraph](https://langchain-ai.github.io/langgraph/reference/graphs/#langgraph.graph.StateGraph)"
>>> _transform_link("StateGraph", "python", "file.md", 5, "Custom Title")
"[Custom Title](https://langchain-ai.github.io/langgraph/reference/graphs/#langgraph.graph.StateGraph)"
>>> _transform_link("unknown-link", "python", "file.md", 5)
None
"""
if scope == "global":
# Special scope that is composed of both Python and JS links
# For now, we will substitute in the python scope!
# But we need to add support for handling both scopes.
scope = "python"
logger.error(
"Encountered unhandled 'global' scope. Defaulting to 'python'."
"In file: %s, line %d, link_name: %s",
file_path,
line_number,
link_name,
)
link_map = SCOPE_LINK_MAPS.get(scope, {})
url = link_map.get(link_name)
if url:
title = custom_title if custom_title is not None else link_name
return f"[{title}]({url})"
else:
# Log error with file location information
logger.info(
# Using %s
"Link '%s' not found in scope '%s'. "
"In file: %s, line %d. Available links in scope: %s",
link_name,
scope,
file_path,
line_number,
list(link_map.keys() if link_map else []),
)
return None
CONDITIONAL_FENCE_PATTERN = re.compile(
r"""
^ # Start of line
(?P<indent>[ \t]*) # Optional indentation (spaces or tabs)
::: # Literal fence marker
(?P<language>\w+)? # Optional language identifier (named group: language)
\s* # Optional trailing whitespace
$ # End of line
""",
re.VERBOSE,
)
CROSS_REFERENCE_PATTERN = re.compile(
r"""
@ # Literal @ symbol
(?: # Non-capturing group for two possible formats:
\[ # Opening bracket for title
(?P<title>[^\]]+) # Custom title - one or more non-bracket characters
\] # Closing bracket for title
\[ # Opening bracket for link name
(?P<link_name_with_title>[^\]]+) # Link name - one or more non-bracket characters
\] # Closing bracket for link name
| # OR
\[ # Opening bracket
(?P<link_name>[^\]]+) # Link name - one or more non-bracket characters
\] # Closing bracket
)
""",
re.VERBOSE,
)
def _replace_autolinks(markdown: str, file_path: str) -> str:
"""Preprocess markdown lines to handle @[links] with conditional fence scopes.
This function processes markdown content to transform @[link_name] references
based on the current conditional fence scope. Conditional fences use the
syntax :::language to define scope boundaries.
Args:
markdown: The markdown content to process.
file_path: The file path for error reporting.
Returns:
Processed markdown content with @[references] transformed to proper
markdown links or left unchanged if not found.
Example:
Input:
"@[StateGraph]\\n:::python\\n@[Command]\\n:::\\n"
Output:
"[StateGraph](url)\\n:::python\\n[Command](url)\\n:::\\n"
"""
# Track the current scope context
current_scope = "global"
lines = markdown.splitlines(keepends=True)
processed_lines = []
for line_number, line in enumerate(lines, 1):
line_stripped = line.strip()
# Check if this line defines a new conditional fence scope
fence_match = CONDITIONAL_FENCE_PATTERN.match(line_stripped)
if fence_match:
language = fence_match.group("language")
# Set scope to the specified language, or reset to global if no language
current_scope = language.lower() if language else "global"
processed_lines.append(line)
continue
# Transform all @[link_name] references in this line based on current scope
def replace_cross_reference(match: re.Match[str]) -> str:
"""Replace a single @[link_name] with the scoped equivalent."""
# Check if this is the @[title][ref] format or @[ref] format
title = match.group("title")
if title is not None:
# This is @[title][ref] format
link_name = match.group("link_name_with_title")
custom_title = title
else:
# This is @[ref] format
link_name = match.group("link_name")
custom_title = None
transformed = _transform_link(
link_name, current_scope, file_path, line_number, custom_title
)
return transformed if transformed is not None else match.group(0)
transformed_line = CROSS_REFERENCE_PATTERN.sub(replace_cross_reference, line)
processed_lines.append(transformed_line)
return "".join(processed_lines)
+141 -4
View File
@@ -1,5 +1,142 @@
JS_LINK_MAP = {
"langgraph.types.interrupt": "https://langchain-ai.github.io/langgraphjs/reference/functions/langgraph.interrupt-2.html",
"create_react_agent": "https://langchain-ai.github.io/langgraphjs/reference/functions/langgraph_prebuilt.createReactAgent.html",
"langgraph.types.Command": "https://langchain-ai.github.io/langgraphjs/reference/classes/langgraph.Command.html",
"""Link mapping for cross-reference resolution across different scopes.
This module provides link mappings for different language/framework scopes
to resolve @[link_name] references to actual URLs.
"""
# Python-specific link mappings
# Python-specific link mappings
PYTHON_LINK_MAP = {
"StateGraph": "reference/graphs/#langgraph.graph.StateGraph",
"add_conditional_edges": "reference/graphs/#langgraph.graph.StateGraph.add_conditional_edges",
"add_edge": "reference/graphs/#langgraph.graph.StateGraph.add_edge",
"add_node": "reference/graphs/#langgraph.graph.StateGraph.add_node",
"add_messages": "reference/messages/#langgraph.graph.message.add_messages",
"ToolNode": "reference/prebuilt/#langgraph.prebuilt.tool_node.ToolNode",
"CompiledStateGraph.astream": "reference/graphs/#langgraph.graph.state.CompiledStateGraph.astream",
"Pregel.astream": "reference/graphs/#langgraph.pregel.Pregel.astream",
"AsyncPostgresSaver": "reference/checkpoints/#langgraph.checkpoint.postgres.aio.AsyncPostgresSaver",
"AsyncSqliteSaver": "reference/checkpoints/#langgraph.checkpoint.sqlite.aio.AsyncSqliteSaver",
"BaseCheckpointSaver": "reference/checkpoints/#langgraph.checkpoint.base.BaseCheckpointSaver",
"BaseStore": "reference/stores/#langgraph.store.base.BaseStore",
"BaseStore.put": "reference/stores/#langgraph.store.base.BaseStore.put",
"BinaryOperatorAggregate": "reference/channels/#langgraph.channels.BinaryOperatorAggregate",
"CipherProtocol": "reference/checkpoints/#langgraph.checkpoint.serde.base.CipherProtocol",
"client.runs.stream": "reference/client/#langgraph_sdk.client.RunsClient.stream",
"client.runs.wait": "reference/client/#langgraph_sdk.client.RunsClient.wait",
"client.threads.get_history": "reference/client/#langgraph_sdk.client.ThreadsClient.get_history",
"client.threads.update_state": "reference/client/#langgraph_sdk.client.ThreadsClient.update_state",
"Command": "reference/types/#langgraph.types.Command",
"CompiledStateGraph": "reference/graphs/#langgraph.graph.state.CompiledStateGraph",
"create_react_agent": "reference/prebuilt/#langgraph.prebuilt.chat_agent_executor.create_react_agent",
"create_supervisor": "reference/supervisor/#langgraph_supervisor.supervisor.create_supervisor",
"EncryptedSerializer": "reference/checkpoints/#langgraph.checkpoint.serde.encrypted.EncryptedSerializer",
"entrypoint.final": "reference/functions/#langgraph.func.entrypoint.final",
"entrypoint": "reference/functions/#langgraph.func.entrypoint",
"from_pycryptodome_aes": "reference/checkpoints/#langgraph.checkpoint.serde.encrypted.EncryptedSerializer.from_pycryptodome_aes",
# "getContextVariable": "<insert-ref>",
"get_state_history": "reference/graphs/#langgraph.graph.state.CompiledStateGraph.get_state_history",
"get_stream_writer": "reference/config/#langgraph.config.get_stream_writer",
"HumanInterrupt": "reference/prebuilt/#langgraph.prebuilt.interrupt.HumanInterrupt",
"InjectedState": "reference/prebuilt/#langgraph.prebuilt.InjectedState",
"InMemorySaver": "reference/checkpoints/#langgraph.checkpoint.memory.InMemorySaver",
"interrupt": "reference/graphs/#langgraph.graph.interrupt",
"CompiledStateGraph.invoke": "reference/graphs/#langgraph.graph.state.CompiledStateGraph.invoke",
"JsonPlusSerializer": "reference/checkpoints/#langgraph.checkpoint.serde.jsonplus.JsonPlusSerializer",
"langgraph.json": "reference/configuration/#configuration-file",
"LastValue": "reference/channels/#langgraph.channels.LastValue",
# "MemorySaver": "<insert-ref>",
# "messagesStateReducer": "<insert-ref>",
"PostgresSaver": "reference/checkpoints/#langgraph.checkpoint.postgres.PostgresSaver",
"Pregel": "reference/graphs/#langgraph.pregel.Pregel",
"Pregel.stream": "reference/graphs/#langgraph.pregel.Pregel.stream",
"pre_model_hook": "reference/prebuilt/#langgraph.prebuilt.chat_agent_executor.create_react_agent",
"protocol": "reference/checkpoints/#langgraph.checkpoint.serde.base.SerializerProtocol",
"Send": "reference/types/#langgraph.types.Send",
"SerializerProtocol": "reference/checkpoints/#langgraph.checkpoint.serde.base.SerializerProtocol",
"SqliteSaver": "reference/checkpoints/#langgraph.checkpoint.sqlite.SqliteSaver",
"START": "reference/constants/#langgraph.constants.START",
"CompiledStateGraph.stream": "reference/graphs/#langgraph.graph.state.CompiledStateGraph.stream",
"task": "reference/functions/#langgraph.func.task",
"Topic": "reference/channels/#langgraph.channels.Topic",
"update_state": "reference/graphs/#langgraph.graph.state.CompiledStateGraph.update_state",
}
# JavaScript-specific link mappings
JS_LINK_MAP = {
"StateGraph": "reference/classes/langgraph.StateGraph.html",
"add_conditional_edges": "reference/functions/langgraph_StateGraph.addConditionalEdges.html",
"add_edge": "reference/functions/langgraph_StateGraph.addEdge.html",
"add_node": "reference/functions/langgraph_StateGraph.addNode.html",
"add_messages": "reference/functions/langgraph_message.addMessages.html",
"ToolNode": "reference/classes/langgraph_prebuilt.ToolNode.html",
"CompiledStateGraph.astream()": "reference/functions/langgraph_CompiledStateGraph.astream.html",
"Pregel.astream": "reference/functions/langgraph_Pregel.astream.html",
"AsyncPostgresSaver": "reference/classes/langgraph_checkpoint_postgres_aio.AsyncPostgresSaver.html",
"AsyncSqliteSaver": "reference/classes/langgraph_checkpoint_sqlite_aio.AsyncSqliteSaver.html",
"BaseCheckpointSaver": "reference/classes/langgraph_checkpoint_base.BaseCheckpointSaver.html",
"BaseStore": "reference/classes/langgraph_store_base.BaseStore.html",
"BaseStore.put": "reference/functions/langgraph_store_base.BaseStore.put.html",
"BinaryOperatorAggregate": "reference/classes/langgraph_channels.BinaryOperatorAggregate.html",
"CipherProtocol": "reference/classes/langgraph_checkpoint_serde_base.CipherProtocol.html",
"client.runs.stream": "reference/functions/langgraph_sdk_client.RunsClient.stream.html",
"client.runs.wait": "reference/functions/langgraph_sdk_client.RunsClient.wait.html",
"client.threads.get_history": "reference/functions/langgraph_sdk_client.ThreadsClient.getHistory.html",
"client.threads.update_state": "reference/functions/langgraph_sdk_client.ThreadsClient.updateState.html",
"Command": "reference/classes/langgraph.Command.html",
"CompiledStateGraph": "reference/classes/langgraph.CompiledStateGraph.html",
"create_react_agent": "reference/functions/langgraph_prebuilt.createReactAgent.html",
"create_supervisor": "reference/functions/langgraph_supervisor.createSupervisor.html",
"EncryptedSerializer": "reference/classes/langgraph_checkpoint_serde_encrypted.EncryptedSerializer.html",
"entrypoint.final": "reference/functions/langgraph_func.entrypoint.final.html",
"entrypoint": "reference/functions/langgraph_func.entrypoint.html",
"from_pycryptodome_aes": "reference/functions/langgraph_checkpoint_serde_encrypted.EncryptedSerializer.fromPycryptodomeAes.html",
# "getContextVariable": "<insert-ref>",
"get_state_history": "reference/functions/langgraph_CompiledStateGraph.getStateHistory.html",
"get_stream_writer": "reference/functions/langgraph_config.getStreamWriter.html",
"HumanInterrupt": "reference/classes/langgraph_prebuilt.HumanInterrupt.html",
"InjectedState": "reference/classes/langgraph_prebuilt.InjectedState.html",
"InMemorySaver": "reference/classes/langgraph_checkpoint_memory.InMemorySaver.html",
"interrupt": "reference/functions/langgraph.interrupt-2.html",
"CompiledStateGraph.invoke": "reference/functions/langgraph_CompiledStateGraph.invoke.html",
"JsonPlusSerializer": "reference/classes/langgraph_checkpoint_serde_jsonplus.JsonPlusSerializer.html",
"langgraph.json": "reference/configuration.html",
"LastValue": "reference/classes/langgraph_channels.LastValue.html",
# "MemorySaver": "<insert-ref>",
# "messagesStateReducer": "<insert-ref>",
"PostgresSaver": "reference/classes/langgraph_checkpoint_postgres.PostgresSaver.html",
"Pregel": "reference/classes/langgraph.Pregel.html",
"Pregel.stream": "reference/functions/langgraph_Pregel.stream.html",
"pre_model_hook": "reference/functions/langgraph_prebuilt.createReactAgent.html",
"protocol": "reference/classes/langgraph_checkpoint_serde_base.SerializerProtocol.html",
"Send": "reference/classes/langgraph.Send.html",
"SerializerProtocol": "reference/classes/langgraph_checkpoint_serde_base.SerializerProtocol.html",
"SqliteSaver": "reference/classes/langgraph_checkpoint_sqlite.SqliteSaver.html",
"START": "reference/constants.html#START",
"CompiledStateGraph.stream": "reference/functions/langgraph_CompiledStateGraph.stream.html",
"task": "reference/functions/langgraph_func.task.html",
"Topic": "reference/classes/langgraph_channels.Topic.html",
"update_state": "reference/functions/langgraph_CompiledStateGraph.updateState.html",
}
# TODO: Allow updating these to localhost for local development
PY_REFERENCE_HOST = "https://langchain-ai.github.io/langgraph/"
JS_REFERENCE_HOST = "https://langchain-ai.github.io/langgraphjs/"
for key, value in PYTHON_LINK_MAP.items():
# Ensure the link is absolute
if not value.startswith("http"):
PYTHON_LINK_MAP[key] = f"{PY_REFERENCE_HOST}{value}"
for key, value in JS_LINK_MAP.items():
# Ensure the link is absolute
if not value.startswith("http"):
JS_LINK_MAP[key] = f"{JS_REFERENCE_HOST}{value}"
# Global scope is assembled from the Python and JS mappings
# Combined mapping by scope
SCOPE_LINK_MAPS = {
"python": PYTHON_LINK_MAP,
"js": JS_LINK_MAP,
}
+14 -44
View File
@@ -16,7 +16,7 @@ from mkdocs.structure.files import Files, File
from mkdocs.structure.pages import Page
from _scripts.generate_api_reference_links import update_markdown_with_imports
from _scripts.link_map import JS_LINK_MAP
from _scripts.handle_auto_links import _replace_autolinks
from _scripts.notebook_convert import convert_notebook
logger = logging.getLogger(__name__)
@@ -176,31 +176,7 @@ def _add_path_to_code_blocks(markdown: str, page: Page) -> str:
return code_block_pattern.sub(replace_code_block_header, markdown)
def _resolve_cross_references(md_text: str, link_map: dict[str, str]) -> str:
"""Replace [title][identifier] with [title](url) using language-specific link_map.
Args:
md_text: The markdown text to process.
link_map: mapping of identifier to URL.
Returns:
The processed markdown text with cross-references resolved.
"""
# Pattern to match [title][identifier]
pattern = re.compile(r"\[([^\]]+)\]\[([^\]]+)\]")
def replace_reference(match: re.Match) -> str:
"""Replace the matched reference with the corresponding URL."""
title, identifier = match.group(1), match.group(2)
url = link_map.get(identifier)
if url:
return f"[{title}]({url})"
else:
# Leave it unchanged if not found
return match.group(0)
return pattern.sub(replace_reference, md_text)
# Compiled regex patterns for better performance and readability
def _apply_conditional_rendering(md_text: str, target_language: str) -> str:
@@ -295,7 +271,7 @@ def _highlight_code_blocks(markdown: str) -> str:
opening_fence += f" {attributes}"
if highlighted_lines:
opening_fence += f" hl_lines=\"{' '.join(highlighted_lines)}\""
opening_fence += f' hl_lines="{" ".join(highlighted_lines)}"'
return (
# The indent and opening fence
@@ -325,6 +301,9 @@ def _on_page_markdown_with_config(
# logger.info("Processing Jupyter notebook: %s", page.file.src_path)
markdown = convert_notebook(page.file.abs_src_path)
# Apply cross-reference preprocessing to all markdown content
markdown = _replace_autolinks(markdown, page.file.src_path)
# Append API reference links to code blocks
if add_api_references:
markdown = update_markdown_with_imports(markdown, page.file.abs_src_path)
@@ -334,16 +313,6 @@ def _on_page_markdown_with_config(
# Apply conditional rendering for code blocks
target_language = kwargs.get("target_language", "python")
markdown = _apply_conditional_rendering(markdown, target_language)
if target_language == "js":
markdown = _resolve_cross_references(markdown, JS_LINK_MAP)
elif target_language == "python":
# Via a dedicated plugin
pass
else:
raise ValueError(
f"Unsupported target language: {target_language}. "
"Supported languages are 'python' and 'js'."
)
# Add file path as an attribute to code blocks that are executable.
# This file path is used to associate fixtures with the executable code
@@ -358,13 +327,11 @@ def _on_page_markdown_with_config(
def on_page_markdown(markdown: str, page: Page, **kwargs: Dict[str, Any]):
finalized_markdown = (
_on_page_markdown_with_config(
markdown,
page,
add_api_references=True,
**kwargs,
)
finalized_markdown = _on_page_markdown_with_config(
markdown,
page,
add_api_references=True,
**kwargs,
)
page.meta["original_markdown"] = finalized_markdown
return finalized_markdown
@@ -437,6 +404,7 @@ height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
else:
return html # fallback if no <body> found
def _inject_markdown_into_html(html: str, page: Page) -> str:
"""Inject the original markdown content into the HTML page as JSON."""
original_markdown = page.meta.get("original_markdown", "")
@@ -469,6 +437,7 @@ def _inject_markdown_into_html(html: str, page: Page) -> str:
)
return html.replace("</head>", f"{script_content}</head>")
def on_post_page(html: str, page: Page, config: MkDocsConfig) -> str:
"""Inject Google Tag Manager noscript tag immediately after <body>.
@@ -483,6 +452,7 @@ def on_post_page(html: str, page: Page, config: MkDocsConfig) -> str:
html = _inject_markdown_into_html(html, page)
return _inject_gtm(html)
# Create HTML files for redirects after site dir has been built
def on_post_build(config):
use_directory_urls = config.get("use_directory_urls")
+37 -22
View File
@@ -1,33 +1,43 @@
# Context
**Context engineering** is the practice of building dynamic systems that provide the right information and tools, in the right format, so that a language model can plausibly accomplish a task.
**Context engineering** is the practice of building dynamic systems that provide the right information and tools, in the right format, so that an AI application can accomplish a task. Context can be characterized along two key dimensions:
Context includes *any* data outside the message list that can shape behavior. This can be:
1. By **mutability**:
- Information passed at runtime, like a `user_id` or API credentials.
- Internal state updated during a multi-step reasoning process.
- Persistent memory or facts from previous interactions.
- **Static context**: Immutable data that doesn't change during execution (e.g., user metadata, database connections, tools)
- **Dynamic context**: Mutable data that evolves as the application runs (e.g., conversation history, intermediate results, tool call observations)
LangGraph provides **three** primary ways to supply context:
2. By **lifetime**:
| Type | Description | Mutable? | Lifetime |
|------------------------------------------------------------------------------|-----------------------------------------------|----------|-------------------------|
| [**Runtime Context**](#runtime-context) | data passed at the start of a run | ❌ | per run |
| [**Short-term memory (State)**](#short-term-memory-mutable-context) | dynamic data that can change during execution | ✅ | per run or conversation |
| [**Long-term memory (Store)**](#long-term-memory-cross-conversation-context) | data that can be shared between conversations | ✅ | across conversations |
- **Runtime context**: Data scoped to a single run or invocation
- **Cross-conversation context**: Data that persists across multiple conversations or sessions
### Runtime Context
!!! tip "Runtime context vs LLM context"
!!! note "`config['configurable']` -> `runtime.context`"
Runtime context refers to local context: data and dependencies your code needs to run. It does **not** refer to:
In LangGraph < v1.0, static runtime context was passed via the `config['configurable']` key, paired with a `config_schema` argument
to `StateGraph` or `Pregel`. This is now deprecated and will be removed in v2.0.
* The LLM context, which is the data passed into the LLM's prompt.
* The "context window", which is the maximum number of tokens that can be passed to the LLM.
As of LangGraph v1.0, the Runtime object is recommended to access static context and runtime-specific information like the store and stream writer.
Runtime context can be used to optimize the LLM context. For example, you can use user metadata
in the runtime context to fetch user preferences and feed them into the context window.
Runtime context is for immutable data like user metadata or API keys. Use this when you have values that don't change mid-run.
LangGraph provides three ways to manage context, which combines the mutability and lifetime dimensions:
Specify static context via the `context` argument to `invoke` / `stream`, which is reserved for this purpose:
| Context type | Description | Mutability | Lifetime | Access method |
|------------------------------------------------------------------------------|--------------------------------------------------------|------------|-------------------------|-----------------------------------|
| [**Static runtime context**](#static-runtime-context) | User metadata, tools, db connections passed at startup | Static | Single run | `context` argument to `invoke`/`stream` |
| [**Dynamic runtime context (state)**](#dynamic-runtime-context-state) | Mutable data that evolves during a single run | Dynamic | Single run | LangGraph state object |
| [**Dynamic cross-conversation context (store)**](#dynamic-cross-conversation-context-store) | Persistent data shared across conversations | Dynamic | Cross-conversation | LangGraph store |
## Static runtime context
**Static runtime context** represents immutable data like user metadata, tools, and database connections that are passed to an application at the start of a run via the `context` argument to `invoke`/`stream`. This data does not change during execution.
!!! version-added "New in LangGraph v0.6: `context` replaces `config['configurable']`"
Runtime context is now passed to the `context` argument of `invoke`/`stream`,
which replaces the previous pattern of passing application configuration to `config['configurable']`.
```python
@dataclass
@@ -105,9 +115,14 @@ graph.invoke( # (1)!
See the [tool calling guide](../how-tos/tool-calling.md#configuration) for details.
### Short-term memory (mutable context)
!!! tip
State acts as [short-term memory](../concepts/memory.md) during a run. It holds dynamic data that can evolve during execution, such as values derived from tools or LLM outputs.
The `Runtime` object can be used to access static context and other utilities like the active store and stream writer.
See the [Runtime][langgraph.runtime.Runtime] documentation for details.
## Dynamic runtime context (state)
**Dynamic runtime context** represents mutable data that can evolve during a single run and is managed through the LangGraph state object. This includes conversation history, intermediate results, and values derived from tools or LLM outputs. In LangGraph, the state object acts as [short-term memory](../concepts/memory.md) during a run.
=== "In an agent"
@@ -187,8 +202,8 @@ State acts as [short-term memory](../concepts/memory.md) during a run. It holds
Please see the [memory guide](../how-tos/memory/add-memory.md) for more details on how to enable memory. This is a powerful feature that allows you to persist the agent's state across multiple invocations. Otherwise, the state is scoped only to a single run.
### Long-term memory (cross-conversation context)
## Dynamic cross-conversation context (store)
For context that spans *across* conversations or sessions, LangGraph allows access to **long-term memory** via a `store`. This can be used to read or update persistent facts (e.g., user profiles, preferences, prior interactions).
**Dynamic cross-conversation context** represents persistent, mutable data that spans across multiple conversations or sessions and is managed through the LangGraph store. This includes user profiles, preferences, and historical interactions. The LangGraph store acts as [long-term memory](../concepts/memory.md#long-term-memory) across multiple runs. This can be used to read or update persistent facts (e.g., user profiles, preferences, prior interactions).
For more information, see the [Memory guide](../how-tos/memory/add-memory.md).
@@ -4,6 +4,47 @@
---
## v0.2.109 (2025-07-28)
- Fixed an issue where missing config schema occurred when `config_type` was not set.
## v0.2.108 (2025-07-28)
- Added compatibility for langgraph v0.6, including new context API support and a migration to enhance context handling in assistant operations.
## v0.2.107 (2025-07-27)
- Implemented caching for authentication processes to improve performance.
- Merged count and select queries to improve database query efficiency.
## v0.2.106 (2025-07-27)
- Log whether run uses resumable streams.
## v0.2.105 (2025-07-27)
- Added a `/heapdump` endpoint to capture and save JS process heap data.
## v0.2.103 (2025-07-25)
- Corrected the metadata endpoint to ensure accurate data retrieval.
## v0.2.102 (2025-07-24)
- Captured interrupt events in the wait method to preserve legacy behavior and stream updates by default.
- Added support for SDK structlog in the JavaScript environment, enhancing logging capabilities.
## v0.2.101 (2025-07-24)
- Used the correct metadata endpoint for self-hosted environments, resolving an access issue.
## v0.2.99 (2025-07-22)
- Improved license validation by adding an in-memory cache and handling Redis connection errors more effectively.
- Automatically remove agents from memory that are removed from `langgraph.json` to prevent persistence issues.
- Ensured the UI namespace for generated UI is a valid JavaScript property name to prevent errors.
- Raised a 422 error for improved request validation feedback.
## v0.2.98 (2025-07-19)
- Added langgraph node context for improved log filtering and trace visibility.
## v0.2.97 (2025-07-19)
- Fixed scheduling issue with ckpt ingestion worker that occurred on isolated background loops.
- Ensured queue worker starts only after all migrations have completed.
- Added more detailed error messages for thread state issues and improved response handling when state updates fail.
- Exposed interrupt ID while retrieving thread state for enhanced API response details.
## v0.2.96 (2025-07-17)
- Added a fallback mechanism for configurable header patterns to handle exclude/include settings more effectively.
+2 -2
View File
@@ -23,12 +23,12 @@ To review, edit, and approve tool calls in an agent or workflow, [use LangGraph'
## Key capabilities
* **Persistent execution state**: Interrupts use LangGraph's [persistence](../../concepts/persistence.md) layer, which saves the graph state, to indefinitely pause graph execution until you resume. This is possible because LangGraph checkpoints the graph state after each step, which allows the system to persist execution context and later resume the workflow, continuing from where it left off. This supports asynchronous human review or input without time constraints.
* **Persistent execution state**: Interrupts use LangGraph's [persistence](./persistence.md) layer, which saves the graph state, to indefinitely pause graph execution until you resume. This is possible because LangGraph checkpoints the graph state after each step, which allows the system to persist execution context and later resume the workflow, continuing from where it left off. This supports asynchronous human review or input without time constraints.
There are two ways to pause a graph:
- [Dynamic interrupts](../how-tos/human_in_the_loop/add-human-in-the-loop.md#pause-using-interrupt): Use `interrupt` to pause a graph from inside a specific node, based on the current state of the graph.
- [Static interrupts](../how-tos/human_in_the_loop/add-human-in-the-loop.md#debug-with-interrupts): Use `interrupt_before` and `interrupt_after` to pause the graph at defined points, either before or after a node executes.
- [Static interrupts](../how-tos/human_in_the_loop/add-human-in-the-loop.md#debug-with-interrupts): Use `interrupt_before` and `interrupt_after` to pause the graph at pre-defined points, either before or after a node executes.
<figure markdown="1">
![image](./img/breakpoints.png){: style="max-height:400px"}
@@ -119,6 +119,11 @@ These metrics are displayed as charts in the Control Plane UI.
### LangSmith Integration
A [LangSmith](https://docs.smith.langchain.com/) tracing project is automatically created for each deployment. The tracing project has the same name as the deployment. When creating a deployment, the `LANGCHAIN_TRACING` and `LANGSMITH_API_KEY`/`LANGCHAIN_API_KEY` environment variables do not need to be specified; they are set automatically by the control plane.
A [LangSmith](https://docs.smith.langchain.com/) tracing project and LangSmith API key are automatically created for each deployment. The deployment uses the API key to automatically send traces to LangSmith.
When a deployment is deleted, the traces and the tracing project are not deleted.
- The tracing project has the same name as the deployment.
- The API key has the description `LangGraph Platform: <deployment_name>`.
- The API key is never revealed and cannot be deleted manually.
- When creating a deployment, the `LANGCHAIN_TRACING` and `LANGSMITH_API_KEY`/`LANGCHAIN_API_KEY` environment variables do not need to be specified; they are set automatically by the control plane.
When a deployment is deleted, the traces and the tracing project are not deleted. However, the API will be deleted when the deployment is deleted.
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

@@ -128,7 +128,7 @@ print(graph.invoke(Command(resume="Edited text"), config=config)) # (7)!
!!! tip "New in 0.4.0"
`__interrupt__` is a special key that will be returned when running the graph if the graph is interrupted. Support for `__interrupt__` in `invoke` and `ainvoke` has been added in version 0.4.0. If you're on an older version, you will only see `__interrupt__` in the result if you use `stream` or `astream`. You can also use `graph.get_state(thread_id)` to get the interrupt value.
`__interrupt__` is a special key that will be returned when running the graph if the graph is interrupted. Support for `__interrupt__` in `invoke` and `ainvoke` has been added in version 0.4.0. If you're on an older version, you will only see `__interrupt__` in the result if you use `stream` or `astream`. You can also use `graph.get_state(thread_id)` to get the interrupt value(s).
!!! warning
@@ -145,19 +145,67 @@ To resume execution, use the [`Command`][langgraph.types.Command] primitive, whi
graph.invoke(Command(resume={"age": "25"}), thread_config)
```
### Resume multiple interrupts with one invocation
## Resuming Multiple interrupts
If you have multiple interrupts in the task queue, you can use `Command.resume` with a dictionary mapping of interrupt ids to resume with a single `invoke` / `stream` call.
When nodes with interrupt conditions are run in parallel, it's possible to have multiple interrupts in the task queue.
For example, the following graph has two nodes run in parallel that require human input:
<figure markdown="1">
![image](../assets/human_in_loop_parallel.png){: style="max-height:400px"}
</figure>
Once your graph has been interrupted and is stalled, you can resume all the interrupts at once with `Command.resume`, passing a dictionary mapping of interrupt ids to resume values.
For example, once your graph has been interrupted (multiple times, theoretically) and is stalled:
```python
resume_map = {
i.id: f"human input for prompt {i.value}"
for i in parent.get_state(thread_config).interrupts
}
from typing import TypedDict
import uuid
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.constants import START
from langgraph.graph import StateGraph
from langgraph.types import interrupt, Command
parent_graph.invoke(Command(resume=resume_map), config=thread_config)
class State(TypedDict):
text_1: str
text_2: str
def human_node_1(state: State):
value = interrupt({"text_to_revise": state["text_1"]})
return {"text_1": value}
def human_node_2(state: State):
value = interrupt({"text_to_revise": state["text_2"]})
return {"text_2": value}
graph_builder = StateGraph(State)
graph_builder.add_node("human_node_1", human_node_1)
graph_builder.add_node("human_node_2", human_node_2)
# Add both nodes in parallel from START
graph_builder.add_edge(START, "human_node_1")
graph_builder.add_edge(START, "human_node_2")
checkpointer = InMemorySaver()
graph = graph_builder.compile(checkpointer=checkpointer)
thread_id = str(uuid.uuid4())
config: RunnableConfig = {"configurable": {"thread_id": thread_id}}
result = graph.invoke(
{"text_1": "original text 1", "text_2": "original text 2"}, config=config
)
# Resume with mapping of interrupt IDs to values
resume_map = {
i.id: f"edited text for {i.value['text_to_revise']}"
for i in result["__interrupt__"]
}
print(graph.invoke(Command(resume=resume_map), config=config))
# > {'text_1': 'edited text for original text 1', 'text_2': 'edited text for original text 2'}
```
## Common patterns
@@ -1027,7 +1075,7 @@ def node_in_parent_graph(state: State):
{'parent_node': {'state_counter': 1}}
```
### Using multiple interrupts
### Using multiple interrupts in a single node
Using multiple interrupts within a **single** node can be helpful for patterns like [validating human input](#validate-human-input). However, using multiple interrupts in the same node can lead to unexpected behavior if not handled carefully.
+18
View File
@@ -0,0 +1,18 @@
# Runtime
::: langgraph.runtime.Runtime
options:
show_root_heading: true
show_root_full_path: false
members:
- context
- store
- stream_writer
- previous
::: langgraph.runtime
options:
members:
- get_runtime
+1
View File
@@ -250,6 +250,7 @@ nav:
- Storage: reference/store.md
- Caching: reference/cache.md
- Types: reference/types.md
- Runtime: reference/runtime.md
- Config: reference/config.md
- Errors: reference/errors.md
- Constants: reference/constants.md
+216
View File
@@ -0,0 +1,216 @@
"""Unit tests for cross-reference preprocessing functionality."""
from unittest.mock import patch
import pytest
from _scripts.handle_auto_links import _transform_link, _replace_autolinks
@pytest.fixture
def mock_link_maps():
"""Fixture providing mock link maps for testing."""
mock_scope_maps = {
"python": {"py-link": "https://example.com/python"},
"js": {"js-link": "https://example.com/js"},
}
with patch("_scripts.handle_auto_links.SCOPE_LINK_MAPS", mock_scope_maps):
yield mock_scope_maps
def test_transform_link_basic(mock_link_maps) -> None:
"""Test basic link transformation."""
# Test with a known link
result = _transform_link("py-link", "python", "test.md", 1)
assert result == "[py-link](https://example.com/python)"
# Test with an unknown link (returns None)
result = _transform_link("unknown-link", "global", "test.md", 1)
assert result is None
def test_transform_link_with_custom_title(mock_link_maps) -> None:
"""Test link transformation with custom title."""
# Test with a known link and custom title
result = _transform_link("py-link", "python", "test.md", 1, "Custom Python Link")
assert result == "[Custom Python Link](https://example.com/python)"
# Test with unknown link and custom title (should still return None)
result = _transform_link("unknown-link", "python", "test.md", 1, "Custom Title")
assert result is None
def test_no_cross_refs(mock_link_maps) -> None:
"""Test markdown with no @[references]."""
lines = ["# Title\n", "Regular text.\n"]
markdown = "".join(lines)
result = _replace_autolinks(markdown, "test.md")
expected = "".join(["# Title\n", "Regular text.\n"])
assert result == expected
def test_global_cross_refs(mock_link_maps) -> None:
"""Test @[references] in global scope (no conditional blocks)."""
lines = ["@[global-link]\n", "Text with @[unknown-link].\n"]
markdown = "".join(lines)
result = _replace_autolinks(markdown, "test.md")
expected = "".join(["@[global-link]\n", "Text with @[unknown-link].\n"])
assert result == expected
def test_python_conditional_block(mock_link_maps) -> None:
"""Test @[references] inside Python conditional block."""
lines = [":::python\n", "@[py-link]\n", ":::\n"]
markdown = "".join(lines)
result = _replace_autolinks(markdown, "test.md")
expected = "".join(
[":::python\n", "[py-link](https://example.com/python)\n", ":::\n"]
)
assert result == expected
def test_js_conditional_block(mock_link_maps) -> None:
"""Test @[references] inside JavaScript conditional block."""
lines = [":::js\n", "@[js-link]\n", ":::\n"]
markdown = "".join(lines)
result = _replace_autolinks(markdown, "test.md")
expected = "".join([":::js\n", "[js-link](https://example.com/js)\n", ":::\n"])
assert result == expected
def test_all_scopes(mock_link_maps) -> None:
"""Test @[references] in global, Python, and JavaScript scopes."""
lines = [
"@[global-link]\n",
":::python\n",
"@[py-link]\n",
":::\n",
"@[global-link]\n",
":::js\n",
"@[js-link]\n",
":::\n",
"@[global-link]\n",
]
markdown = "".join(lines)
result = _replace_autolinks(markdown, "test.md")
expected = "".join(
[
"@[global-link]\n",
":::python\n",
"[py-link](https://example.com/python)\n",
":::\n",
"@[global-link]\n",
":::js\n",
"[js-link](https://example.com/js)\n",
":::\n",
"@[global-link]\n",
]
)
assert result == expected
def test_fence_resets_to_global(mock_link_maps) -> None:
"""Test that closing fence resets scope to global."""
lines = [":::python\n", "@[py-link]\n", ":::\n", "@[global-link]\n"]
markdown = "".join(lines)
result = _replace_autolinks(markdown, "test.md")
expected = "".join(
[
":::python\n",
"[py-link](https://example.com/python)\n",
":::\n",
"@[global-link]\n",
]
)
assert result == expected
def test_indented_conditional_fences(mock_link_maps) -> None:
"""Test @[references] inside indented conditional fences (e.g., in tabs or admonitions)."""
lines = [
"@[global-link]\n",
" :::python\n",
" @[py-link]\n",
" :::\n",
"@[global-link]\n",
"\t\t:::js\n",
"\t\t@[js-link]\n",
"\t\t:::\n",
"@[global-link]\n",
]
markdown = "".join(lines)
result = _replace_autolinks(markdown, "test.md")
expected = "".join(
[
"@[global-link]\n",
" :::python\n",
" [py-link](https://example.com/python)\n",
" :::\n",
"@[global-link]\n",
"\t\t:::js\n",
"\t\t[js-link](https://example.com/js)\n",
"\t\t:::\n",
"@[global-link]\n",
]
)
assert result == expected
def test_custom_title_syntax(mock_link_maps) -> None:
"""Test @[title][ref] syntax with custom titles."""
lines = [
":::python\n",
"@[Custom Python Title][py-link]\n",
":::\n",
":::js\n",
"@[Custom JS Title][js-link]\n",
":::\n"
]
markdown = "".join(lines)
result = _replace_autolinks(markdown, "test.md")
expected = "".join([
":::python\n",
"[Custom Python Title](https://example.com/python)\n",
":::\n",
":::js\n",
"[Custom JS Title](https://example.com/js)\n",
":::\n"
])
assert result == expected
def test_mixed_syntax_compatibility(mock_link_maps) -> None:
"""Test that both @[ref] and @[title][ref] syntax work together."""
lines = [
":::python\n",
"@[py-link]\n", # Old syntax
"@[Custom Title][py-link]\n", # New syntax
":::\n"
]
markdown = "".join(lines)
result = _replace_autolinks(markdown, "test.md")
expected = "".join([
":::python\n",
"[py-link](https://example.com/python)\n",
"[Custom Title](https://example.com/python)\n",
":::\n"
])
assert result == expected
def test_custom_title_with_unknown_link(mock_link_maps) -> None:
"""Test @[title][ref] syntax with unknown reference."""
lines = [
":::python\n",
"@[Custom Title][unknown-link]\n",
":::\n"
]
markdown = "".join(lines)
result = _replace_autolinks(markdown, "test.md")
expected = "".join([
":::python\n",
"@[Custom Title][unknown-link]\n", # Should remain unchanged
":::\n"
])
assert result == expected
+112 -3
View File
@@ -152,6 +152,14 @@ base64-js@^1.5.1:
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6"
integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==
dependencies:
es-errors "^1.3.0"
function-bind "^1.1.2"
camelcase@6:
version "6.3.0"
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a"
@@ -201,6 +209,42 @@ delayed-stream@~1.0.0:
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
dunder-proto@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a"
integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==
dependencies:
call-bind-apply-helpers "^1.0.1"
es-errors "^1.3.0"
gopd "^1.2.0"
es-define-property@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa"
integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==
es-errors@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f"
integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==
es-object-atoms@^1.0.0, es-object-atoms@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1"
integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==
dependencies:
es-errors "^1.3.0"
es-set-tostringtag@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz#f31dbbe0c183b00a6d26eb6325c810c0fd18bd4d"
integrity sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==
dependencies:
es-errors "^1.3.0"
get-intrinsic "^1.2.6"
has-tostringtag "^1.0.2"
hasown "^2.0.2"
event-lite@^0.1.1:
version "0.1.3"
resolved "https://registry.yarnpkg.com/event-lite/-/event-lite-0.1.3.tgz#3dfe01144e808ac46448f0c19b4ab68e403a901d"
@@ -222,12 +266,14 @@ form-data-encoder@1.7.2:
integrity sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==
form-data@^4.0.0:
version "4.0.1"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.1.tgz#ba1076daaaa5bfd7e99c1a6cb02aa0a5cff90d48"
integrity sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==
version "4.0.4"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.4.tgz#784cdcce0669a9d68e94d11ac4eea98088edd2c4"
integrity sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==
dependencies:
asynckit "^0.4.0"
combined-stream "^1.0.8"
es-set-tostringtag "^2.1.0"
hasown "^2.0.2"
mime-types "^2.1.12"
formdata-node@^4.3.2:
@@ -238,11 +284,69 @@ formdata-node@^4.3.2:
node-domexception "1.0.0"
web-streams-polyfill "4.0.0-beta.3"
function-bind@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c"
integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
get-intrinsic@^1.2.6:
version "1.3.0"
resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01"
integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==
dependencies:
call-bind-apply-helpers "^1.0.2"
es-define-property "^1.0.1"
es-errors "^1.3.0"
es-object-atoms "^1.1.1"
function-bind "^1.1.2"
get-proto "^1.0.1"
gopd "^1.2.0"
has-symbols "^1.1.0"
hasown "^2.0.2"
math-intrinsics "^1.1.0"
get-proto@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1"
integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==
dependencies:
dunder-proto "^1.0.1"
es-object-atoms "^1.0.0"
gopd@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1"
integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==
has-flag@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
has-symbols@^1.0.3, has-symbols@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338"
integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==
has-tostringtag@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc"
integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==
dependencies:
has-symbols "^1.0.3"
hasown@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003"
integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==
dependencies:
function-bind "^1.1.2"
he@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
humanize-ms@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/humanize-ms/-/humanize-ms-1.2.1.tgz#c46e3159a293f6b896da29316d8b6fe8bb79bbed"
@@ -295,6 +399,11 @@ json-stringify-safe@^5.0.1:
semver "^7.6.3"
uuid "^10.0.0"
math-intrinsics@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
mime-db@1.52.0:
version "1.52.0"
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"
@@ -3,6 +3,7 @@ from __future__ import annotations
import concurrent.futures
import datetime
import logging
import re
import sqlite3
import threading
from collections import defaultdict
@@ -107,6 +108,23 @@ def _decode_ns_text(namespace: str) -> tuple[str, ...]:
return tuple(namespace.split("."))
def _validate_filter_key(key: str) -> None:
"""Validate that a filter key is safe for use in SQL queries.
Args:
key: The filter key to validate
Raises:
ValueError: If the key contains invalid characters that could enable SQL injection
"""
# Allow alphanumeric characters, underscores, dots, and hyphens
# This covers typical JSON property names while preventing SQL injection
if not re.match(r"^[a-zA-Z0-9_.-]+$", key):
raise ValueError(
f"Invalid filter key: '{key}'. Filter keys must contain only alphanumeric characters, underscores, dots, and hyphens."
)
def _json_loads(content: bytes | str | orjson.Fragment) -> Any:
if isinstance(content, orjson.Fragment):
if hasattr(content, "buf"):
@@ -372,6 +390,8 @@ class BaseSqliteStore:
filter_conditions = []
if op.filter:
for key, value in op.filter.items():
_validate_filter_key(key)
if isinstance(value, dict):
for op_name, val in value.items():
condition, filter_params_ = self._get_filter_condition(
@@ -622,6 +642,8 @@ class BaseSqliteStore:
def _get_filter_condition(self, key: str, op: str, value: Any) -> tuple[str, list]:
"""Helper to generate filter conditions."""
_validate_filter_key(key)
# We need to properly format values for SQLite JSON extraction comparison
if op == "$eq":
if isinstance(value, str):
@@ -858,6 +880,8 @@ class SqliteStore(BaseSqliteStore, BaseStore):
def _get_filter_condition(self, key: str, op: str, value: Any) -> tuple[str, list]:
"""Helper to generate filter conditions."""
_validate_filter_key(key)
# We need to properly format values for SQLite JSON extraction comparison
if op == "$eq":
if isinstance(value, str):
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph-checkpoint-sqlite"
version = "2.0.10"
version = "2.0.11"
description = "Library with a SQLite implementation of LangGraph checkpoint saver."
authors = []
requires-python = ">=3.9"
@@ -1047,3 +1047,23 @@ def test_search_items(
for ns in test_namespaces:
key = f"item_{ns[-1]}"
store.delete(ns, key)
def test_sql_injection_vulnerability(store: SqliteStore) -> None:
"""Test that SQL injection via malicious filter keys is prevented."""
# Add public and private documents
store.put(("docs",), "public", {"access": "public", "data": "public info"})
store.put(
("docs",), "private", {"access": "private", "data": "secret", "password": "123"}
)
# Normal query - returns 1 public document
normal = store.search(("docs",), filter={"access": "public"})
assert len(normal) == 1
assert normal[0].value["access"] == "public"
# SQL injection attempt via malicious key should raise ValueError
malicious_key = "access') = 'public' OR '1'='1' OR json_extract(value, '$."
with pytest.raises(ValueError, match="Invalid filter key"):
store.search(("docs",), filter={malicious_key: "dummy"})
+1 -1
View File
@@ -346,7 +346,7 @@ dev = [
[[package]]
name = "langgraph-checkpoint-sqlite"
version = "2.0.10"
version = "2.0.11"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },
+32 -4
View File
@@ -153,6 +153,12 @@ OPT_POSTGRES_URI = click.option(
help="Postgres URI to use for the database. Defaults to launching a local database",
)
OPT_API_VERSION = click.option(
"--api-version",
type=str,
help="API server version to use for the base image. If unspecified, the latest version will be used.",
)
@click.group()
@click.version_option(version=__version__, prog_name="LangGraph CLI")
@@ -170,6 +176,7 @@ def cli():
@OPT_DEBUGGER_BASE_URL
@OPT_WATCH
@OPT_POSTGRES_URI
@OPT_API_VERSION
@click.option(
"--image",
type=str,
@@ -203,6 +210,7 @@ def up(
debugger_port: Optional[int],
debugger_base_url: Optional[str],
postgres_uri: Optional[str],
api_version: Optional[str],
image: Optional[str],
base_image: Optional[str],
):
@@ -225,6 +233,7 @@ For production use, requires a license key in env var LANGGRAPH_CLOUD_LICENSE_KE
debugger_port=debugger_port,
debugger_base_url=debugger_base_url,
postgres_uri=postgres_uri,
api_version=api_version,
image=image,
base_image=base_image,
)
@@ -290,6 +299,7 @@ def _build(
config: pathlib.Path,
config_json: dict,
base_image: Optional[str],
api_version: Optional[str],
pull: bool,
tag: str,
passthrough: Sequence[str] = (),
@@ -300,7 +310,7 @@ def _build(
subp_exec(
"docker",
"pull",
langgraph_cli.config.docker_tag(config_json, base_image),
langgraph_cli.config.docker_tag(config_json, base_image, api_version),
verbose=True,
)
)
@@ -314,7 +324,7 @@ def _build(
]
# apply config
stdin, additional_contexts = langgraph_cli.config.config_to_docker(
config, config_json, base_image
config, config_json, base_image, api_version
)
# add additional_contexts
if additional_contexts:
@@ -355,6 +365,7 @@ def _build(
"\n\n \b\nExamples:\n --base-image langchain/langgraph-server:0.2.18 # Pin to a specific patch version"
"\n --base-image langchain/langgraph-server:0.2 # Pin to a minor version (Python)",
)
@OPT_API_VERSION
@click.argument("docker_build_args", nargs=-1, type=click.UNPROCESSED)
@cli.command(
help="📦 Build LangGraph API server Docker image.",
@@ -367,6 +378,7 @@ def build(
config: pathlib.Path,
docker_build_args: Sequence[str],
base_image: Optional[str],
api_version: Optional[str],
pull: bool,
tag: str,
):
@@ -376,7 +388,15 @@ def build(
config_json = langgraph_cli.config.validate_config_file(config)
warn_non_wolfi_distro(config_json)
_build(
runner, set, config, config_json, base_image, pull, tag, docker_build_args
runner,
set,
config,
config_json,
base_image,
api_version,
pull,
tag,
docker_build_args,
)
@@ -456,12 +476,14 @@ tests
"\n\n \b\nExamples:\n --base-image langchain/langgraph-server:0.2.18 # Pin to a specific patch version"
"\n --base-image langchain/langgraph-server:0.2 # Pin to a minor version (Python)",
)
@OPT_API_VERSION
@log_command
def dockerfile(
save_path: str,
config: pathlib.Path,
add_docker_compose: bool,
base_image: Optional[str] = None,
api_version: Optional[str] = None,
) -> None:
save_path = pathlib.Path(save_path).absolute()
secho(f"🔍 Validating configuration at path: {config}", fg="yellow")
@@ -474,6 +496,7 @@ def dockerfile(
config,
config_json,
base_image=base_image,
api_version=api_version,
)
with open(str(save_path), "w", encoding="utf-8") as f:
f.write(dockerfile)
@@ -739,6 +762,7 @@ def prepare_args_and_stdin(
debugger_port: Optional[int] = None,
debugger_base_url: Optional[str] = None,
postgres_uri: Optional[str] = None,
api_version: Optional[str] = None,
# Like "my-tag" (if you already built it locally)
image: Optional[str] = None,
# Like "langchain/langgraphjs-api" or "langchain/langgraph-api
@@ -754,6 +778,7 @@ def prepare_args_and_stdin(
postgres_uri=postgres_uri,
image=image, # Pass image to compose YAML generator
base_image=base_image,
api_version=api_version,
)
args = [
"--project-directory",
@@ -769,6 +794,7 @@ def prepare_args_and_stdin(
config,
watch=watch,
base_image=langgraph_cli.config.default_base_image(config),
api_version=api_version,
image=image,
)
return args, stdin
@@ -787,6 +813,7 @@ def prepare(
debugger_port: Optional[int] = None,
debugger_base_url: Optional[str] = None,
postgres_uri: Optional[str] = None,
api_version: Optional[str] = None,
image: Optional[str] = None,
base_image: Optional[str] = None,
) -> tuple[list[str], str]:
@@ -799,7 +826,7 @@ def prepare(
subp_exec(
"docker",
"pull",
langgraph_cli.config.docker_tag(config_json, base_image),
langgraph_cli.config.docker_tag(config_json, base_image, api_version),
verbose=verbose,
)
)
@@ -814,6 +841,7 @@ def prepare(
debugger_port=debugger_port,
debugger_base_url=debugger_base_url or f"http://127.0.0.1:{port}",
postgres_uri=postgres_uri,
api_version=api_version,
image=image,
base_image=base_image,
)
+25 -7
View File
@@ -1213,6 +1213,7 @@ def python_config_to_docker(
config_path: pathlib.Path,
config: Config,
base_image: str,
api_version: Optional[str] = None,
) -> tuple[str, dict[str, str]]:
"""Generate a Dockerfile from the configuration."""
pip_installer = config.get("pip_installer", "auto")
@@ -1360,7 +1361,7 @@ ADD {relpath} /deps/{name}
"# -- End of JS dependencies install --",
]
)
image_str = docker_tag(config, base_image)
image_str = docker_tag(config, base_image, api_version)
docker_file_contents = [
f"FROM {image_str}",
"",
@@ -1402,10 +1403,11 @@ def node_config_to_docker(
config_path: pathlib.Path,
config: Config,
base_image: str,
api_version: Optional[str] = None,
) -> tuple[str, dict[str, str]]:
faux_path = f"/deps/{config_path.parent.name}"
install_cmd = _get_node_pm_install_cmd(config_path, config)
image_str = docker_tag(config, base_image)
image_str = docker_tag(config, base_image, api_version)
env_vars: list[str] = []
@@ -1461,6 +1463,7 @@ def default_base_image(config: Config) -> str:
def docker_tag(
config: Config,
base_image: Optional[str] = None,
api_version: Optional[str] = None,
) -> str:
base_image = base_image or default_base_image(config)
@@ -1473,28 +1476,43 @@ def docker_tag(
if "/langgraph-server" in base_image:
return f"{base_image}-py{config['python_version']}"
# Build the standard tag format
language, version = None, None
if config.get("node_version") and not config.get("python_version"):
return f"{base_image}:{config['node_version']}{distro_tag}"
return f"{base_image}:{config['python_version']}{distro_tag}"
language, version = "node", config["node_version"]
else:
language, version = "py", config["python_version"]
version_distro_tag = f"{version}{distro_tag}"
# Prepend API version if provided
if api_version:
full_tag = f"{api_version}-{language}{version_distro_tag}"
else:
full_tag = version_distro_tag
return f"{base_image}:{full_tag}"
def config_to_docker(
config_path: pathlib.Path,
config: Config,
base_image: Optional[str] = None,
api_version: Optional[str] = None,
) -> tuple[str, dict[str, str]]:
base_image = base_image or default_base_image(config)
if config.get("node_version") and not config.get("python_version"):
return node_config_to_docker(config_path, config, base_image)
return node_config_to_docker(config_path, config, base_image, api_version)
return python_config_to_docker(config_path, config, base_image)
return python_config_to_docker(config_path, config, base_image, api_version)
def config_to_compose(
config_path: pathlib.Path,
config: Config,
base_image: Optional[str] = None,
api_version: Optional[str] = None,
image: Optional[str] = None,
watch: bool = False,
) -> str:
@@ -1531,7 +1549,7 @@ def config_to_compose(
else:
dockerfile, additional_contexts = config_to_docker(
config_path, config, base_image
config_path, config, base_image, api_version
)
additional_contexts_str = "\n".join(
+4
View File
@@ -147,6 +147,8 @@ def compose_as_dict(
image: Optional[str] = None,
# Base image to use for the LangGraph API server
base_image: Optional[str] = None,
# API version of the base image
api_version: Optional[str] = None,
) -> dict:
"""Create a docker compose file as a dictionary in YML style."""
if postgres_uri is None:
@@ -252,6 +254,7 @@ def compose(
postgres_uri: Optional[str] = None,
image: Optional[str] = None,
base_image: Optional[str] = None,
api_version: Optional[str] = None,
) -> str:
"""Create a docker compose file as a string."""
compose_content = compose_as_dict(
@@ -262,6 +265,7 @@ def compose(
postgres_uri=postgres_uri,
image=image,
base_image=base_image,
api_version=api_version,
)
compose_str = dict_to_yaml(compose_content)
return compose_str
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph-cli"
version = "0.3.5"
version = "0.3.6"
description = "CLI for interacting with LangGraph API"
authors = []
requires-python = ">=3.9"
+245
View File
@@ -574,3 +574,248 @@ def test_build_generate_proper_build_context():
assert len(build_contexts) == 2, (
f"Expected 2 build contexts, but found {len(build_contexts)}"
)
def test_dockerfile_command_with_api_version() -> None:
"""Test the 'dockerfile' command with --api-version flag."""
runner = CliRunner()
config_content = {
"python_version": "3.11",
"graphs": {"agent": "agent.py:graph"},
"dependencies": ["."],
}
with temporary_config_folder(config_content) as temp_dir:
save_path = temp_dir / "Dockerfile"
agent_path = temp_dir / "agent.py"
agent_path.touch()
result = runner.invoke(
cli,
[
"dockerfile",
str(save_path),
"--config",
str(temp_dir / "config.json"),
"--api-version",
"0.2.74",
],
)
# Assert command was successful
assert result.exit_code == 0, result.output
assert "✅ Created: Dockerfile" in result.output
# Check if Dockerfile was created and contains correct FROM line
assert save_path.exists()
with open(save_path) as f:
dockerfile = f.read()
assert "FROM langchain/langgraph-api:0.2.74-py3.11" in dockerfile
def test_dockerfile_command_with_api_version_and_base_image() -> None:
"""Test the 'dockerfile' command with both --api-version and --base-image flags."""
runner = CliRunner()
config_content = {
"python_version": "3.12",
"graphs": {"agent": "agent.py:graph"},
"dependencies": ["."],
"image_distro": "wolfi",
}
with temporary_config_folder(config_content) as temp_dir:
save_path = temp_dir / "Dockerfile"
agent_path = temp_dir / "agent.py"
agent_path.touch()
result = runner.invoke(
cli,
[
"dockerfile",
str(save_path),
"--config",
str(temp_dir / "config.json"),
"--api-version",
"1.0.0",
"--base-image",
"my-registry/custom-api",
],
)
# Assert command was successful
assert result.exit_code == 0, result.output
assert "✅ Created: Dockerfile" in result.output
# Check if Dockerfile was created and contains correct FROM line
assert save_path.exists()
with open(save_path) as f:
dockerfile = f.read()
assert "FROM my-registry/custom-api:1.0.0-py3.12-wolfi" in dockerfile
def test_dockerfile_command_with_api_version_nodejs() -> None:
"""Test the 'dockerfile' command with --api-version flag for Node.js config."""
runner = CliRunner()
config_content = {
"node_version": "20",
"graphs": {"agent": "agent.js:graph"},
}
with temporary_config_folder(config_content) as temp_dir:
save_path = temp_dir / "Dockerfile"
agent_path = temp_dir / "agent.js"
agent_path.touch()
result = runner.invoke(
cli,
[
"dockerfile",
str(save_path),
"--config",
str(temp_dir / "config.json"),
"--api-version",
"0.2.74",
],
)
# Assert command was successful
assert result.exit_code == 0, result.output
assert "✅ Created: Dockerfile" in result.output
# Check if Dockerfile was created and contains correct FROM line
assert save_path.exists()
with open(save_path) as f:
dockerfile = f.read()
assert "FROM langchain/langgraphjs-api:0.2.74-node20" in dockerfile
def test_build_command_with_api_version() -> None:
"""Test the 'build' command with --api-version flag."""
runner = CliRunner()
config_content = {
"python_version": "3.11",
"graphs": {"agent": "agent.py:graph"},
"dependencies": ["."],
"image_distro": "wolfi", # Use wolfi to avoid warning messages
}
with temporary_config_folder(config_content) as temp_dir:
agent_path = temp_dir / "agent.py"
agent_path.touch()
# Mock docker command since we don't want to actually build
with runner.isolated_filesystem():
result = runner.invoke(
cli,
[
"build",
"--tag",
"test-image",
"--config",
str(temp_dir / "config.json"),
"--api-version",
"0.2.74",
"--no-pull", # Avoid pulling non-existent images
],
catch_exceptions=True,
)
# Check that the build command is called with the correct tag
# The output should contain the docker build command with the api_version tag
assert "langchain/langgraph-api:0.2.74-py3.11-wolfi" in result.output
def test_build_command_with_api_version_and_base_image() -> None:
"""Test the 'build' command with both --api-version and --base-image flags."""
runner = CliRunner()
config_content = {
"python_version": "3.12",
"graphs": {"agent": "agent.py:graph"},
"dependencies": ["."],
"image_distro": "wolfi", # Use wolfi to avoid warning messages
}
with temporary_config_folder(config_content) as temp_dir:
agent_path = temp_dir / "agent.py"
agent_path.touch()
# Mock docker command since we don't want to actually build
with runner.isolated_filesystem():
result = runner.invoke(
cli,
[
"build",
"--tag",
"test-image",
"--config",
str(temp_dir / "config.json"),
"--api-version",
"1.0.0",
"--base-image",
"my-registry/custom-api",
"--no-pull", # Avoid pulling non-existent images
],
catch_exceptions=True,
)
# Check that the build command includes the api_version
assert "my-registry/custom-api:1.0.0-py3.12-wolfi" in result.output
def test_prepare_args_and_stdin_with_api_version() -> None:
"""Test prepare_args_and_stdin function with api_version parameter."""
config_path = pathlib.Path(__file__).parent / "langgraph.json"
config = validate_config(
Config(dependencies=["."], graphs={"agent": "agent.py:graph"})
)
port = 8000
api_version = "0.2.74"
actual_args, actual_stdin = prepare_args_and_stdin(
capabilities=DEFAULT_DOCKER_CAPABILITIES,
config_path=config_path,
config=config,
docker_compose=None,
port=port,
watch=False,
api_version=api_version,
)
expected_args = [
"--project-directory",
str(pathlib.Path(__file__).parent.absolute()),
"-f",
"-",
]
# Check that the args are correct
assert actual_args == expected_args
# Check that the stdin contains the correct FROM line with api_version
assert "FROM langchain/langgraph-api:0.2.74-py3.11" in actual_stdin
def test_prepare_args_and_stdin_with_api_version_and_image() -> None:
"""Test prepare_args_and_stdin function with both api_version and image parameters."""
config_path = pathlib.Path(__file__).parent / "langgraph.json"
config = validate_config(
Config(dependencies=["."], graphs={"agent": "agent.py:graph"})
)
port = 8000
api_version = "0.2.74"
image = "my-custom-image:latest"
actual_args, actual_stdin = prepare_args_and_stdin(
capabilities=DEFAULT_DOCKER_CAPABILITIES,
config_path=config_path,
config=config,
docker_compose=None,
port=port,
watch=False,
api_version=api_version,
image=image,
)
# When image is provided, api_version should be ignored for the image
# but the stdin should not contain a build section (since image is provided)
assert "pull_policy: build" not in actual_stdin
+192
View File
@@ -1337,3 +1337,195 @@ def test_docker_tag_different_node_versions_with_distro():
)
tag = docker_tag(config)
assert tag == expected_tag, f"Failed for Node.js {node_version}"
def test_docker_tag_with_api_version():
"""Test docker_tag function with api_version parameter."""
# Test 1: Python config with api_version and default distro
config = validate_config(
{
"python_version": "3.11",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
}
)
tag = docker_tag(config, api_version="0.2.74")
assert tag == "langchain/langgraph-api:0.2.74-py3.11"
# Test 2: Python config with api_version and wolfi distro
config = validate_config(
{
"python_version": "3.12",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"image_distro": "wolfi",
}
)
tag = docker_tag(config, api_version="0.2.74")
assert tag == "langchain/langgraph-api:0.2.74-py3.12-wolfi"
# Test 3: Node.js config with api_version and default distro
config = validate_config(
{
"node_version": "20",
"graphs": {"agent": "./agent.js:graph"},
}
)
tag = docker_tag(config, api_version="0.2.74")
assert tag == "langchain/langgraphjs-api:0.2.74-node20"
# Test 4: Node.js config with api_version and wolfi distro
config = validate_config(
{
"node_version": "20",
"graphs": {"agent": "./agent.js:graph"},
"image_distro": "wolfi",
}
)
tag = docker_tag(config, api_version="0.2.74")
assert tag == "langchain/langgraphjs-api:0.2.74-node20-wolfi"
# Test 5: Custom base image with api_version
config = validate_config(
{
"python_version": "3.11",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"base_image": "my-registry/custom-image",
}
)
tag = docker_tag(config, base_image="my-registry/custom-image", api_version="1.0.0")
assert tag == "my-registry/custom-image:1.0.0-py3.11"
# Test 6: api_version with different Python versions
for python_version in ["3.11", "3.12", "3.13"]:
config = validate_config(
{
"python_version": python_version,
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
}
)
tag = docker_tag(config, api_version="0.2.74")
assert tag == f"langchain/langgraph-api:0.2.74-py{python_version}"
# Test 7: Without api_version should work as before
config = validate_config(
{
"python_version": "3.11",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
}
)
tag = docker_tag(config)
assert tag == "langchain/langgraph-api:3.11"
# Test 8: api_version with multiplatform config (should default to Python)
config = validate_config(
{
"python_version": "3.11",
"node_version": "20",
"dependencies": ["."],
"graphs": {"python": "./agent.py:graph", "js": "./agent.js:graph"},
}
)
tag = docker_tag(config, api_version="0.2.74")
assert tag == "langchain/langgraph-api:0.2.74-py3.11"
# Test 9: api_version with _INTERNAL_docker_tag should ignore api_version
config = validate_config(
{
"python_version": "3.11",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"_INTERNAL_docker_tag": "internal-tag",
}
)
tag = docker_tag(config, api_version="0.2.74")
assert tag == "langchain/langgraph-api:internal-tag"
# Test 10: api_version with langgraph-server base image should follow special format
config = validate_config(
{
"python_version": "3.11",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
}
)
tag = docker_tag(
config, base_image="langchain/langgraph-server:0.2", api_version="0.2.74"
)
assert tag == "langchain/langgraph-server:0.2-py3.11"
def test_config_to_docker_with_api_version():
"""Test config_to_docker function with api_version parameter."""
# Test Python config with api_version
graphs = {"agent": "./agent.py:graph"}
actual_docker_stdin, additional_contexts = config_to_docker(
PATH_TO_CONFIG,
validate_config({"dependencies": ["."], "graphs": graphs}),
"langchain/langgraph-api",
api_version="0.2.74",
)
# Check that the FROM line uses the api_version
lines = actual_docker_stdin.split("\n")
from_line = lines[0]
assert from_line == "FROM langchain/langgraph-api:0.2.74-py3.11"
# Test Node.js config with api_version
graphs = {"agent": "./agent.js:graph"}
actual_docker_stdin, additional_contexts = config_to_docker(
PATH_TO_CONFIG,
validate_config({"node_version": "20", "graphs": graphs}),
"langchain/langgraphjs-api",
api_version="0.2.74",
)
# Check that the FROM line uses the api_version
lines = actual_docker_stdin.split("\n")
from_line = lines[0]
assert from_line == "FROM langchain/langgraphjs-api:0.2.74-node20"
def test_config_to_compose_with_api_version():
"""Test config_to_compose function with api_version parameter."""
# Test Python config with api_version
config = validate_config(
{
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
}
)
actual_compose_str = config_to_compose(
PATH_TO_CONFIG,
config,
"langchain/langgraph-api",
api_version="0.2.74",
)
# Check that the compose file includes the correct FROM line with api_version
assert "FROM langchain/langgraph-api:0.2.74-py3.11" in actual_compose_str
# Test Node.js config with api_version
config = validate_config(
{
"node_version": "20",
"graphs": {"agent": "./agent.js:graph"},
}
)
actual_compose_str = config_to_compose(
PATH_TO_CONFIG,
config,
"langchain/langgraphjs-api",
api_version="0.2.74",
)
# Check that the compose file includes the correct FROM line with api_version
assert "FROM langchain/langgraphjs-api:0.2.74-node20" in actual_compose_str
+217
View File
@@ -146,3 +146,220 @@ services:
REDIS_URI: redis://langgraph-redis:6379
POSTGRES_URI: {DEFAULT_POSTGRES_URI}"""
assert clean_empty_lines(actual_compose_str) == expected_compose_str
def test_compose_with_api_version():
"""Test compose function with api_version parameter."""
port = 8123
api_version = "0.2.74"
actual_compose_str = compose(
DEFAULT_DOCKER_CAPABILITIES, port=port, api_version=api_version
)
# The compose function should generate a compose file that doesn't directly
# reference the api_version, since it's handled in the docker tag creation
# when building the image. The compose function mainly sets up services.
expected_compose_str = f"""volumes:
langgraph-data:
driver: local
services:
langgraph-redis:
image: redis:6
healthcheck:
test: redis-cli ping
interval: 5s
timeout: 1s
retries: 5
langgraph-postgres:
image: pgvector/pgvector:pg16
ports:
- "5433:5432"
environment:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
command:
- postgres
- -c
- shared_preload_libraries=vector
volumes:
- langgraph-data:/var/lib/postgresql/data
healthcheck:
test: pg_isready -U postgres
start_period: 10s
timeout: 1s
retries: 5
interval: 5s
langgraph-api:
ports:
- "{port}:8000"
depends_on:
langgraph-redis:
condition: service_healthy
langgraph-postgres:
condition: service_healthy
environment:
REDIS_URI: redis://langgraph-redis:6379
POSTGRES_URI: {DEFAULT_POSTGRES_URI}"""
assert clean_empty_lines(actual_compose_str) == expected_compose_str
def test_compose_with_api_version_and_base_image():
"""Test compose function with both api_version and base_image parameters."""
port = 8123
api_version = "1.0.0"
base_image = "my-registry/custom-api"
actual_compose_str = compose(
DEFAULT_DOCKER_CAPABILITIES,
port=port,
api_version=api_version,
base_image=base_image,
)
# Similar to the previous test - the compose function doesn't directly embed
# the api_version or base_image into the compose file since those are handled
# during the docker build process
expected_compose_str = f"""volumes:
langgraph-data:
driver: local
services:
langgraph-redis:
image: redis:6
healthcheck:
test: redis-cli ping
interval: 5s
timeout: 1s
retries: 5
langgraph-postgres:
image: pgvector/pgvector:pg16
ports:
- "5433:5432"
environment:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
command:
- postgres
- -c
- shared_preload_libraries=vector
volumes:
- langgraph-data:/var/lib/postgresql/data
healthcheck:
test: pg_isready -U postgres
start_period: 10s
timeout: 1s
retries: 5
interval: 5s
langgraph-api:
ports:
- "{port}:8000"
depends_on:
langgraph-redis:
condition: service_healthy
langgraph-postgres:
condition: service_healthy
environment:
REDIS_URI: redis://langgraph-redis:6379
POSTGRES_URI: {DEFAULT_POSTGRES_URI}"""
assert clean_empty_lines(actual_compose_str) == expected_compose_str
def test_compose_with_api_version_and_custom_postgres():
"""Test compose function with api_version and custom postgres URI."""
port = 8123
api_version = "0.2.74"
custom_postgres_uri = "postgresql://user:pass@external-db:5432/mydb"
actual_compose_str = compose(
DEFAULT_DOCKER_CAPABILITIES,
port=port,
api_version=api_version,
postgres_uri=custom_postgres_uri,
)
expected_compose_str = f"""services:
langgraph-redis:
image: redis:6
healthcheck:
test: redis-cli ping
interval: 5s
timeout: 1s
retries: 5
langgraph-api:
ports:
- "{port}:8000"
depends_on:
langgraph-redis:
condition: service_healthy
environment:
REDIS_URI: redis://langgraph-redis:6379
POSTGRES_URI: {custom_postgres_uri}"""
assert clean_empty_lines(actual_compose_str) == expected_compose_str
def test_compose_with_api_version_and_debugger():
"""Test compose function with api_version and debugger port."""
port = 8123
debugger_port = 8001
api_version = "0.2.74"
actual_compose_str = compose(
DEFAULT_DOCKER_CAPABILITIES,
port=port,
api_version=api_version,
debugger_port=debugger_port,
)
expected_compose_str = f"""volumes:
langgraph-data:
driver: local
services:
langgraph-redis:
image: redis:6
healthcheck:
test: redis-cli ping
interval: 5s
timeout: 1s
retries: 5
langgraph-postgres:
image: pgvector/pgvector:pg16
ports:
- "5433:5432"
environment:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
command:
- postgres
- -c
- shared_preload_libraries=vector
volumes:
- langgraph-data:/var/lib/postgresql/data
healthcheck:
test: pg_isready -U postgres
start_period: 10s
timeout: 1s
retries: 5
interval: 5s
langgraph-debugger:
image: langchain/langgraph-debugger
restart: on-failure
depends_on:
langgraph-postgres:
condition: service_healthy
ports:
- "{debugger_port}:3968"
langgraph-api:
ports:
- "{port}:8000"
depends_on:
langgraph-redis:
condition: service_healthy
langgraph-postgres:
condition: service_healthy
environment:
REDIS_URI: redis://langgraph-redis:6379
POSTGRES_URI: {DEFAULT_POSTGRES_URI}"""
assert clean_empty_lines(actual_compose_str) == expected_compose_str
+1 -1
View File
@@ -531,7 +531,7 @@ wheels = [
[[package]]
name = "langgraph-cli"
version = "0.3.5"
version = "0.3.6"
source = { editable = "." }
dependencies = [
{ name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
@@ -165,7 +165,7 @@ def patch_config(
Defaults to None.
recursion_limit: The recursion limit to set.
Defaults to None.
max_concurrency: The max concurrency to set.
max_concurrency: The max number of concurrent steps to run, which also applies to parallelized steps.
Defaults to None.
run_name: The run name to set. Defaults to None.
configurable: The configurable to set.
@@ -132,7 +132,13 @@ ASYNCIO_ACCEPTS_CONTEXT = sys.version_info >= (3, 11)
KWARGS_CONFIG_KEYS: tuple[tuple[str, tuple[Any, ...], str, Any], ...] = (
(
"config",
(RunnableConfig, "RunnableConfig", inspect.Parameter.empty),
(
RunnableConfig,
"RunnableConfig",
Optional[RunnableConfig],
"Optional[RunnableConfig]",
inspect.Parameter.empty,
),
# for now, use config directly, eventually, will pop off of Runtime
"N/A",
inspect.Parameter.empty,
@@ -262,6 +262,11 @@ class entrypoint(Generic[ContextT]):
cache_policy: A cache policy to use for caching the results of the workflow.
retry_policy: A retry policy (or list of policies) to use for the workflow in case of a failure.
!!! warning "`config_schema` Deprecated"
The `config_schema` parameter is deprecated in v0.6.0 and support will be removed in v2.0.0.
Please use `context_schema` instead to specify the schema for run-scoped context.
Example: Using entrypoint and tasks
```python
import time
+4
View File
@@ -129,6 +129,10 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
input_schema: The schema class that defines the input to the graph.
output_schema: The schema class that defines the output from the graph.
!!! warning "`config_schema` Deprecated"
The `config_schema` parameter is deprecated in v0.6.0 and support will be removed in v2.0.0.
Please use `context_schema` instead to specify the schema for run-scoped context.
Example:
```python
from langchain_core.runnables import RunnableConfig
+20 -3
View File
@@ -116,7 +116,7 @@ from langgraph.pregel._validate import validate_graph, validate_keys
from langgraph.pregel._write import ChannelWrite, ChannelWriteEntry
from langgraph.pregel.debug import get_bolded_text, get_colored_text, tasks_w_writes
from langgraph.pregel.protocol import PregelProtocol, StreamChunk, StreamProtocol
from langgraph.runtime import Runtime
from langgraph.runtime import DEFAULT_RUNTIME, Runtime
from langgraph.store.base import BaseStore
from langgraph.types import (
All,
@@ -602,6 +602,7 @@ class Pregel(
Defaults to None."""
context_schema: type[ContextT] | None = None
"""Specifies the schema for the context object that will be passed to the workflow."""
config: RunnableConfig | None = None
@@ -2438,6 +2439,8 @@ class Pregel(
Args:
input: The input to the graph.
config: The configuration to use for the run.
context: The static context to use for the run.
!!! version-added "Added in version 0.6.0."
stream_mode: The mode to stream output, defaults to `self.stream_mode`.
Options are:
@@ -2567,12 +2570,16 @@ class Pregel(
if durability is not None or deprecated_checkpoint_during is not None:
config[CONF][CONFIG_KEY_DURABILITY] = durability_
config[CONF][CONFIG_KEY_RUNTIME] = Runtime(
runtime = Runtime(
context=context,
store=store,
stream_writer=stream_writer,
previous=None,
)
parent_runtime = config[CONF].get(CONFIG_KEY_RUNTIME, DEFAULT_RUNTIME)
runtime = parent_runtime.merge(runtime)
config[CONF][CONFIG_KEY_RUNTIME] = runtime
with SyncPregelLoop(
input,
stream=StreamProtocol(stream.put, stream_modes),
@@ -2694,6 +2701,8 @@ class Pregel(
Args:
input: The input to the graph.
config: The configuration to use for the run.
context: The static context to use for the run.
!!! version-added "Added in version 0.6.0."
stream_mode: The mode to stream output, defaults to `self.stream_mode`.
Options are:
@@ -2856,12 +2865,16 @@ class Pregel(
if durability is not None or deprecated_checkpoint_during is not None:
config[CONF][CONFIG_KEY_DURABILITY] = durability_
config[CONF][CONFIG_KEY_RUNTIME] = Runtime(
runtime = Runtime(
context=context,
store=store,
stream_writer=stream_writer,
previous=None,
)
parent_runtime = config[CONF].get(CONFIG_KEY_RUNTIME, DEFAULT_RUNTIME)
runtime = parent_runtime.merge(runtime)
config[CONF][CONFIG_KEY_RUNTIME] = runtime
async with AsyncPregelLoop(
input,
stream=StreamProtocol(stream.put_nowait, stream_modes),
@@ -2981,6 +2994,8 @@ class Pregel(
Args:
input: The input data for the graph. It can be a dictionary or any other type.
config: Optional. The configuration for the graph run.
context: The static context to use for the run.
!!! version-added "Added in version 0.6.0."
stream_mode: Optional[str]. The stream mode for the graph run. Default is "values".
print_mode: Accepts the same values as `stream_mode`, but only prints the output to the console, for debugging purposes. Does not affect the output of the graph in any way.
output_keys: Optional. The output keys to retrieve from the graph run.
@@ -3058,6 +3073,8 @@ class Pregel(
Args:
input: The input data for the computation. It can be a dictionary or any other type.
config: Optional. The configuration for the computation.
context: The static context to use for the run.
!!! version-added "Added in version 0.6.0."
stream_mode: Optional. The stream mode for the computation. Default is "values".
print_mode: Accepts the same values as `stream_mode`, but only prints the output to the console, for debugging purposes. Does not affect the output of the graph in any way.
output_keys: Optional. The output keys to include in the result. Default is None.
+34
View File
@@ -8,6 +8,7 @@ from typing import (
cast,
)
import langsmith as ls
from langchain_core.runnables import RunnableConfig
from langchain_core.runnables.graph import (
Edge as DrawableEdge,
@@ -118,6 +119,7 @@ class RemoteGraph(PregelProtocol):
sync_client: SyncLangGraphClient | None = None,
config: RunnableConfig | None = None,
name: str | None = None,
distributed_tracing: bool = False,
):
"""Specify `url`, `api_key`, and/or `headers` to create default sync and async clients.
@@ -136,6 +138,7 @@ class RemoteGraph(PregelProtocol):
name: Human-readable name to attach to the RemoteGraph instance.
This is useful for adding `RemoteGraph` as a subgraph via `graph.add_node(remote_graph)`.
If not provided, defaults to the assistant ID.
distributed_tracing: Whether to enable sending LangSmith distributed tracing headers.
"""
self.assistant_id = assistant_id
if name is None:
@@ -143,6 +146,7 @@ class RemoteGraph(PregelProtocol):
else:
self.name = name
self.config = config
self.distributed_tracing = distributed_tracing
if client is None and url is not None:
client = get_client(url=url, api_key=api_key, headers=headers)
@@ -629,6 +633,7 @@ class RemoteGraph(PregelProtocol):
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: bool = False,
headers: dict[str, str] | None = None,
**kwargs: Any,
) -> Iterator[dict[str, Any] | Any]:
"""Create a run and stream the results.
@@ -644,6 +649,7 @@ class RemoteGraph(PregelProtocol):
interrupt_before: Interrupt the graph before these nodes.
interrupt_after: Interrupt the graph after these nodes.
subgraphs: Stream from subgraphs.
headers: Additional headers to pass to the request.
**kwargs: Additional params to pass to client.runs.stream.
Yields:
@@ -672,6 +678,9 @@ class RemoteGraph(PregelProtocol):
interrupt_after=interrupt_after,
stream_subgraphs=subgraphs or stream is not None,
if_not_exists="create",
headers=_merge_tracing_headers(headers)
if self.distributed_tracing
else headers,
**kwargs,
):
# split mode and ns
@@ -731,6 +740,7 @@ class RemoteGraph(PregelProtocol):
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: bool = False,
headers: dict[str, str] | None = None,
**kwargs: Any,
) -> AsyncIterator[dict[str, Any] | Any]:
"""Create a run and stream the results.
@@ -746,6 +756,7 @@ class RemoteGraph(PregelProtocol):
interrupt_before: Interrupt the graph before these nodes.
interrupt_after: Interrupt the graph after these nodes.
subgraphs: Stream from subgraphs.
headers: Additional headers to pass to the request.
**kwargs: Additional params to pass to client.runs.stream.
Yields:
@@ -774,6 +785,9 @@ class RemoteGraph(PregelProtocol):
interrupt_after=interrupt_after,
stream_subgraphs=subgraphs or stream is not None,
if_not_exists="create",
headers=_merge_tracing_headers(headers)
if self.distributed_tracing
else headers,
**kwargs,
):
# split mode and ns
@@ -847,6 +861,7 @@ class RemoteGraph(PregelProtocol):
*,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
headers: dict[str, str] | None = None,
**kwargs: Any,
) -> dict[str, Any] | Any:
"""Create a run, wait until it finishes and return the final state.
@@ -856,6 +871,7 @@ class RemoteGraph(PregelProtocol):
config: A `RunnableConfig` for graph invocation.
interrupt_before: Interrupt the graph before these nodes.
interrupt_after: Interrupt the graph after these nodes.
headers: Additional headers to pass to the request.
**kwargs: Additional params to pass to RemoteGraph.stream.
Returns:
@@ -866,6 +882,7 @@ class RemoteGraph(PregelProtocol):
config=config,
interrupt_before=interrupt_before,
interrupt_after=interrupt_after,
headers=headers,
stream_mode="values",
**kwargs,
):
@@ -882,6 +899,7 @@ class RemoteGraph(PregelProtocol):
*,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
headers: dict[str, str] | None = None,
**kwargs: Any,
) -> dict[str, Any] | Any:
"""Create a run, wait until it finishes and return the final state.
@@ -891,6 +909,7 @@ class RemoteGraph(PregelProtocol):
config: A `RunnableConfig` for graph invocation.
interrupt_before: Interrupt the graph before these nodes.
interrupt_after: Interrupt the graph after these nodes.
headers: Additional headers to pass to the request.
**kwargs: Additional params to pass to RemoteGraph.astream.
Returns:
@@ -901,6 +920,7 @@ class RemoteGraph(PregelProtocol):
config=config,
interrupt_before=interrupt_before,
interrupt_after=interrupt_after,
headers=headers,
stream_mode="values",
**kwargs,
):
@@ -909,3 +929,17 @@ class RemoteGraph(PregelProtocol):
return chunk
except UnboundLocalError:
return None
def _merge_tracing_headers(headers: dict[str, str] | None) -> dict[str, str] | None:
if rt := ls.get_current_run_tree():
tracing_headers = rt.to_headers()
baggage = tracing_headers.pop("baggage")
if headers:
if "baggage" in headers:
baggage = headers["baggage"] + "," + baggage
tracing_headers["baggage"] = baggage
headers.update(tracing_headers)
else:
headers = tracing_headers
return headers
+64 -3
View File
@@ -11,6 +11,8 @@ from langgraph.store.base import BaseStore
from langgraph.types import _DC_KWARGS, StreamWriter
from langgraph.typing import ContextT
__all__ = ("Runtime", "get_runtime")
def _no_op_stream_writer(_: Any) -> None: ...
@@ -24,9 +26,61 @@ class _RuntimeOverrides(TypedDict, Generic[ContextT], total=False):
@dataclass(**_DC_KWARGS)
class Runtime(Generic[ContextT]):
"""Convenience class that bundles run-scoped context and graph configuration.
"""Convenience class that bundles run-scoped context and other runtime utilities.
!!! version-added "Added in version 1.0.0."
!!! version-added "Added in version v0.6.0"
Example:
```python
from typing import TypedDict
from langgraph.graph import StateGraph
from dataclasses import dataclass
from langgraph.runtime import Runtime
from langgraph.store.memory import InMemoryStore
@dataclass
class Context: # (1)!
user_id: str
class State(TypedDict, total=False):
response: str
store = InMemoryStore() # (2)!
store.put(("users",), "user_123", {"name": "Alice"})
def personalized_greeting(state: State, runtime: Runtime[Context]) -> State:
'''Generate personalized greeting using runtime context and store.'''
user_id = runtime.context.user_id # (3)!
name = "unknown_user"
if runtime.store:
if memory := runtime.store.get(("users",), user_id):
name = memory.value["name"]
response = f"Hello {name}! Nice to see you again."
return {"response": response}
graph = (
StateGraph(state_schema=State, context_schema=Context)
.add_node("personalized_greeting", personalized_greeting)
.set_entry_point("personalized_greeting")
.set_finish_point("personalized_greeting")
.compile(store=store)
)
result = graph.invoke({}, context=Context(user_id="user_123"))
print(result)
# > {'response': 'Hello Alice! Nice to see you again.'}
```
1. Define a schema for the runtime context.
2. Create a store to persist memories and other information.
3. Use the runtime context to access the user_id.
"""
context: ContextT = field(default=None) # type: ignore[assignment]
@@ -76,7 +130,14 @@ DEFAULT_RUNTIME = Runtime(
def get_runtime(context_schema: type[ContextT] | None = None) -> Runtime[ContextT]:
"""Get the runtime for the current graph run."""
"""Get the runtime for the current graph run.
Args:
context_schema: Optional schema used for type hinting the return type of the runtime.
Returns:
The runtime for the current graph run.
"""
# TODO: in an ideal world, we would have a context manager for
# the runtime that's independent of the config. this will follow
+15
View File
@@ -149,10 +149,25 @@ class Interrupt:
"""Information about an interrupt that occurred in a node.
!!! version-added "Added in version 0.2.24."
!!! version-changed "Changed in version v0.4.0"
* `interrupt_id` was introduced as a property
!!! version-changed "Changed in version v0.6.0"
The following attributes have been removed:
* `ns`
* `when`
* `resumable`
* `interrupt_id`, deprecated in favor of `id`
"""
value: Any
"""The value associated with the interrupt."""
id: str
"""The ID of the interrupt. Can be used to resume the interrupt directly."""
def __init__(
self,
@@ -0,0 +1 @@
"""Legacy utilities module, to be removed in v1."""
+4
View File
@@ -0,0 +1,4 @@
"""Backwards compat imports for config utilities, to be removed in v1."""
from langgraph._internal._config import ensure_config, patch_configurable # noqa: F401
from langgraph.config import get_config, get_store # noqa: F401
@@ -0,0 +1,3 @@
"""Backwards compat imports for runnable utilities, to be removed in v1."""
from langgraph._internal._runnable import RunnableCallable, RunnableLike # noqa: F401
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph"
version = "0.6.0a1"
version = "0.6.1"
description = "Building stateful, multi-actor applications with LLMs"
authors = []
requires-python = ">=3.9"
@@ -15,7 +15,7 @@ dependencies = [
"langchain-core>=0.1",
"langgraph-checkpoint>=2.1.0,<3.0.0",
"langgraph-sdk>=0.2.0,<0.3.0",
"langgraph-prebuilt>=0.5.0,<0.6.0",
"langgraph-prebuilt>=0.6.0,<0.7.0",
"xxhash>=3.5.0",
"pydantic>=2.7.4",
]
+79 -12
View File
@@ -3,6 +3,7 @@ import sys
from typing import Annotated, Union
from unittest.mock import AsyncMock, MagicMock
import langsmith as ls
import pytest
from langchain_core.messages import AnyMessage, BaseMessage
from langchain_core.runnables import RunnableConfig
@@ -899,21 +900,20 @@ async def test_langgraph_cloud_integration():
}
# test invoke
response = app.invoke(
app.invoke(
input,
config={"configurable": {"thread_id": "39a6104a-34e7-4f83-929c-d9eb163003c9"}},
interrupt_before=["agent"],
)
print("response:", response["messages"][-1].content)
# test stream
async for chunk in app.astream(
async for _ in app.astream(
input,
config={"configurable": {"thread_id": "2dc3e3e7-39ac-4597-aa57-4404b944e82a"}},
subgraphs=True,
stream_mode=["debug", "messages"],
):
print("chunk:", chunk)
pass
# test stream events
async for chunk in remote_pregel.astream_events(
@@ -923,17 +923,16 @@ async def test_langgraph_cloud_integration():
subgraphs=True,
stream_mode=[],
):
print("chunk:", chunk)
pass
# test get state
state_snapshot = await remote_pregel.aget_state(
await remote_pregel.aget_state(
config={"configurable": {"thread_id": "2dc3e3e7-39ac-4597-aa57-4404b944e82a"}},
subgraphs=True,
)
print("state snapshot:", state_snapshot)
# test update state
response = await remote_pregel.aupdate_state(
await remote_pregel.aupdate_state(
config={"configurable": {"thread_id": "6645e002-ed50-4022-92a3-d0d186fdf812"}},
values={
"messages": [
@@ -944,18 +943,16 @@ async def test_langgraph_cloud_integration():
]
},
)
print("response:", response)
# test get history
async for state in remote_pregel.aget_state_history(
config={"configurable": {"thread_id": "2dc3e3e7-39ac-4597-aa57-4404b944e82a"}},
):
print("state snapshot:", state)
pass
# test get graph
remote_pregel.graph_id = "fe096781-5601-53d2-b2f6-0d3403f7e9ca" # must be UUID
graph = await remote_pregel.aget_graph(xray=True)
print("graph:", graph)
await remote_pregel.aget_graph(xray=True)
def test_sanitize_config():
@@ -1181,3 +1178,73 @@ async def test_remote_graph_stream_messages_tuple(
assert coerced_events == coerced_inmem_events
# TODO: Fix the namespace matching in the next api release.
# assert namespaces == inmem_namespaces
@pytest.mark.anyio
@pytest.mark.parametrize("distributed_tracing", [False, True])
@pytest.mark.parametrize("stream", [False, True])
async def test_include_headers(distributed_tracing: bool, stream: bool):
mock_async_client = MagicMock()
async_iter = MagicMock()
return_value = [
StreamPart(event="values", data={"chunk": "data1"}),
]
async_iter.__aiter__.return_value = return_value
astream_mock = mock_async_client.runs.stream
astream_mock.return_value = async_iter
mock_sync_client = MagicMock()
sync_iter = MagicMock()
sync_iter.__iter__.return_value = return_value
stream_mock = mock_sync_client.runs.stream
stream_mock.return_value = async_iter
remote_pregel = RemoteGraph(
"test_graph_id",
client=mock_async_client,
sync_client=mock_sync_client,
distributed_tracing=distributed_tracing,
)
config = {"configurable": {"thread_id": "thread_1"}}
with ls.tracing_context(enabled=True, client=MagicMock()):
with ls.trace("foo"):
if stream:
async for _ in remote_pregel.astream(
{"input": {"messages": [{"type": "human", "content": "hello"}]}},
config,
headers={"foo": "bar"},
):
pass
else:
await remote_pregel.ainvoke(
{"input": {"messages": [{"type": "human", "content": "hello"}]}},
config,
headers={"foo": "bar"},
)
expected = {"foo": "bar"}
if distributed_tracing:
expected["langsmith-trace"] = AnyStr()
expected["baggage"] = AnyStr()
assert astream_mock.call_args.kwargs["headers"] == expected
stream_mock.assert_not_called()
with ls.tracing_context(enabled=True, client=MagicMock()):
with ls.trace("foo"):
if stream:
for _ in remote_pregel.stream(
{"input": {"messages": [{"type": "human", "content": "hello"}]}},
config,
headers={"foo": "bar"},
):
pass
else:
remote_pregel.invoke(
{"input": {"messages": [{"type": "human", "content": "hello"}]}},
config,
headers={"foo": "bar"},
)
assert stream_mock.call_args.kwargs["headers"] == expected
+24
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from typing import Any, Optional
import pytest
from langchain_core.runnables.config import RunnableConfig
from langgraph._internal._runnable import RunnableCallable
from langgraph.runtime import Runtime
@@ -370,3 +371,26 @@ async def test_runnable_callable_injectable_arguments_async() -> None:
)
== "success"
)
def test_config_injection() -> None:
def func(x: Any, config: RunnableConfig) -> list[str]:
return config.get("tags", [])
assert RunnableCallable(func).invoke(
"test", config={"tags": ["test"], "configurable": {}}
) == ["test"]
def func_optional(x: Any, config: Optional[RunnableConfig]) -> list[str]: # noqa: UP045
return config.get("tags", []) if config else []
assert RunnableCallable(func_optional).invoke(
"test", config={"tags": ["test"], "configurable": {}}
) == ["test"]
def func_untyped(x: Any, config) -> list[str]:
return config.get("tags", [])
assert RunnableCallable(func_untyped).invoke(
"test", config={"tags": ["test"], "configurable": {}}
) == ["test"]
+70 -9
View File
@@ -7,16 +7,14 @@ from langgraph.graph import END, START, StateGraph
from langgraph.runtime import Runtime, get_runtime
@dataclass
class Context:
api_key: str
class State(TypedDict):
message: str
def test_injected_runtime() -> None:
@dataclass
class Context:
api_key: str
class State(TypedDict):
message: str
def injected_runtime(state: State, runtime: Runtime[Context]) -> dict[str, Any]:
return {"message": f"api key: {runtime.context.api_key}"}
@@ -32,6 +30,13 @@ def test_injected_runtime() -> None:
def test_context_runtime() -> None:
@dataclass
class Context:
api_key: str
class State(TypedDict):
message: str
def context_runtime(state: State) -> dict[str, Any]:
runtime = get_runtime(Context)
return {"message": f"api key: {runtime.context.api_key}"}
@@ -45,3 +50,59 @@ def test_context_runtime() -> None:
{"message": "hello world"}, context=Context(api_key="sk_123456")
)
assert result == {"message": "api key: sk_123456"}
def test_override_runtime() -> None:
@dataclass
class Context:
api_key: str
prev = Runtime(context=Context(api_key="abc"))
new = prev.override(context=Context(api_key="def"))
assert new.override(context=Context(api_key="def")).context.api_key == "def"
def test_merge_runtime() -> None:
@dataclass
class Context:
api_key: str
runtime1 = Runtime(context=Context(api_key="abc"))
runtime2 = Runtime(context=Context(api_key="def"))
runtime3 = Runtime(context=None)
assert runtime1.merge(runtime2).context.api_key == "def"
# override only applies to non-falsy values
assert runtime1.merge(runtime3).context.api_key == "abc" # type: ignore
def test_runtime_propogated_to_subgraph() -> None:
@dataclass
class Context:
username: str
class State(TypedDict, total=False):
subgraph: str
main: str
def subgraph_node_1(state: State, runtime: Runtime[Context]):
return {"subgraph": f"{runtime.context.username}!"}
subgraph_builder = StateGraph(State, context_schema=Context)
subgraph_builder.add_node(subgraph_node_1)
subgraph_builder.set_entry_point("subgraph_node_1")
subgraph = subgraph_builder.compile()
def main_node(state: State, runtime: Runtime[Context]):
return {"main": f"{runtime.context.username}!"}
builder = StateGraph(State, context_schema=Context)
builder.add_node(main_node)
builder.add_node("node_1", subgraph)
builder.set_entry_point("main_node")
builder.add_edge("main_node", "node_1")
graph = builder.compile()
context = Context(username="Alice")
result = graph.invoke({}, context=context)
assert result == {"subgraph": "Alice!", "main": "Alice!"}
View File
-2
View File
@@ -1,2 +0,0 @@
# import for backwards compatibility
from langgraph._internal._runnable import RunnableCallable, RunnableSeq # noqa: F401
+5 -5
View File
@@ -1192,7 +1192,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "0.6.0a1"
version = "0.6.1"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
@@ -1364,7 +1364,7 @@ dev = [
[[package]]
name = "langgraph-checkpoint-sqlite"
version = "2.0.10"
version = "2.0.11"
source = { editable = "../checkpoint-sqlite" }
dependencies = [
{ name = "aiosqlite" },
@@ -1394,7 +1394,7 @@ dev = [
[[package]]
name = "langgraph-cli"
version = "0.3.5"
version = "0.3.6"
source = { editable = "../cli" }
dependencies = [
{ name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
@@ -1433,7 +1433,7 @@ dev = [
[[package]]
name = "langgraph-prebuilt"
version = "0.5.2"
version = "0.6.1"
source = { editable = "../prebuilt" }
dependencies = [
{ name = "langchain-core" },
@@ -1481,7 +1481,7 @@ wheels = [
[[package]]
name = "langgraph-sdk"
version = "0.2.0a1"
version = "0.2.0"
source = { editable = "../sdk-py" }
dependencies = [
{ name = "httpx" },
@@ -1,6 +1,7 @@
import inspect
from typing import (
Any,
Awaitable,
Callable,
Literal,
Optional,
@@ -44,8 +45,10 @@ from langgraph.graph.state import CompiledStateGraph
from langgraph.managed import IsLastStep, RemainingSteps
from langgraph.prebuilt._internal import ToolCallWithContext
from langgraph.prebuilt.tool_node import ToolNode
from langgraph.runtime import Runtime
from langgraph.store.base import BaseStore
from langgraph.types import Checkpointer, Send
from langgraph.typing import ContextT
from langgraph.warnings import LangGraphDeprecatedSinceV10
StructuredResponse = Union[dict, BaseModel]
@@ -246,7 +249,12 @@ def _validate_chat_history(
def create_react_agent(
model: Union[str, LanguageModelLike],
model: Union[
str,
LanguageModelLike,
Callable[[StateSchema, Runtime[ContextT]], BaseChatModel],
Callable[[StateSchema, Runtime[ContextT]], Awaitable[BaseChatModel]],
],
tools: Union[Sequence[Union[BaseTool, Callable, dict[str, Any]]], ToolNode],
*,
prompt: Optional[Prompt] = None,
@@ -271,7 +279,43 @@ def create_react_agent(
For more details on using `create_react_agent`, visit [Agents](https://langchain-ai.github.io/langgraph/agents/overview/) documentation.
Args:
model: The `LangChain` chat model that supports tool calling.
model: The language model for the agent. Supports static and dynamic
model selection.
- **Static model**: A chat model instance (e.g., `ChatOpenAI()`) or
string identifier (e.g., `"openai:gpt-4"`)
- **Dynamic model**: A callable with signature
`(state, runtime) -> BaseChatModel` that returns different models
based on runtime context
Dynamic functions receive graph state and runtime, enabling
context-dependent model selection. Must return a `BaseChatModel`
instance. For tool calling, bind tools using `.bind_tools()`.
Bound tools must be a subset of the `tools` parameter.
Dynamic model example:
```python
from dataclasses import dataclass
@dataclass
class ModelContext:
model_name: str = "gpt-3.5-turbo"
# Instantiate models globally
gpt4_model = ChatOpenAI(model="gpt-4")
gpt35_model = ChatOpenAI(model="gpt-3.5-turbo")
def select_model(state: AgentState, runtime: Runtime[ModelContext]) -> ChatOpenAI:
model_name = runtime.context.model_name
model = gpt4_model if model_name == "gpt-4" else gpt35_model
return model.bind_tools(tools)
```
!!! note "Dynamic Model Requirements"
Ensure returned models have appropriate tools bound via
`.bind_tools()` and support required functionality. Bound tools
must be a subset of those specified in the `tools` parameter.
tools: A list of tools or a ToolNode instance.
If an empty list is provided, the agent will consist of a single LLM node without tool calling.
prompt: An optional prompt for the LLM. Can take a few different forms:
@@ -364,6 +408,11 @@ def create_react_agent(
This name will be automatically used when adding ReAct agent graph to another graph as a subgraph node -
particularly useful for building multi-agent systems.
!!! warning "`config_schema` Deprecated"
The `config_schema` parameter is deprecated in v0.6.0 and support will be removed in v2.0.0.
Please use `context_schema` instead to specify the schema for run-scoped context.
Returns:
A compiled LangChain runnable that can be used for chat interactions.
@@ -447,32 +496,63 @@ def create_react_agent(
tool_node = ToolNode([t for t in tools if not isinstance(t, dict)])
tool_classes = list(tool_node.tools_by_name.values())
if isinstance(model, str):
try:
from langchain.chat_models import ( # type: ignore[import-not-found]
init_chat_model,
)
except ImportError:
raise ImportError(
"Please install langchain (`pip install langchain`) to use '<provider>:<model>' string syntax for `model` parameter."
)
model = cast(BaseChatModel, init_chat_model(model))
is_dynamic_model = not isinstance(model, (str, Runnable)) and callable(model)
is_async_dynamic_model = is_dynamic_model and inspect.iscoroutinefunction(model)
tool_calling_enabled = len(tool_classes) > 0
if (
_should_bind_tools(model, tool_classes, num_builtin=len(llm_builtin_tools))
and len(tool_classes + llm_builtin_tools) > 0
):
model = cast(BaseChatModel, model).bind_tools(tool_classes + llm_builtin_tools) # type: ignore[operator]
if not is_dynamic_model:
if isinstance(model, str):
try:
from langchain.chat_models import ( # type: ignore[import-not-found]
init_chat_model,
)
except ImportError:
raise ImportError(
"Please install langchain (`pip install langchain`) to "
"use '<provider>:<model>' string syntax for `model` parameter."
)
model_runnable = _get_prompt_runnable(prompt) | model
model = cast(BaseChatModel, init_chat_model(model))
if (
_should_bind_tools(model, tool_classes, num_builtin=len(llm_builtin_tools)) # type: ignore[arg-type]
and len(tool_classes + llm_builtin_tools) > 0
):
model = cast(BaseChatModel, model).bind_tools(
tool_classes + llm_builtin_tools # type: ignore[operator]
)
static_model: Optional[Runnable] = _get_prompt_runnable(prompt) | model # type: ignore[operator]
else:
# For dynamic models, we'll create the runnable at runtime
static_model = None
# If any of the tools are configured to return_directly after running,
# our graph needs to check if these were called
should_return_direct = {t.name for t in tool_classes if t.return_direct}
def _resolve_model(
state: StateSchema, runtime: Runtime[ContextT]
) -> LanguageModelLike:
"""Resolve the model to use, handling both static and dynamic models."""
if is_dynamic_model:
return _get_prompt_runnable(prompt) | model(state, runtime) # type: ignore[operator]
else:
return static_model
async def _aresolve_model(
state: StateSchema, runtime: Runtime[ContextT]
) -> LanguageModelLike:
"""Async resolve the model to use, handling both static and dynamic models."""
if is_async_dynamic_model:
resolved_model = await model(state, runtime) # type: ignore[misc,operator]
return _get_prompt_runnable(prompt) | resolved_model
elif is_dynamic_model:
return _get_prompt_runnable(prompt) | model(state, runtime) # type: ignore[operator]
else:
return static_model
def _are_more_steps_needed(state: StateSchema, response: BaseMessage) -> bool:
has_tool_calls = isinstance(response, AIMessage) and response.tool_calls
all_tools_return_direct = (
@@ -517,9 +597,26 @@ def create_react_agent(
return state
# Define the function that calls the model
def call_model(state: StateSchema, config: RunnableConfig) -> StateSchema:
state = _get_model_input_state(state)
response = cast(AIMessage, model_runnable.invoke(state, config))
def call_model(
state: StateSchema, runtime: Runtime[ContextT], config: RunnableConfig
) -> StateSchema:
if is_async_dynamic_model:
msg = (
"Async model callable provided but agent invoked synchronously. "
"Use agent.ainvoke() or agent.astream(), or "
"provide a sync model callable."
)
raise RuntimeError(msg)
model_input = _get_model_input_state(state)
if is_dynamic_model:
# Resolve dynamic model at runtime and apply prompt
dynamic_model = _resolve_model(state, runtime)
response = cast(AIMessage, dynamic_model.invoke(model_input, config)) # type: ignore[arg-type]
else:
response = cast(AIMessage, static_model.invoke(model_input, config)) # type: ignore[union-attr]
# add agent name to the AIMessage
response.name = name
@@ -535,9 +632,19 @@ def create_react_agent(
# We return a list, because this will get added to the existing list
return {"messages": [response]}
async def acall_model(state: StateSchema, config: RunnableConfig) -> StateSchema:
state = _get_model_input_state(state)
response = cast(AIMessage, await model_runnable.ainvoke(state, config))
async def acall_model(
state: StateSchema, runtime: Runtime[ContextT], config: RunnableConfig
) -> StateSchema:
model_input = _get_model_input_state(state)
if is_dynamic_model:
# Resolve dynamic model at runtime and apply prompt
# (supports both sync and async)
dynamic_model = await _aresolve_model(state, runtime)
response = cast(AIMessage, await dynamic_model.ainvoke(model_input, config)) # type: ignore[arg-type]
else:
response = cast(AIMessage, await static_model.ainvoke(model_input, config)) # type: ignore[union-attr]
# add agent name to the AIMessage
response.name = name
if _are_more_steps_needed(state, response):
@@ -574,22 +681,32 @@ def create_react_agent(
input_schema = state_schema
def generate_structured_response(
state: StateSchema, config: RunnableConfig
state: StateSchema, runtime: Runtime[ContextT], config: RunnableConfig
) -> StateSchema:
if is_async_dynamic_model:
msg = (
"Async model callable provided but agent invoked synchronously. "
"Use agent.ainvoke() or agent.astream(), or provide a sync model callable."
)
raise RuntimeError(msg)
messages = _get_state_value(state, "messages")
structured_response_schema = response_format
if isinstance(response_format, tuple):
system_prompt, structured_response_schema = response_format
messages = [SystemMessage(content=system_prompt)] + list(messages)
model_with_structured_output = _get_model(model).with_structured_output(
resolved_model = _resolve_model(state, runtime)
model_with_structured_output = _get_model(
resolved_model
).with_structured_output(
cast(StructuredResponseSchema, structured_response_schema)
)
response = model_with_structured_output.invoke(messages, config)
return {"structured_response": response}
async def agenerate_structured_response(
state: StateSchema, config: RunnableConfig
state: StateSchema, runtime: Runtime[ContextT], config: RunnableConfig
) -> StateSchema:
messages = _get_state_value(state, "messages")
structured_response_schema = response_format
@@ -597,7 +714,10 @@ def create_react_agent(
system_prompt, structured_response_schema = response_format
messages = [SystemMessage(content=system_prompt)] + list(messages)
model_with_structured_output = _get_model(model).with_structured_output(
resolved_model = await _aresolve_model(state, runtime)
model_with_structured_output = _get_model(
resolved_model
).with_structured_output(
cast(StructuredResponseSchema, structured_response_schema)
)
response = await model_with_structured_output.ainvoke(messages, config)
@@ -52,6 +52,7 @@ from typing import (
from langchain_core.messages import (
AIMessage,
AnyMessage,
RemoveMessage,
ToolCall,
ToolMessage,
convert_to_messages,
@@ -72,6 +73,7 @@ from typing_extensions import Annotated, get_args, get_origin
from langgraph._internal._runnable import RunnableCallable
from langgraph.errors import GraphBubbleUp
from langgraph.graph.message import REMOVE_ALL_MESSAGES
from langgraph.prebuilt._internal import ToolCallWithContext
from langgraph.store.base import BaseStore
from langgraph.types import Command, Send
@@ -754,6 +756,11 @@ class ToolNode(RunnableCallable):
# convert to message objects if updates are in a dict format
messages_update = convert_to_messages(messages_update)
# no validation needed if all messages are being removed
if messages_update == [RemoveMessage(id=REMOVE_ALL_MESSAGES)]:
return updated_command
has_matching_tool_message = False
for message in messages_update:
if not isinstance(message, ToolMessage):
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph-prebuilt"
version = "0.5.2"
version = "0.6.1"
description = "Library with high-level APIs for creating and executing LangGraph agents and tools."
authors = []
requires-python = ">=3.9"
+374 -1
View File
@@ -13,10 +13,12 @@ from typing import (
)
import pytest
from langchain_core.language_models import BaseChatModel
from langchain_core.messages import (
AIMessage,
AnyMessage,
HumanMessage,
MessageLikeRepresentation,
RemoveMessage,
SystemMessage,
ToolCall,
@@ -52,6 +54,7 @@ from langgraph.prebuilt.tool_node import (
_get_state_args,
_infer_handled_types,
)
from langgraph.runtime import Runtime
from langgraph.store.base import BaseStore
from langgraph.store.memory import InMemoryStore
from langgraph.types import Command, Interrupt, interrupt
@@ -1092,7 +1095,7 @@ def test_inspect_react() -> None:
@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS)
def test_react_with_subgraph_tools(
sync_checkpointer: BaseCheckpointSaver, version: str
sync_checkpointer: BaseCheckpointSaver, version: Literal["v1", "v2"]
) -> None:
class State(TypedDict):
a: int
@@ -1367,6 +1370,376 @@ def test_get_model() -> None:
_get_model(RunnableLambda(lambda message: message))
@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS)
def test_dynamic_model_basic(version: str) -> None:
"""Test basic dynamic model functionality."""
def dynamic_model(state, runtime: Runtime):
# Return different models based on state
if "urgent" in state["messages"][-1].content:
return FakeToolCallingModel(tool_calls=[])
else:
return FakeToolCallingModel(tool_calls=[])
agent = create_react_agent(dynamic_model, [], version=version)
result = agent.invoke({"messages": [HumanMessage("hello")]})
assert len(result["messages"]) == 2
assert result["messages"][-1].content == "hello"
result = agent.invoke({"messages": [HumanMessage("urgent help")]})
assert len(result["messages"]) == 2
assert result["messages"][-1].content == "urgent help"
@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS)
def test_dynamic_model_with_tools(version: Literal["v1", "v2"]) -> None:
"""Test dynamic model with tool calling."""
@dec_tool
def basic_tool(x: int) -> str:
"""Basic tool."""
return f"basic: {x}"
@dec_tool
def advanced_tool(x: int) -> str:
"""Advanced tool."""
return f"advanced: {x}"
def dynamic_model(state: dict, runtime: Runtime) -> BaseChatModel:
# Return model with different behaviors based on message content
if "advanced" in state["messages"][-1].content:
return FakeToolCallingModel(
tool_calls=[
[{"args": {"x": 1}, "id": "1", "name": "advanced_tool"}],
[],
]
)
else:
return FakeToolCallingModel(
tool_calls=[[{"args": {"x": 1}, "id": "1", "name": "basic_tool"}], []]
)
agent = create_react_agent(
dynamic_model, [basic_tool, advanced_tool], version=version
)
# Test basic tool usage
result = agent.invoke({"messages": [HumanMessage("basic request")]})
assert len(result["messages"]) == 3
tool_message = result["messages"][-1]
assert tool_message.content == "basic: 1"
assert tool_message.name == "basic_tool"
# Test advanced tool usage
result = agent.invoke({"messages": [HumanMessage("advanced request")]})
assert len(result["messages"]) == 3
tool_message = result["messages"][-1]
assert tool_message.content == "advanced: 1"
assert tool_message.name == "advanced_tool"
@dataclasses.dataclass
class Context:
user_id: str
@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS)
def test_dynamic_model_with_context(version: str) -> None:
"""Test dynamic model using config parameters."""
def dynamic_model(state, runtime: Runtime[Context]):
# Use context to determine model behavior
user_id = runtime.context.user_id
if user_id == "user_premium":
return FakeToolCallingModel(tool_calls=[])
else:
return FakeToolCallingModel(tool_calls=[])
agent = create_react_agent(
dynamic_model, [], context_schema=Context, version=version
)
# Test with basic user
result = agent.invoke(
{"messages": [HumanMessage("hello")]},
context=Context(user_id="user_basic"),
)
assert len(result["messages"]) == 2
# Test with premium user
result = agent.invoke(
{"messages": [HumanMessage("hello")]},
context=Context(user_id="user_premium"),
)
assert len(result["messages"]) == 2
@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS)
def test_dynamic_model_with_state_schema(version: Literal["v1", "v2"]) -> None:
"""Test dynamic model with custom state schema."""
class CustomDynamicState(AgentState):
model_preference: str = "default"
def dynamic_model(state: CustomDynamicState, runtime: Runtime) -> BaseChatModel:
# Use custom state field to determine model
if state.get("model_preference") == "advanced":
return FakeToolCallingModel(tool_calls=[])
else:
return FakeToolCallingModel(tool_calls=[])
agent = create_react_agent(
dynamic_model, [], state_schema=CustomDynamicState, version=version
)
result = agent.invoke(
{"messages": [HumanMessage("hello")], "model_preference": "advanced"}
)
assert len(result["messages"]) == 2
assert result["model_preference"] == "advanced"
@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS)
def test_dynamic_model_with_prompt(version: Literal["v1", "v2"]) -> None:
"""Test dynamic model with different prompt types."""
def dynamic_model(state: AgentState, runtime: Runtime) -> BaseChatModel:
return FakeToolCallingModel(tool_calls=[])
# Test with string prompt
agent = create_react_agent(dynamic_model, [], prompt="system_msg", version=version)
result = agent.invoke({"messages": [HumanMessage("human_msg")]})
assert result["messages"][-1].content == "system_msg-human_msg"
# Test with callable prompt
def dynamic_prompt(state: AgentState) -> list[MessageLikeRepresentation]:
"""Generate a dynamic system message based on state."""
return [{"role": "system", "content": "system_msg"}] + list(state["messages"])
agent = create_react_agent(
dynamic_model, [], prompt=dynamic_prompt, version=version
)
result = agent.invoke({"messages": [HumanMessage("human_msg")]})
assert result["messages"][-1].content == "system_msg-human_msg"
async def test_dynamic_model_async() -> None:
"""Test dynamic model with async operations."""
def dynamic_model(state: AgentState, runtime: Runtime) -> BaseChatModel:
return FakeToolCallingModel(tool_calls=[])
agent = create_react_agent(dynamic_model, [])
result = await agent.ainvoke({"messages": [HumanMessage("hello async")]})
assert len(result["messages"]) == 2
assert result["messages"][-1].content == "hello async"
@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS)
def test_dynamic_model_with_structured_response(version: str) -> None:
"""Test dynamic model with structured response format."""
class TestResponse(BaseModel):
message: str
confidence: float
def dynamic_model(state, runtime: Runtime):
expected_response = TestResponse(message="dynamic response", confidence=0.9)
return FakeToolCallingModel(
tool_calls=[], structured_response=expected_response
)
agent = create_react_agent(
dynamic_model, [], response_format=TestResponse, version=version
)
result = agent.invoke({"messages": [HumanMessage("hello")]})
assert "structured_response" in result
assert result["structured_response"].message == "dynamic response"
assert result["structured_response"].confidence == 0.9
def test_dynamic_model_with_checkpointer(sync_checkpointer):
"""Test dynamic model with checkpointer."""
call_count = 0
def dynamic_model(state: AgentState, runtime: Runtime) -> BaseChatModel:
nonlocal call_count
call_count += 1
return FakeToolCallingModel(
tool_calls=[],
# Incrementing the call count as it is used to assign an id
# to the AIMessage.
# The default reducer semantics are to overwrite an existing message
# with the new one if the id matches.
index=call_count,
)
agent = create_react_agent(dynamic_model, [], checkpointer=sync_checkpointer)
config = {"configurable": {"thread_id": "test_dynamic"}}
# First call
result1 = agent.invoke({"messages": [HumanMessage("hello")]}, config)
assert len(result1["messages"]) == 2 # Human + AI message
# Second call - should load from checkpoint
result2 = agent.invoke({"messages": [HumanMessage("world")]}, config)
assert len(result2["messages"]) == 4
# Dynamic model should be called each time
assert call_count >= 2
@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS)
def test_dynamic_model_state_dependent_tools(version: Literal["v1", "v2"]) -> None:
"""Test dynamic model that changes available tools based on state."""
@dec_tool
def tool_a(x: int) -> str:
"""Tool A."""
return f"A: {x}"
@dec_tool
def tool_b(x: int) -> str:
"""Tool B."""
return f"B: {x}"
def dynamic_model(state, runtime: Runtime):
# Switch tools based on message history
if any("use_b" in msg.content for msg in state["messages"]):
return FakeToolCallingModel(
tool_calls=[[{"args": {"x": 2}, "id": "1", "name": "tool_b"}], []]
)
else:
return FakeToolCallingModel(
tool_calls=[[{"args": {"x": 1}, "id": "1", "name": "tool_a"}], []]
)
agent = create_react_agent(dynamic_model, [tool_a, tool_b], version=version)
# Ask to use tool B
result = agent.invoke({"messages": [HumanMessage("use_b please")]})
last_message = result["messages"][-1]
assert isinstance(last_message, ToolMessage)
assert last_message.content == "B: 2"
# Ask to use tool A
result = agent.invoke({"messages": [HumanMessage("hello")]})
last_message = result["messages"][-1]
assert isinstance(last_message, ToolMessage)
assert last_message.content == "A: 1"
@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS)
def test_dynamic_model_error_handling(version: Literal["v1", "v2"]) -> None:
"""Test error handling in dynamic model."""
def failing_dynamic_model(state, runtime: Runtime):
if "fail" in state["messages"][-1].content:
raise ValueError("Dynamic model failed")
return FakeToolCallingModel(tool_calls=[])
agent = create_react_agent(failing_dynamic_model, [], version=version)
# Normal operation should work
result = agent.invoke({"messages": [HumanMessage("hello")]})
assert len(result["messages"]) == 2
# Should propagate the error
with pytest.raises(ValueError, match="Dynamic model failed"):
agent.invoke({"messages": [HumanMessage("fail now")]})
def test_dynamic_model_vs_static_model_behavior():
"""Test that dynamic and static models produce equivalent results when configured the same."""
# Static model
static_model = FakeToolCallingModel(tool_calls=[])
static_agent = create_react_agent(static_model, [])
# Dynamic model returning the same model
def dynamic_model(state, runtime: Runtime):
return FakeToolCallingModel(tool_calls=[])
dynamic_agent = create_react_agent(dynamic_model, [])
input_msg = {"messages": [HumanMessage("test message")]}
static_result = static_agent.invoke(input_msg)
dynamic_result = dynamic_agent.invoke(input_msg)
# Results should be equivalent (content-wise, IDs may differ)
assert len(static_result["messages"]) == len(dynamic_result["messages"])
assert static_result["messages"][0].content == dynamic_result["messages"][0].content
assert static_result["messages"][1].content == dynamic_result["messages"][1].content
def test_dynamic_model_receives_correct_state():
"""Test that the dynamic model function receives the correct state, not the model input."""
received_states = []
class CustomAgentState(AgentState):
custom_field: str
def dynamic_model(state, runtime: Runtime) -> BaseChatModel:
# Capture the state that's passed to the dynamic model function
received_states.append(state)
return FakeToolCallingModel(tool_calls=[])
agent = create_react_agent(dynamic_model, [], state_schema=CustomAgentState)
# Test with initial state
input_state = {"messages": [HumanMessage("hello")], "custom_field": "test_value"}
agent.invoke(input_state)
# The dynamic model function should receive the original state, not the processed model input
assert len(received_states) == 1
received_state = received_states[0]
# Should have the custom field from original state
assert "custom_field" in received_state
assert received_state["custom_field"] == "test_value"
# Should have the original messages
assert len(received_state["messages"]) == 1
assert received_state["messages"][0].content == "hello"
async def test_dynamic_model_receives_correct_state_async():
"""Test that the async dynamic model function receives the correct state, not the model input."""
received_states = []
class CustomAgentStateAsync(AgentState):
custom_field: str
def dynamic_model(state, runtime: Runtime):
# Capture the state that's passed to the dynamic model function
received_states.append(state)
return FakeToolCallingModel(tool_calls=[])
agent = create_react_agent(dynamic_model, [], state_schema=CustomAgentStateAsync)
# Test with initial state
input_state = {
"messages": [HumanMessage("hello async")],
"custom_field": "test_value_async",
}
await agent.ainvoke(input_state)
# The dynamic model function should receive the original state, not the processed model input
assert len(received_states) == 1
received_state = received_states[0]
# Should have the custom field from original state
assert "custom_field" in received_state
assert received_state["custom_field"] == "test_value_async"
# Should have the original messages
assert len(received_state["messages"]) == 1
assert received_state["messages"][0].content == "hello async"
def test_pre_model_hook() -> None:
model = FakeToolCallingModel(tool_calls=[])
+27
View File
@@ -7,6 +7,7 @@ from typing import (
import pytest
from langchain_core.messages import (
AIMessage,
RemoveMessage,
ToolMessage,
)
from langchain_core.tools import BaseTool, ToolException
@@ -15,6 +16,7 @@ from pydantic import BaseModel, ValidationError
from pydantic.v1 import ValidationError as ValidationErrorV1
from langgraph.errors import GraphBubbleUp, GraphInterrupt
from langgraph.graph.message import REMOVE_ALL_MESSAGES
from langgraph.prebuilt import ToolNode
from langgraph.prebuilt.tool_node import TOOL_CALL_ERROR_TEMPLATE
from langgraph.types import Command, Send
@@ -1129,3 +1131,28 @@ def test_tool_node_parent_command_with_send():
graph=Command.PARENT,
)
]
async def test_tool_node_command_remove_all_messages():
from langchain_core.tools.base import InjectedToolCallId
@dec_tool
def remove_all_messages_tool(tool_call_id: Annotated[str, InjectedToolCallId]):
"""A tool that removes all messages."""
return Command(update={"messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES)]})
tool_node = ToolNode([remove_all_messages_tool])
tool_call = {
"name": "remove_all_messages_tool",
"args": {},
"id": "tool_call_123",
}
result = await tool_node.ainvoke(
{"messages": [AIMessage(content="", tool_calls=[tool_call])]}
)
assert isinstance(result, list)
assert len(result) == 1
command = result[0]
assert isinstance(command, Command)
assert command.update == {"messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES)]}
+4 -4
View File
@@ -316,7 +316,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "0.6.0a1"
version = "0.6.1"
source = { editable = "../langgraph" }
dependencies = [
{ name = "langchain-core" },
@@ -430,7 +430,7 @@ dev = [
[[package]]
name = "langgraph-checkpoint-sqlite"
version = "2.0.10"
version = "2.0.11"
source = { editable = "../checkpoint-sqlite" }
dependencies = [
{ name = "aiosqlite" },
@@ -460,7 +460,7 @@ dev = [
[[package]]
name = "langgraph-prebuilt"
version = "0.5.2"
version = "0.6.1"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
@@ -507,7 +507,7 @@ dev = [
[[package]]
name = "langgraph-sdk"
version = "0.2.0a1"
version = "0.2.0"
source = { editable = "../sdk-py" }
dependencies = [
{ name = "httpx" },
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph-sdk"
version = "0.2.0a1"
version = "0.2.0"
description = "SDK for interacting with LangGraph API"
authors = []
requires-python = ">=3.9"
+1 -1
View File
@@ -119,7 +119,7 @@ wheels = [
[[package]]
name = "langgraph-sdk"
version = "0.2.0a1"
version = "0.2.0"
source = { editable = "." }
dependencies = [
{ name = "httpx" },