mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-19 06:05:44 +02:00
Compare commits
71
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d5455227b | ||
|
|
1b28530d52 | ||
|
|
416da06d6b | ||
|
|
027bb0a1b8 | ||
|
|
cd30b9cfea | ||
|
|
5eb9826c46 | ||
|
|
fadbe7d710 | ||
|
|
c861614337 | ||
|
|
c020f01425 | ||
|
|
14949c81c8 | ||
|
|
3af857922a | ||
|
|
7ec049e0c7 | ||
|
|
1923ff8d85 | ||
|
|
4b9b7d0b1c | ||
|
|
624247a51f | ||
|
|
b21b595927 | ||
|
|
f4633a0015 | ||
|
|
86017c010c | ||
|
|
2115cffc94 | ||
|
|
03726f9bc6 | ||
|
|
509dfd1f21 | ||
|
|
efca21070d | ||
|
|
dba20d0577 | ||
|
|
5145dac12b | ||
|
|
440c7ff12a | ||
|
|
5eef290c4e | ||
|
|
a8b3746356 | ||
|
|
7541331643 | ||
|
|
76814676c2 | ||
|
|
0804984f9d | ||
|
|
8f11b6a003 | ||
|
|
aa6b122e4c | ||
|
|
23491e5c9a | ||
|
|
1491f30a07 | ||
|
|
f63635d3c8 | ||
|
|
6672032568 | ||
|
|
311ce7b04f | ||
|
|
14b732740e | ||
|
|
370825a48a | ||
|
|
264bae5a7e | ||
|
|
f6aa19709e | ||
|
|
8495f6f95d | ||
|
|
6710908d40 | ||
|
|
d24ad3d980 | ||
|
|
2d8288fd0f | ||
|
|
c1ef10a0ec | ||
|
|
a3d7b6f44e | ||
|
|
bc9d45b476 | ||
|
|
7d3f0089aa | ||
|
|
ed678f4701 | ||
|
|
22411ba0fd | ||
|
|
5b9021ff37 | ||
|
|
aaff464115 | ||
|
|
3a23a256e2 | ||
|
|
f6857395b9 | ||
|
|
cdaa7ba003 | ||
|
|
39745ed794 | ||
|
|
738bb8a343 | ||
|
|
b2dde8d9af | ||
|
|
5312edc830 | ||
|
|
276310e116 | ||
|
|
39977ded8c | ||
|
|
439038fc3a | ||
|
|
2e5445c565 | ||
|
|
d38510ad03 | ||
|
|
951486d107 | ||
|
|
745a1e7a29 | ||
|
|
24731d6a28 | ||
|
|
a4689a5d10 | ||
|
|
0824161984 | ||
|
|
0232201b7b |
@@ -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:
|
||||
|
||||
@@ -39,6 +39,7 @@ jobs:
|
||||
scheduler-kafka
|
||||
sdk-py
|
||||
docs
|
||||
ci
|
||||
requireScope: false
|
||||
ignoreLabels: |
|
||||
ignore-lint-pr-title
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""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 _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
|
||||
) -> str | None:
|
||||
"""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.
|
||||
|
||||
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("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:
|
||||
return f"[{link_name}]({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
|
||||
\[ # 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."""
|
||||
link_name = match.group("link_name")
|
||||
transformed = _transform_link(
|
||||
link_name, current_scope, file_path, line_number
|
||||
)
|
||||
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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
[`add_conditional_edges`][langgraph.graph.StateGraph.add_conditional_edges]
|
||||
[add_conditional_edges][langgraph.graph.StateGraph.add_conditional_edges]
|
||||
[`add_edge`][langgraph.graph.StateGraph.add_edge]
|
||||
[add_edge][langgraph.graph.StateGraph.add_edge]
|
||||
[`add_messages`][langgraph.graph.message.add_messages]
|
||||
[add_node][langgraph.graph.StateGraph.add_node]
|
||||
[API reference][langgraph.prebuilt.tool_node.ToolNode]
|
||||
[API reference][toolnode]
|
||||
[`astream()`][langgraph.graph.state.CompiledStateGraph.astream]
|
||||
[`.astream()`][langgraph.pregel.Pregel.astream]
|
||||
[AsyncPostgresSaver][langgraph.checkpoint.postgres.aio.AsyncPostgresSaver]
|
||||
[AsyncSqliteSaver][langgraph.checkpoint.sqlite.aio.AsyncSqliteSaver]
|
||||
[BaseCheckpointSaver][<insert-ref>]
|
||||
[BaseCheckpointSaver][langgraph.checkpoint.base.BaseCheckpointSaver]
|
||||
[BaseStore][langgraph.store.base.BaseStore]
|
||||
[BaseStore.put][<insert-ref>]
|
||||
[BaseStore.put][langgraph.store.base.BaseStore.put]
|
||||
[BinaryOperatorAggregate][<insert-ref>]
|
||||
[BinaryOperatorAggregate][langgraph.channels.BinaryOperatorAggregate]
|
||||
[`CipherProtocol`][langgraph.checkpoint.serde.base.CipherProtocol]
|
||||
[`client.runs.stream`][langgraph_sdk.client.RunsClient.stream]
|
||||
[`client.runs.wait`][langgraph_sdk.client.RunsClient.wait]
|
||||
[`client.threads.get_history`][langgraph_sdk.client.ThreadsClient.get_history]
|
||||
[`client.threads.update_state`][langgraph_sdk.client.ThreadsClient.update_state]
|
||||
[`Command`][<insert-ref>]
|
||||
[`Command`][langgraph.types.Command]
|
||||
[Command][langgraph.types.Command]
|
||||
[CompiledStateGraph][langgraph.graph.state.CompiledStateGraph]
|
||||
[`createReactAgent`][<insert-ref>]
|
||||
[`create_react_agent`][langgraph.prebuilt.chat_agent_executor.create_react_agent]
|
||||
[create_react_agent][langgraph.prebuilt.chat_agent_executor.create_react_agent]
|
||||
[`create_supervisor`][langgraph_supervisor.supervisor.create_supervisor]
|
||||
[`EncryptedSerializer`][langgraph.checkpoint.serde.encrypted.EncryptedSerializer]
|
||||
[`entrypoint.final`][langgraph.func.entrypoint.final]
|
||||
[`entrypoint`][<insert-ref>]
|
||||
[entrypoint][<insert-ref>]
|
||||
[`@entrypoint`][langgraph.func.entrypoint]
|
||||
[`entrypoint`][langgraph.func.entrypoint]
|
||||
[entrypoint()][langgraph.func.entrypoint]
|
||||
[entrypoint][langgraph.func.entrypoint]
|
||||
[finalResult['values']['messages']
|
||||
[`from_pycryptodome_aes`][langgraph.checkpoint.serde.encrypted.EncryptedSerializer.from_pycryptodome_aes]
|
||||
[`getContextVariable`][<insert-ref>]
|
||||
[`getStateHistory()`][<insert-ref>]
|
||||
[`get_state_history()`][langgraph.graph.state.CompiledStateGraph.get_state_history]
|
||||
[get_stream_writer][langgraph.config.get_stream_writer]
|
||||
[`HumanInterrupt`][langgraph.prebuilt.interrupt.HumanInterrupt]
|
||||
[`HumanInterrupt` schema][langgraph.prebuilt.interrupt.HumanInterrupt]
|
||||
[HumanMessage(content=state[\"messages\"][-2]
|
||||
[`InjectedState`][langgraph.prebuilt.InjectedState]
|
||||
[InjectedState][langgraph.prebuilt.InjectedState]
|
||||
[InMemorySaver][langgraph.checkpoint.memory.InMemorySaver]
|
||||
[`interrupt` function][<insert-ref>]
|
||||
[`interrupt` function][langgraph.types.interrupt]
|
||||
[`interrupt()`][langgraph.types.interrupt]
|
||||
[`interrupt`][langgraph.types.interrupt]
|
||||
[interrupt][langgraph.types.interrupt]
|
||||
[`invoke`][<insert-ref>]
|
||||
[`invoke`][langgraph.graph.state.CompiledStateGraph.invoke]
|
||||
[`JsonPlusSerializer`][langgraph.checkpoint.serde.jsonplus.JsonPlusSerializer]
|
||||
[JsonPlusSerializer][langgraph.checkpoint.serde.jsonplus.JsonPlusSerializer]
|
||||
[langgraph.json CLI reference][configuration-file]
|
||||
[LastValue][<insert-ref>]
|
||||
[LastValue][langgraph.channels.LastValue]
|
||||
[MemorySaver][<insert-ref>]
|
||||
[`messagesStateReducer`][<insert-ref>]
|
||||
[PostgresSaver][<insert-ref>]
|
||||
[PostgresSaver][langgraph.checkpoint.postgres.PostgresSaver]
|
||||
[Pregel][<insert-ref>]
|
||||
[Pregel][langgraph.pregel.Pregel]
|
||||
[`Pregel`][langgraph.pregel.Pregel.stream]
|
||||
[`pre_model_hook`][langgraph.prebuilt.chat_agent_executor.create_react_agent]
|
||||
[protocol][langgraph.checkpoint.serde.base.SerializerProtocol]
|
||||
[`Send()`][langgraph.types.Send]
|
||||
[`Send`][langgraph.types.Send]
|
||||
[SerializerProtocol][<insert-ref>]
|
||||
[SerializerProtocol][langgraph.checkpoint.serde.base.SerializerProtocol]
|
||||
[SqliteSaver][<insert-ref>]
|
||||
[SqliteSaver][langgraph.checkpoint.sqlite.SqliteSaver]
|
||||
[`START`][langgraph.constants.START]
|
||||
[StateGraph (Graph API)][<insert-ref>]
|
||||
[StateGraph (Graph API)][langgraph.graph.StateGraph]
|
||||
[StateGraph (Graph API)][langgraph.graph.state.StateGraph]
|
||||
[StateGraph][<insert-ref>]
|
||||
[StateGraph][langgraph.graph.StateGraph]
|
||||
[`.stream()`][<insert-ref>]
|
||||
[`stream()`][<insert-ref>]
|
||||
[`stream`][<insert-ref>]
|
||||
[`stream()`][langgraph.graph.state.CompiledStateGraph.stream]
|
||||
[`stream`][langgraph.graph.state.CompiledStateGraph.stream]
|
||||
[`.stream()`][langgraph.pregel.Pregel.stream]
|
||||
[tasks][<insert-ref>]
|
||||
[tasks][langgraph.func.task]
|
||||
[`ToolNode`][<insert-ref>]
|
||||
[`ToolNode`][langgraph.prebuilt.tool_node.ToolNode]
|
||||
[ToolNode][langgraph.prebuilt.tool_node.ToolNode]
|
||||
[Topic][<insert-ref>]
|
||||
[Topic][langgraph.channels.Topic]
|
||||
[`updateState`][<insert-ref>]
|
||||
[`update_state`][langgraph.graph.state.CompiledStateGraph.update_state]
|
||||
@@ -8,7 +8,7 @@ Context includes *any* data outside the message list that can shape behavior. Th
|
||||
- Internal state updated during a multi-step reasoning process.
|
||||
- Persistent memory or facts from previous interactions.
|
||||
|
||||
LangGraph provides **three** primary ways to supply context:
|
||||
LangGraph provides **three** primary ways to manage context:
|
||||
|
||||
| Type | Description | Mutable? | Lifetime |
|
||||
|------------------------------------------------------------------------------|-----------------------------------------------|----------|-------------------------|
|
||||
@@ -18,14 +18,21 @@ LangGraph provides **three** primary ways to supply context:
|
||||
|
||||
### Runtime Context
|
||||
|
||||
!!! note "`config['configurable']` -> `runtime.context`"
|
||||
Runtime context is for immutable data like user metadata, tools, db connections, etc. Use this when you have values that don't change mid-run.
|
||||
|
||||
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.
|
||||
!!! version-added "New in LangGraph v0.6: `Runtime.context` replaces `config['configurable']`"
|
||||
|
||||
As of LangGraph v1.0, the Runtime object is recommended to access static context and runtime-specific information like the store and stream writer.
|
||||
The `Runtime` object is recommended to access static context and runtime-specific information like the store and stream writer.
|
||||
|
||||
Runtime context is for immutable data like user metadata or API keys. Use this when you have values that don't change mid-run.
|
||||
!!! note
|
||||
|
||||
Runtime context refers to local context: data and dependencies your code needs to run. It does not refer to:
|
||||
|
||||
* 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.
|
||||
|
||||
You likely want to use the local context to optimize the LLM's context window. For example, you
|
||||
could use a user id to fetch a user's name and information from a database to populate the context window with relevant memories.
|
||||
|
||||
Specify static context via the `context` argument to `invoke` / `stream`, which is reserved for this purpose:
|
||||
|
||||
|
||||
@@ -4,6 +4,38 @@
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
@@ -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">
|
||||
{: style="max-height:400px"}
|
||||
|
||||
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">
|
||||
{: 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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import glob
|
||||
|
||||
def load_replacement_map():
|
||||
with open('replacement_map.json', 'r') as f:
|
||||
return json.load(f)
|
||||
|
||||
def replace_in_file(file_path, replacement_map):
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
original_content = content
|
||||
for key, value in replacement_map.items():
|
||||
content = content.replace(key, value)
|
||||
|
||||
if content != original_content:
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
print(f"Updated: {file_path}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def main():
|
||||
replacement_map = load_replacement_map()
|
||||
|
||||
md_files = glob.glob('**/*.md', recursive=True)
|
||||
updated_count = 0
|
||||
|
||||
for file_path in md_files:
|
||||
if replace_in_file(file_path, replacement_map):
|
||||
updated_count += 1
|
||||
|
||||
print(f"Processed {len(md_files)} markdown files")
|
||||
print(f"Updated {updated_count} files")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"[API reference][langgraph.prebuilt.tool_node.ToolNode]": "@[API reference][ToolNode]",
|
||||
"[API reference][toolnode]": "@[API reference][ToolNode]",
|
||||
"[AsyncPostgresSaver][langgraph.checkpoint.postgres.aio.AsyncPostgresSaver]": "@[AsyncPostgresSaver][AsyncPostgresSaver]",
|
||||
"[AsyncSqliteSaver][langgraph.checkpoint.sqlite.aio.AsyncSqliteSaver]": "@[AsyncSqliteSaver][AsyncSqliteSaver]",
|
||||
"[BaseCheckpointSaver][langgraph.checkpoint.base.BaseCheckpointSaver]": "@[BaseCheckpointSaver][BaseCheckpointSaver]",
|
||||
"[BaseStore.put][langgraph.store.base.BaseStore.put]": "@[BaseStore.put][BaseStore.put]",
|
||||
"[BaseStore][langgraph.store.base.BaseStore]": "@[BaseStore][BaseStore]",
|
||||
"[BinaryOperatorAggregate][langgraph.channels.BinaryOperatorAggregate]": "@[BinaryOperatorAggregate][BinaryOperatorAggregate]",
|
||||
"[Command][langgraph.types.Command]": "@[Command][Command]",
|
||||
"[CompiledStateGraph][langgraph.graph.state.CompiledStateGraph]": "@[CompiledStateGraph][CompiledStateGraph]",
|
||||
"[InMemorySaver][langgraph.checkpoint.memory.InMemorySaver]": "@[InMemorySaver][InMemorySaver]",
|
||||
"[InjectedState][langgraph.prebuilt.InjectedState]": "@[InjectedState][InjectedState]",
|
||||
"[JsonPlusSerializer][langgraph.checkpoint.serde.jsonplus.JsonPlusSerializer]": "@[JsonPlusSerializer][JsonPlusSerializer]",
|
||||
"[LastValue][langgraph.channels.LastValue]": "@[LastValue][LastValue]",
|
||||
"[PostgresSaver][langgraph.checkpoint.postgres.PostgresSaver]": "@[PostgresSaver][PostgresSaver]",
|
||||
"[Pregel][langgraph.pregel.Pregel]": "@[Pregel][Pregel]",
|
||||
"[SerializerProtocol][langgraph.checkpoint.serde.base.SerializerProtocol]": "@[SerializerProtocol][SerializerProtocol]",
|
||||
"[SqliteSaver][langgraph.checkpoint.sqlite.SqliteSaver]": "@[SqliteSaver][SqliteSaver]",
|
||||
"[StateGraph (Graph API)][langgraph.graph.StateGraph]": "@[StateGraph (Graph API)][StateGraph]",
|
||||
"[StateGraph (Graph API)][langgraph.graph.state.StateGraph]": "@[StateGraph (Graph API)][StateGraph]",
|
||||
"[StateGraph][langgraph.graph.StateGraph]": "@[StateGraph][StateGraph]",
|
||||
"[ToolNode][langgraph.prebuilt.tool_node.ToolNode]": "@[ToolNode][ToolNode]",
|
||||
"[Topic][langgraph.channels.Topic]": "@[Topic][Topic]",
|
||||
"[`.astream()`][langgraph.pregel.Pregel.astream]": "@[`.astream()`][Pregel.astream]",
|
||||
"[`.stream()`][langgraph.pregel.Pregel.stream]": "@[`.stream()`][Pregel.stream]",
|
||||
"[`@entrypoint`][langgraph.func.entrypoint]": "@[`@entrypoint`][entrypoint]",
|
||||
"[`CipherProtocol`][langgraph.checkpoint.serde.base.CipherProtocol]": "@[`CipherProtocol`][CipherProtocol]",
|
||||
"[`Command`][langgraph.types.Command]": "@[`Command`][Command]",
|
||||
"[`EncryptedSerializer`][langgraph.checkpoint.serde.encrypted.EncryptedSerializer]": "@[`EncryptedSerializer`][EncryptedSerializer]",
|
||||
"[`HumanInterrupt` schema][langgraph.prebuilt.interrupt.HumanInterrupt]": "@[`HumanInterrupt` schema][HumanInterrupt]",
|
||||
"[`HumanInterrupt`][langgraph.prebuilt.interrupt.HumanInterrupt]": "@[`HumanInterrupt`][HumanInterrupt]",
|
||||
"[`InjectedState`][langgraph.prebuilt.InjectedState]": "@[`InjectedState`][InjectedState]",
|
||||
"[`JsonPlusSerializer`][langgraph.checkpoint.serde.jsonplus.JsonPlusSerializer]": "@[`JsonPlusSerializer`][JsonPlusSerializer]",
|
||||
"[`Pregel`][langgraph.pregel.Pregel.stream]": "@[`Pregel`][Pregel.stream]",
|
||||
"[`START`][langgraph.constants.START]": "@[`START`][START]",
|
||||
"[`Send()`][langgraph.types.Send]": "@[`Send()`][Send]",
|
||||
"[`Send`][langgraph.types.Send]": "@[`Send`][Send]",
|
||||
"[`ToolNode`][langgraph.prebuilt.tool_node.ToolNode]": "@[`ToolNode`][ToolNode]",
|
||||
"[`add_conditional_edges`][langgraph.graph.StateGraph.add_conditional_edges]": "@[`add_conditional_edges`][add_conditional_edges]",
|
||||
"[`add_edge`][langgraph.graph.StateGraph.add_edge]": "@[`add_edge`][add_edge]",
|
||||
"[`add_messages`][langgraph.graph.message.add_messages]": "@[`add_messages`][add_messages]",
|
||||
"[`astream()`][langgraph.graph.state.CompiledStateGraph.astream]": "@[`astream()`][CompiledStateGraph.astream]",
|
||||
"[`client.runs.stream`][langgraph_sdk.client.RunsClient.stream]": "@[`client.runs.stream`][client.runs.stream]",
|
||||
"[`client.runs.wait`][langgraph_sdk.client.RunsClient.wait]": "@[`client.runs.wait`][client.runs.wait]",
|
||||
"[`client.threads.get_history`][langgraph_sdk.client.ThreadsClient.get_history]": "@[`client.threads.get_history`][client.threads.get_history]",
|
||||
"[`client.threads.update_state`][langgraph_sdk.client.ThreadsClient.update_state]": "@[`client.threads.update_state`][client.threads.update_state]",
|
||||
"[`create_react_agent`][langgraph.prebuilt.chat_agent_executor.create_react_agent]": "@[`create_react_agent`][pre_model_hook]",
|
||||
"[`create_supervisor`][langgraph_supervisor.supervisor.create_supervisor]": "@[`create_supervisor`][create_supervisor]",
|
||||
"[`entrypoint.final`][langgraph.func.entrypoint.final]": "@[`entrypoint.final`][entrypoint.final]",
|
||||
"[`entrypoint`][langgraph.func.entrypoint]": "@[`entrypoint`][entrypoint]",
|
||||
"[`from_pycryptodome_aes`][langgraph.checkpoint.serde.encrypted.EncryptedSerializer.from_pycryptodome_aes]": "@[`from_pycryptodome_aes`][from_pycryptodome_aes]",
|
||||
"[`get_state_history()`][langgraph.graph.state.CompiledStateGraph.get_state_history]": "@[`get_state_history()`][get_state_history]",
|
||||
"[`interrupt()`][langgraph.types.interrupt]": "@[`interrupt()`][interrupt]",
|
||||
"[`interrupt` function][langgraph.types.interrupt]": "@[`interrupt` function][interrupt]",
|
||||
"[`interrupt`][langgraph.types.interrupt]": "@[`interrupt`][interrupt]",
|
||||
"[`invoke`][langgraph.graph.state.CompiledStateGraph.invoke]": "@[`invoke`][CompiledStateGraph.invoke]",
|
||||
"[`pre_model_hook`][langgraph.prebuilt.chat_agent_executor.create_react_agent]": "@[`pre_model_hook`][pre_model_hook]",
|
||||
"[`stream()`][langgraph.graph.state.CompiledStateGraph.stream]": "@[`stream()`][CompiledStateGraph.stream]",
|
||||
"[`stream`][langgraph.graph.state.CompiledStateGraph.stream]": "@[`stream`][CompiledStateGraph.stream]",
|
||||
"[`update_state`][langgraph.graph.state.CompiledStateGraph.update_state]": "@[`update_state`][update_state]",
|
||||
"[add_conditional_edges][langgraph.graph.StateGraph.add_conditional_edges]": "@[add_conditional_edges][add_conditional_edges]",
|
||||
"[add_edge][langgraph.graph.StateGraph.add_edge]": "@[add_edge][add_edge]",
|
||||
"[add_node][langgraph.graph.StateGraph.add_node]": "@[add_node][add_node]",
|
||||
"[create_react_agent][langgraph.prebuilt.chat_agent_executor.create_react_agent]": "@[create_react_agent][pre_model_hook]",
|
||||
"[entrypoint()][langgraph.func.entrypoint]": "@[entrypoint()][entrypoint]",
|
||||
"[entrypoint][langgraph.func.entrypoint]": "@[entrypoint][entrypoint]",
|
||||
"[get_stream_writer][langgraph.config.get_stream_writer]": "@[get_stream_writer][get_stream_writer]",
|
||||
"[interrupt][langgraph.types.interrupt]": "@[interrupt][interrupt]",
|
||||
"[langgraph.json CLI reference][configuration-file]": "@[langgraph.json CLI reference][langgraph.json]",
|
||||
"[protocol][langgraph.checkpoint.serde.base.SerializerProtocol]": "@[protocol][SerializerProtocol]",
|
||||
"[tasks][langgraph.func.task]": "@[tasks][task]"
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"""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_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
|
||||
+112
-3
@@ -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):
|
||||
|
||||
@@ -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"})
|
||||
|
||||
Generated
+1
-1
@@ -346,7 +346,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint-sqlite"
|
||||
version = "2.0.10"
|
||||
version = "2.0.11"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -2694,6 +2697,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:
|
||||
|
||||
@@ -2981,6 +2986,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 +3065,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.
|
||||
|
||||
@@ -633,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.
|
||||
@@ -648,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:
|
||||
@@ -676,7 +678,9 @@ class RemoteGraph(PregelProtocol):
|
||||
interrupt_after=interrupt_after,
|
||||
stream_subgraphs=subgraphs or stream is not None,
|
||||
if_not_exists="create",
|
||||
headers=self._merge_tracing_headers(kwargs.pop("headers", None) or {}),
|
||||
headers=_merge_tracing_headers(headers)
|
||||
if self.distributed_tracing
|
||||
else headers,
|
||||
**kwargs,
|
||||
):
|
||||
# split mode and ns
|
||||
@@ -736,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.
|
||||
@@ -751,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:
|
||||
@@ -779,7 +785,9 @@ class RemoteGraph(PregelProtocol):
|
||||
interrupt_after=interrupt_after,
|
||||
stream_subgraphs=subgraphs or stream is not None,
|
||||
if_not_exists="create",
|
||||
headers=self._merge_tracing_headers(kwargs.pop("headers", None) or {}),
|
||||
headers=_merge_tracing_headers(headers)
|
||||
if self.distributed_tracing
|
||||
else headers,
|
||||
**kwargs,
|
||||
):
|
||||
# split mode and ns
|
||||
@@ -853,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.
|
||||
@@ -862,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:
|
||||
@@ -872,6 +882,7 @@ class RemoteGraph(PregelProtocol):
|
||||
config=config,
|
||||
interrupt_before=interrupt_before,
|
||||
interrupt_after=interrupt_after,
|
||||
headers=headers,
|
||||
stream_mode="values",
|
||||
**kwargs,
|
||||
):
|
||||
@@ -888,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.
|
||||
@@ -897,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:
|
||||
@@ -907,6 +920,7 @@ class RemoteGraph(PregelProtocol):
|
||||
config=config,
|
||||
interrupt_before=interrupt_before,
|
||||
interrupt_after=interrupt_after,
|
||||
headers=headers,
|
||||
stream_mode="values",
|
||||
**kwargs,
|
||||
):
|
||||
@@ -916,12 +930,16 @@ class RemoteGraph(PregelProtocol):
|
||||
except UnboundLocalError:
|
||||
return None
|
||||
|
||||
def _merge_tracing_headers(self, headers: dict[str, str]) -> dict[str, str]:
|
||||
if rt := ls.get_current_run_tree():
|
||||
tracing_headers = rt.to_headers()
|
||||
baggage = tracing_headers.pop("baggage")
|
||||
|
||||
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)
|
||||
return headers
|
||||
else:
|
||||
headers = tracing_headers
|
||||
return headers
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
@@ -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
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph"
|
||||
version = "0.6.0a1"
|
||||
version = "0.6.0"
|
||||
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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# import for backwards compatibility
|
||||
from langgraph._internal._runnable import RunnableCallable, RunnableSeq # noqa: F401
|
||||
Generated
+4
-4
@@ -1192,7 +1192,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "0.6.0a1"
|
||||
version = "0.6.0"
|
||||
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.0"
|
||||
source = { editable = "../prebuilt" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph-prebuilt"
|
||||
version = "0.5.2"
|
||||
version = "0.6.0"
|
||||
description = "Library with high-level APIs for creating and executing LangGraph agents and tools."
|
||||
authors = []
|
||||
requires-python = ">=3.9"
|
||||
|
||||
@@ -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=[])
|
||||
|
||||
|
||||
Generated
+3
-3
@@ -316,7 +316,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "0.6.0a1"
|
||||
version = "0.6.0"
|
||||
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.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
|
||||
Reference in New Issue
Block a user