mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-17 07:07:54 +02:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2f96c3936 | ||
|
|
a9a4b83c4c | ||
|
|
5a77be5e8b | ||
|
|
bdb8a9c7a4 | ||
|
|
38031739e5 | ||
|
|
f09cfe8ffc |
@@ -76,6 +76,8 @@ __pypackages__/
|
|||||||
|
|
||||||
# Environments
|
# Environments
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
|
*.crt
|
||||||
.envrc
|
.envrc
|
||||||
*.crt
|
*.crt
|
||||||
*.key
|
*.key
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
This repository is a monorepo. Each library lives in a subdirectory under `libs/`.
|
This repository is a monorepo. Each library lives in a subdirectory under `libs/`.
|
||||||
|
|
||||||
|
<corridor>
|
||||||
|
|
||||||
|
## Corridor security analysis
|
||||||
|
|
||||||
|
When Corridor's `analyzePlan` tool is available, create a plan and use the tool to analyze it before generating or modifying code. Apply the resulting security guidance before writing code.
|
||||||
|
|
||||||
|
</corridor>
|
||||||
|
|
||||||
When you modify code in any library, run the following commands in that library's directory before creating a pull request:
|
When you modify code in any library, run the following commands in that library's directory before creating a pull request:
|
||||||
|
|
||||||
- `make format` – run code formatters
|
- `make format` – run code formatters
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
This repository is a monorepo. Each library lives in a subdirectory under `libs/`.
|
This repository is a monorepo. Each library lives in a subdirectory under `libs/`.
|
||||||
|
|
||||||
|
<corridor>
|
||||||
|
|
||||||
|
## Corridor security analysis
|
||||||
|
|
||||||
|
Before generating or modifying code, create a plan and use Corridor's `analyzePlan` tool to analyze it. Apply the resulting security guidance before writing code.
|
||||||
|
|
||||||
|
</corridor>
|
||||||
|
|
||||||
When you modify code in any library, run the following commands in that library's directory before creating a pull request:
|
When you modify code in any library, run the following commands in that library's directory before creating a pull request:
|
||||||
|
|
||||||
- `make format` – run code formatters
|
- `make format` – run code formatters
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ from langgraph_cli.schemas import (
|
|||||||
GraphDef,
|
GraphDef,
|
||||||
HttpConfig,
|
HttpConfig,
|
||||||
IndexConfig,
|
IndexConfig,
|
||||||
|
McpConfig,
|
||||||
|
McpTasksConfig,
|
||||||
SecurityConfig,
|
SecurityConfig,
|
||||||
SerdeConfig,
|
SerdeConfig,
|
||||||
StoreConfig,
|
StoreConfig,
|
||||||
@@ -115,6 +117,8 @@ def add_descriptions_to_schema(schema, cls):
|
|||||||
AuthConfig,
|
AuthConfig,
|
||||||
SecurityConfig,
|
SecurityConfig,
|
||||||
HttpConfig,
|
HttpConfig,
|
||||||
|
McpConfig,
|
||||||
|
McpTasksConfig,
|
||||||
CorsConfig,
|
CorsConfig,
|
||||||
CacheConfig,
|
CacheConfig,
|
||||||
ThreadTTLConfig,
|
ThreadTTLConfig,
|
||||||
|
|||||||
@@ -439,6 +439,24 @@ class ConfigurableHeaderConfig(TypedDict, total=False):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class McpTasksConfig(TypedDict, total=False):
|
||||||
|
"""Settings for authenticated MCP elicitation tasks."""
|
||||||
|
|
||||||
|
poll_interval_ms: int
|
||||||
|
"""Suggested client polling interval, in milliseconds. Default 1000; range 100–60000."""
|
||||||
|
ttl_minutes: int
|
||||||
|
"""Maximum task lifetime, in minutes. Default 1440; range 1–10080."""
|
||||||
|
completed_grace_seconds: int
|
||||||
|
"""Result retention after completion, in seconds. Default 300; range 1–86400."""
|
||||||
|
allow_insecure_urls: bool
|
||||||
|
"""Allow HTTP elicitation URLs for development. Defaults to False; production URLs require HTTPS."""
|
||||||
|
|
||||||
|
|
||||||
|
class McpConfig(TypedDict, total=False):
|
||||||
|
tasks: McpTasksConfig
|
||||||
|
"""Polling, retention and URL settings for the MCP Tasks extension."""
|
||||||
|
|
||||||
|
|
||||||
class HttpConfig(TypedDict, total=False):
|
class HttpConfig(TypedDict, total=False):
|
||||||
"""Configuration for the built-in HTTP server that powers your deployment's routes and endpoints."""
|
"""Configuration for the built-in HTTP server that powers your deployment's routes and endpoints."""
|
||||||
|
|
||||||
@@ -473,6 +491,8 @@ class HttpConfig(TypedDict, total=False):
|
|||||||
|
|
||||||
Default is False.
|
Default is False.
|
||||||
"""
|
"""
|
||||||
|
mcp: McpConfig
|
||||||
|
"""Optional. Configure the MCP Tasks extension for elicitation over protocol 2026-07-28."""
|
||||||
disable_a2a: bool
|
disable_a2a: bool
|
||||||
"""Optional. If `True`, /a2a routes are removed, disabling default support to expose the deployment as an agent-to-agent (A2A) server.
|
"""Optional. If `True`, /a2a routes are removed, disabling default support to expose the deployment as an agent-to-agent (A2A) server.
|
||||||
|
|
||||||
|
|||||||
@@ -970,6 +970,22 @@ def python_config_to_docker_uv_lock(
|
|||||||
f"{uv_export_project_dir}/uv.lock",
|
f"{uv_export_project_dir}/uv.lock",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
for package_root in sorted(
|
||||||
|
plan.all_workspace_roots,
|
||||||
|
key=lambda root: root.as_posix(),
|
||||||
|
):
|
||||||
|
if package_root == plan.project_root:
|
||||||
|
continue
|
||||||
|
package_relative_path = pathlib.PurePosixPath(
|
||||||
|
package_root.relative_to(plan.project_root).as_posix()
|
||||||
|
)
|
||||||
|
package_pyproject_path = package_relative_path / "pyproject.toml"
|
||||||
|
docker_plan.add_raw(
|
||||||
|
copy_from_project_root(
|
||||||
|
package_pyproject_path,
|
||||||
|
f"{uv_export_project_dir}/{package_pyproject_path.as_posix()}",
|
||||||
|
)
|
||||||
|
)
|
||||||
docker_plan.add_instruction("WORKDIR", uv_export_project_dir)
|
docker_plan.add_instruction("WORKDIR", uv_export_project_dir)
|
||||||
docker_plan.add_instruction(
|
docker_plan.add_instruction(
|
||||||
"RUN",
|
"RUN",
|
||||||
|
|||||||
@@ -1065,6 +1065,10 @@
|
|||||||
],
|
],
|
||||||
"description": "Optional. Defines which headers are excluded from logging."
|
"description": "Optional. Defines which headers are excluded from logging."
|
||||||
},
|
},
|
||||||
|
"mcp": {
|
||||||
|
"$ref": "#/$defs/McpConfig",
|
||||||
|
"description": "Optional. Configure the MCP Tasks extension for elicitation over protocol 2026-07-28."
|
||||||
|
},
|
||||||
"middleware_order": {
|
"middleware_order": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -1170,6 +1174,42 @@
|
|||||||
},
|
},
|
||||||
"required": []
|
"required": []
|
||||||
},
|
},
|
||||||
|
"McpConfig": {
|
||||||
|
"title": "McpConfig",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"tasks": {
|
||||||
|
"$ref": "#/$defs/McpTasksConfig",
|
||||||
|
"description": "Polling, retention and URL settings for the MCP Tasks extension."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [],
|
||||||
|
"description": "dict() -> new empty dictionary\ndict(mapping) -> new dictionary initialized from a mapping object's\n (key, value) pairs\ndict(iterable) -> new dictionary initialized as if via:\n d = {}\n for k, v in iterable:\n d[k] = v\ndict(**kwargs) -> new dictionary initialized with the name=value pairs\n in the keyword argument list. For example: dict(one=1, two=2)"
|
||||||
|
},
|
||||||
|
"McpTasksConfig": {
|
||||||
|
"title": "McpTasksConfig",
|
||||||
|
"description": "Settings for authenticated MCP elicitation tasks.",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"allow_insecure_urls": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Allow HTTP elicitation URLs for development. Defaults to False; production URLs require HTTPS."
|
||||||
|
},
|
||||||
|
"completed_grace_seconds": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Result retention after completion, in seconds. Default 300; range 1\u201386400."
|
||||||
|
},
|
||||||
|
"poll_interval_ms": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Suggested client polling interval, in milliseconds. Default 1000; range 100\u201360000."
|
||||||
|
},
|
||||||
|
"ttl_minutes": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Maximum task lifetime, in minutes. Default 1440; range 1\u201310080."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": []
|
||||||
|
},
|
||||||
"UvSource": {
|
"UvSource": {
|
||||||
"title": "UvSource",
|
"title": "UvSource",
|
||||||
"description": "Deployment source rooted at a uv project or workspace.",
|
"description": "Deployment source rooted at a uv project or workspace.",
|
||||||
|
|||||||
@@ -1065,6 +1065,10 @@
|
|||||||
],
|
],
|
||||||
"description": "Optional. Defines which headers are excluded from logging."
|
"description": "Optional. Defines which headers are excluded from logging."
|
||||||
},
|
},
|
||||||
|
"mcp": {
|
||||||
|
"$ref": "#/$defs/McpConfig",
|
||||||
|
"description": "Optional. Configure the MCP Tasks extension for elicitation over protocol 2026-07-28."
|
||||||
|
},
|
||||||
"middleware_order": {
|
"middleware_order": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -1170,6 +1174,42 @@
|
|||||||
},
|
},
|
||||||
"required": []
|
"required": []
|
||||||
},
|
},
|
||||||
|
"McpConfig": {
|
||||||
|
"title": "McpConfig",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"tasks": {
|
||||||
|
"$ref": "#/$defs/McpTasksConfig",
|
||||||
|
"description": "Polling, retention and URL settings for the MCP Tasks extension."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [],
|
||||||
|
"description": "dict() -> new empty dictionary\ndict(mapping) -> new dictionary initialized from a mapping object's\n (key, value) pairs\ndict(iterable) -> new dictionary initialized as if via:\n d = {}\n for k, v in iterable:\n d[k] = v\ndict(**kwargs) -> new dictionary initialized with the name=value pairs\n in the keyword argument list. For example: dict(one=1, two=2)"
|
||||||
|
},
|
||||||
|
"McpTasksConfig": {
|
||||||
|
"title": "McpTasksConfig",
|
||||||
|
"description": "Settings for authenticated MCP elicitation tasks.",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"allow_insecure_urls": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Allow HTTP elicitation URLs for development. Defaults to False; production URLs require HTTPS."
|
||||||
|
},
|
||||||
|
"completed_grace_seconds": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Result retention after completion, in seconds. Default 300; range 1\u201386400."
|
||||||
|
},
|
||||||
|
"poll_interval_ms": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Suggested client polling interval, in milliseconds. Default 1000; range 100\u201360000."
|
||||||
|
},
|
||||||
|
"ttl_minutes": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Maximum task lifetime, in minutes. Default 1440; range 1\u201310080."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": []
|
||||||
|
},
|
||||||
"UvSource": {
|
"UvSource": {
|
||||||
"title": "UvSource",
|
"title": "UvSource",
|
||||||
"description": "Deployment source rooted at a uv project or workspace.",
|
"description": "Deployment source rooted at a uv project or workspace.",
|
||||||
|
|||||||
@@ -1403,6 +1403,19 @@ def test_config_to_docker_uv_lock():
|
|||||||
"COPY --from=uv-workspace-root uv.lock /tmp/uv_export/project/uv.lock"
|
"COPY --from=uv-workspace-root uv.lock /tmp/uv_export/project/uv.lock"
|
||||||
in docker
|
in docker
|
||||||
)
|
)
|
||||||
|
workspace_pyprojects = [
|
||||||
|
"apps/agent/pyproject.toml",
|
||||||
|
"libs/extra/pyproject.toml",
|
||||||
|
"libs/shared/pyproject.toml",
|
||||||
|
]
|
||||||
|
export_instruction = "RUN uv export --package agent"
|
||||||
|
for pyproject_path in workspace_pyprojects:
|
||||||
|
copy_instruction = (
|
||||||
|
"COPY --from=uv-workspace-root "
|
||||||
|
f"{pyproject_path} /tmp/uv_export/project/{pyproject_path}"
|
||||||
|
)
|
||||||
|
assert copy_instruction in docker
|
||||||
|
assert docker.index(copy_instruction) < docker.index(export_instruction)
|
||||||
assert additional_contexts == {"uv-workspace-root": str(project_root.resolve())}
|
assert additional_contexts == {"uv-workspace-root": str(project_root.resolve())}
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
|
|||||||
Generated
+6
-6
@@ -266,20 +266,20 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langgraph-checkpoint"
|
name = "langgraph-checkpoint"
|
||||||
version = "4.0.1"
|
version = "4.2.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "langchain-core" },
|
{ name = "langchain-core" },
|
||||||
{ name = "ormsgpack" },
|
{ name = "ormsgpack" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/44/a8df45d1e8b4637e29789fa8bae1db022c953cc7ac80093cfc52e923547e/langgraph_checkpoint-4.0.1.tar.gz", hash = "sha256:b433123735df11ade28829e40ce25b9be614930cd50245ff2af60629234befd9", size = 158135, upload-time = "2026-02-27T21:06:16.092Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/dc/e1/089c4c9e0a2fec7f883f82ae8e6a727138d50074cfeb6644bc2d13b1019b/langgraph_checkpoint-4.2.0.tar.gz", hash = "sha256:51a593b6bee684b0818e5d6e58e28ab340c6db7794575056ce7bd1b746a84ed7", size = 180239, upload-time = "2026-08-07T20:05:03.756Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/65/4c/09a4a0c42f5d2fc38d6c4d67884788eff7fd2cfdf367fdf7033de908b4c0/langgraph_checkpoint-4.0.1-py3-none-any.whl", hash = "sha256:e3adcd7a0e0166f3b48b8cf508ce0ea366e7420b5a73aa81289888727769b034", size = 50453, upload-time = "2026-02-27T21:06:14.293Z" },
|
{ url = "https://files.pythonhosted.org/packages/05/71/3b475f09bd57d3a5649792c66353312b4432afd843f301739dfcebd157f0/langgraph_checkpoint-4.2.0-py3-none-any.whl", hash = "sha256:0547fd228935a0b758865de3a3d6d7a2537c308895d0f9ab092ce9151b5da942", size = 56833, upload-time = "2026-08-07T20:05:02.655Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langgraph-checkpoint-postgres"
|
name = "langgraph-checkpoint-postgres"
|
||||||
version = "3.0.5"
|
version = "3.1.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "langgraph-checkpoint" },
|
{ name = "langgraph-checkpoint" },
|
||||||
@@ -287,9 +287,9 @@ dependencies = [
|
|||||||
{ name = "psycopg" },
|
{ name = "psycopg" },
|
||||||
{ name = "psycopg-pool" },
|
{ name = "psycopg-pool" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/95/7a/8f439966643d32111248a225e6cb33a182d07c90de780c4dbfc1e0377832/langgraph_checkpoint_postgres-3.0.5.tar.gz", hash = "sha256:a8fd7278a63f4f849b5cbc7884a15ca8f41e7d5f7467d0a66b31e8c24492f7eb", size = 127856, upload-time = "2026-03-18T21:25:29.785Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/06/92/1e8959f8cd1b56e672fde3227f6fd642be85af6c5fd662d73921074aa39d/langgraph_checkpoint_postgres-3.1.1.tar.gz", hash = "sha256:d320e147ddad8c374cd546df0b52b532dd54d0541dd9fd23fc738cbd5de76f41", size = 150413, upload-time = "2026-07-30T19:15:39.014Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/e8/87/b0f98b33a67204bca9d5619bcd9574222f6b025cf3c125eedcec9a50ecbc/langgraph_checkpoint_postgres-3.0.5-py3-none-any.whl", hash = "sha256:86d7040a88fd70087eaafb72251d796696a0a2d856168f5c11ef620771411552", size = 42907, upload-time = "2026-03-18T21:25:28.75Z" },
|
{ url = "https://files.pythonhosted.org/packages/03/32/ba457698a48a0e18d786caa770033067049fbe36d6846f8e50f13b594b51/langgraph_checkpoint_postgres-3.1.1-py3-none-any.whl", hash = "sha256:6e353aecd8150de144fef8e51a49076f58b7d6830d4cf51392b7ad4d79832ba7", size = 50778, upload-time = "2026-07-30T19:15:37.405Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -39,6 +39,15 @@
|
|||||||
- `client.threads.stream()` now accepts `transport="sse"` (default) or
|
- `client.threads.stream()` now accepts `transport="sse"` (default) or
|
||||||
`transport="websocket"` in place of the previous transport-agnostic default.
|
`transport="websocket"` in place of the previous transport-agnostic default.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Resource-scoped auth decorators now honor `actions=` and reject empty or
|
||||||
|
invalid action lists. Because unmatched custom-auth paths remain allowed,
|
||||||
|
deployments using action-scoped handlers should configure a global
|
||||||
|
default-deny handler; `langgraph-api` 0.10+ warns about uncovered paths at
|
||||||
|
startup. Resource-specific decorators retain matching `resources=` selectors
|
||||||
|
for backward compatibility; use `@auth.on(resources=...)` for other resources.
|
||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
|
|
||||||
- The v3 streaming surface (`AsyncThreadStream`, `SyncThreadStream`, and all
|
- The v3 streaming surface (`AsyncThreadStream`, `SyncThreadStream`, and all
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from langgraph_sdk.client import get_client, get_sync_client
|
|||||||
from langgraph_sdk.encryption import Encryption
|
from langgraph_sdk.encryption import Encryption
|
||||||
from langgraph_sdk.encryption.types import DecryptResult, EncryptionContext
|
from langgraph_sdk.encryption.types import DecryptResult, EncryptionContext
|
||||||
|
|
||||||
__version__ = "0.4.3"
|
__version__ = "0.4.4"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Auth",
|
"Auth",
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from langchain_core.language_models.chat_model_stream import AsyncChatModelStrea
|
|||||||
from langchain_protocol import Event, SubscribeParams
|
from langchain_protocol import Event, SubscribeParams
|
||||||
|
|
||||||
from langgraph_sdk._async.http import HttpClient
|
from langgraph_sdk._async.http import HttpClient
|
||||||
from langgraph_sdk.schema import QueryParamTypes
|
from langgraph_sdk.schema import LangSmithTracing, QueryParamTypes
|
||||||
from langgraph_sdk.stream.controller import _SeenEventIds
|
from langgraph_sdk.stream.controller import _SeenEventIds
|
||||||
from langgraph_sdk.stream.decoders import (
|
from langgraph_sdk.stream.decoders import (
|
||||||
DataDecoder,
|
DataDecoder,
|
||||||
@@ -172,6 +172,7 @@ class RunModule:
|
|||||||
input: Any = None,
|
input: Any = None,
|
||||||
config: dict[str, Any] | None = None,
|
config: dict[str, Any] | None = None,
|
||||||
metadata: dict[str, Any] | None = None,
|
metadata: dict[str, Any] | None = None,
|
||||||
|
langsmith_tracing: LangSmithTracing | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Send `run.start` to the server. Returns the result (`{"run_id": ...}`)."""
|
"""Send `run.start` to the server. Returns the result (`{"run_id": ...}`)."""
|
||||||
params: dict[str, Any] = {"assistant_id": self._owner.assistant_id}
|
params: dict[str, Any] = {"assistant_id": self._owner.assistant_id}
|
||||||
@@ -181,6 +182,8 @@ class RunModule:
|
|||||||
params["config"] = config
|
params["config"] = config
|
||||||
if metadata is not None:
|
if metadata is not None:
|
||||||
params["metadata"] = metadata
|
params["metadata"] = metadata
|
||||||
|
if langsmith_tracing is not None:
|
||||||
|
params["langsmith_tracer"] = langsmith_tracing
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
gate: asyncio.Future[None] = loop.create_future()
|
gate: asyncio.Future[None] = loop.create_future()
|
||||||
self._owner._run_start_ready = gate
|
self._owner._run_start_ready = gate
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from langchain_core.language_models.chat_model_stream import ChatModelStream
|
|||||||
from langchain_protocol import Event, SubscribeParams
|
from langchain_protocol import Event, SubscribeParams
|
||||||
|
|
||||||
from langgraph_sdk._sync.http import SyncHttpClient
|
from langgraph_sdk._sync.http import SyncHttpClient
|
||||||
from langgraph_sdk.schema import QueryParamTypes
|
from langgraph_sdk.schema import LangSmithTracing, QueryParamTypes
|
||||||
from langgraph_sdk.stream.decoders import (
|
from langgraph_sdk.stream.decoders import (
|
||||||
DataDecoder,
|
DataDecoder,
|
||||||
Decoder,
|
Decoder,
|
||||||
@@ -215,6 +215,7 @@ class SyncRunModule:
|
|||||||
input: Any = None,
|
input: Any = None,
|
||||||
config: dict[str, Any] | None = None,
|
config: dict[str, Any] | None = None,
|
||||||
metadata: dict[str, Any] | None = None,
|
metadata: dict[str, Any] | None = None,
|
||||||
|
langsmith_tracing: LangSmithTracing | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Send `run.start` to the server. Returns the result (`{"run_id": ...}`)."""
|
"""Send `run.start` to the server. Returns the result (`{"run_id": ...}`)."""
|
||||||
params: dict[str, Any] = {"assistant_id": self._owner.assistant_id}
|
params: dict[str, Any] = {"assistant_id": self._owner.assistant_id}
|
||||||
@@ -224,6 +225,8 @@ class SyncRunModule:
|
|||||||
params["config"] = config
|
params["config"] = config
|
||||||
if metadata is not None:
|
if metadata is not None:
|
||||||
params["metadata"] = metadata
|
params["metadata"] = metadata
|
||||||
|
if langsmith_tracing is not None:
|
||||||
|
params["langsmith_tracer"] = langsmith_tracing
|
||||||
result = self._owner._send_command("run.start", params)
|
result = self._owner._send_command("run.start", params)
|
||||||
self._owner._run_seen = True
|
self._owner._run_seen = True
|
||||||
controller = self._owner._controller
|
controller = self._owner._controller
|
||||||
|
|||||||
@@ -341,9 +341,15 @@ VUpdate = typing.TypeVar("VUpdate", covariant=True)
|
|||||||
VRead = typing.TypeVar("VRead", covariant=True)
|
VRead = typing.TypeVar("VRead", covariant=True)
|
||||||
VDelete = typing.TypeVar("VDelete", covariant=True)
|
VDelete = typing.TypeVar("VDelete", covariant=True)
|
||||||
VSearch = typing.TypeVar("VSearch", covariant=True)
|
VSearch = typing.TypeVar("VSearch", covariant=True)
|
||||||
|
ResourceActionT = typing.TypeVar("ResourceActionT", bound=str)
|
||||||
|
|
||||||
|
_ResourceAction = typing.Literal["create", "read", "update", "delete", "search"]
|
||||||
|
_ThreadAction = _ResourceAction | typing.Literal["create_run"]
|
||||||
|
|
||||||
|
|
||||||
class _ResourceOn(typing.Generic[VCreate, VRead, VUpdate, VDelete, VSearch]):
|
class _ResourceOn(
|
||||||
|
typing.Generic[VCreate, VRead, VUpdate, VDelete, VSearch, ResourceActionT]
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Generic base class for resource-specific handlers.
|
Generic base class for resource-specific handlers.
|
||||||
"""
|
"""
|
||||||
@@ -392,8 +398,8 @@ class _ResourceOn(typing.Generic[VCreate, VRead, VUpdate, VDelete, VSearch]):
|
|||||||
def __call__(
|
def __call__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
resources: str | Sequence[str],
|
resources: str | Sequence[str] | None = None,
|
||||||
actions: str | Sequence[str] | None = None,
|
actions: ResourceActionT | Sequence[ResourceActionT] | None = None,
|
||||||
) -> Callable[
|
) -> Callable[
|
||||||
[_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]],
|
[_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]],
|
||||||
_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch],
|
_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch],
|
||||||
@@ -408,7 +414,7 @@ class _ResourceOn(typing.Generic[VCreate, VRead, VUpdate, VDelete, VSearch]):
|
|||||||
) = None,
|
) = None,
|
||||||
*,
|
*,
|
||||||
resources: str | Sequence[str] | None = None,
|
resources: str | Sequence[str] | None = None,
|
||||||
actions: str | Sequence[str] | None = None,
|
actions: ResourceActionT | Sequence[ResourceActionT] | None = None,
|
||||||
) -> (
|
) -> (
|
||||||
_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]
|
_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]
|
||||||
| Callable[
|
| Callable[
|
||||||
@@ -416,24 +422,66 @@ class _ResourceOn(typing.Generic[VCreate, VRead, VUpdate, VDelete, VSearch]):
|
|||||||
_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch],
|
_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch],
|
||||||
]
|
]
|
||||||
):
|
):
|
||||||
if fn is not None:
|
|
||||||
_validate_handler(fn)
|
|
||||||
return typing.cast(
|
|
||||||
"_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]",
|
|
||||||
_register_handler(self.auth, self.resource, "*", fn),
|
|
||||||
)
|
|
||||||
|
|
||||||
def decorator(
|
def decorator(
|
||||||
handler: _ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch],
|
handler: _ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch],
|
||||||
) -> _ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]:
|
) -> _ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]:
|
||||||
_validate_handler(handler)
|
_validate_handler(handler)
|
||||||
return typing.cast(
|
if resources is None:
|
||||||
"_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]",
|
resource_list = [self.resource]
|
||||||
_register_handler(self.auth, self.resource, "*", handler),
|
elif isinstance(resources, str):
|
||||||
|
resource_list = [resources]
|
||||||
|
elif isinstance(resources, Sequence):
|
||||||
|
resource_list = list(resources)
|
||||||
|
else:
|
||||||
|
raise TypeError("resources must be a string or sequence of strings")
|
||||||
|
if resource_list != [self.resource]:
|
||||||
|
raise ValueError(
|
||||||
|
f"Resource-specific decorator for {self.resource!r} cannot "
|
||||||
|
f"register handlers for {resource_list!r}. Use @auth.on(...) "
|
||||||
|
"for other or multiple resources."
|
||||||
|
)
|
||||||
|
if actions is None:
|
||||||
|
action_list = ["*"]
|
||||||
|
elif isinstance(actions, str):
|
||||||
|
action_list = [actions]
|
||||||
|
elif isinstance(actions, Sequence):
|
||||||
|
action_list = list(actions)
|
||||||
|
else:
|
||||||
|
raise TypeError("actions must be a string or sequence of strings")
|
||||||
|
if not action_list:
|
||||||
|
raise ValueError("actions must not be empty")
|
||||||
|
if not all(isinstance(action, str) for action in action_list):
|
||||||
|
raise TypeError("actions must be a string or sequence of strings")
|
||||||
|
valid_actions = {
|
||||||
|
value.action
|
||||||
|
for value in vars(self).values()
|
||||||
|
if isinstance(value, _ResourceActionOn)
|
||||||
|
}
|
||||||
|
invalid_actions = (
|
||||||
|
sorted(set(action_list) - valid_actions) if actions is not None else []
|
||||||
)
|
)
|
||||||
|
if invalid_actions:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid action(s) for {self.resource}: {', '.join(invalid_actions)}"
|
||||||
|
)
|
||||||
|
if len(action_list) != len(set(action_list)):
|
||||||
|
raise ValueError("actions must not contain duplicates")
|
||||||
|
for action in action_list:
|
||||||
|
if (self.resource, action) in self.auth._handlers:
|
||||||
|
raise ValueError(
|
||||||
|
f"types.Handler already set for {self.resource}, {action}."
|
||||||
|
)
|
||||||
|
for action in action_list:
|
||||||
|
_register_handler(self.auth, self.resource, action, handler)
|
||||||
|
return handler
|
||||||
|
|
||||||
# Accept keyword-only parameters for future filtering behavior; referenced to satisfy linters.
|
if fn is not None:
|
||||||
_ = resources, actions
|
return decorator(
|
||||||
|
typing.cast(
|
||||||
|
"_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]",
|
||||||
|
fn,
|
||||||
|
)
|
||||||
|
)
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
@@ -444,6 +492,7 @@ class _AssistantsOn(
|
|||||||
types.AssistantsUpdate,
|
types.AssistantsUpdate,
|
||||||
types.AssistantsDelete,
|
types.AssistantsDelete,
|
||||||
types.AssistantsSearch,
|
types.AssistantsSearch,
|
||||||
|
_ResourceAction,
|
||||||
]
|
]
|
||||||
):
|
):
|
||||||
value = (
|
value = (
|
||||||
@@ -467,6 +516,7 @@ class _ThreadsOn(
|
|||||||
types.ThreadsUpdate,
|
types.ThreadsUpdate,
|
||||||
types.ThreadsDelete,
|
types.ThreadsDelete,
|
||||||
types.ThreadsSearch,
|
types.ThreadsSearch,
|
||||||
|
_ThreadAction,
|
||||||
]
|
]
|
||||||
):
|
):
|
||||||
value = (
|
value = (
|
||||||
@@ -502,6 +552,7 @@ class _CronsOn(
|
|||||||
types.CronsUpdate,
|
types.CronsUpdate,
|
||||||
types.CronsDelete,
|
types.CronsDelete,
|
||||||
types.CronsSearch,
|
types.CronsSearch,
|
||||||
|
_ResourceAction,
|
||||||
]
|
]
|
||||||
):
|
):
|
||||||
value = type[
|
value = type[
|
||||||
|
|||||||
@@ -426,11 +426,17 @@ def test_sync_run_start_sends_command():
|
|||||||
with httpx.Client(transport=fake.transport, base_url="http://test") as raw:
|
with httpx.Client(transport=fake.transport, base_url="http://test") as raw:
|
||||||
threads = SyncThreadsClient(SyncHttpClient(raw))
|
threads = SyncThreadsClient(SyncHttpClient(raw))
|
||||||
with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
|
with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
|
||||||
result = thread.run.start(input={"x": 1})
|
result = thread.run.start(
|
||||||
|
input={"x": 1},
|
||||||
|
langsmith_tracing={"project_name": "replica-project"},
|
||||||
|
)
|
||||||
|
|
||||||
assert result == {"run_id": "run-1"}
|
assert result == {"run_id": "run-1"}
|
||||||
assert fake.received_commands[0]["method"] == "run.start"
|
assert fake.received_commands[0]["method"] == "run.start"
|
||||||
assert fake.received_commands[0]["params"]["assistant_id"] == "agent"
|
assert fake.received_commands[0]["params"]["assistant_id"] == "agent"
|
||||||
|
assert fake.received_commands[0]["params"]["langsmith_tracer"] == {
|
||||||
|
"project_name": "replica-project"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_sync_events_iterates_raw_events():
|
def test_sync_events_iterates_raw_events():
|
||||||
|
|||||||
@@ -287,7 +287,7 @@ async def test_command_ids_are_monotonic():
|
|||||||
assert [c["id"] for c in fake.received_commands] == [1, 2]
|
assert [c["id"] for c in fake.received_commands] == [1, 2]
|
||||||
|
|
||||||
|
|
||||||
async def test_run_start_forwards_config_and_metadata():
|
async def test_run_start_forwards_config_metadata_and_langsmith_tracing():
|
||||||
fake = FakeServer()
|
fake = FakeServer()
|
||||||
transport = httpx.ASGITransport(app=fake.app)
|
transport = httpx.ASGITransport(app=fake.app)
|
||||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw:
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw:
|
||||||
@@ -297,10 +297,18 @@ async def test_run_start_forwards_config_and_metadata():
|
|||||||
input={"x": 1},
|
input={"x": 1},
|
||||||
config={"recursion_limit": 5},
|
config={"recursion_limit": 5},
|
||||||
metadata={"trace": "abc"},
|
metadata={"trace": "abc"},
|
||||||
|
langsmith_tracing={
|
||||||
|
"project_name": "replica-project",
|
||||||
|
"example_id": "example-1",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
params = fake.received_commands[0]["params"]
|
params = fake.received_commands[0]["params"]
|
||||||
assert params["config"] == {"recursion_limit": 5}
|
assert params["config"] == {"recursion_limit": 5}
|
||||||
assert params["metadata"] == {"trace": "abc"}
|
assert params["metadata"] == {"trace": "abc"}
|
||||||
|
assert params["langsmith_tracer"] == {
|
||||||
|
"project_name": "replica-project",
|
||||||
|
"example_id": "example-1",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def test_run_start_raises_outside_context_manager():
|
async def test_run_start_raises_outside_context_manager():
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from langgraph_sdk import Auth
|
||||||
|
|
||||||
|
|
||||||
|
def test_handler_multiple_resources_and_actions() -> None:
|
||||||
|
auth = Auth()
|
||||||
|
|
||||||
|
@auth.on(resources=["threads", "assistants"], actions=["read", "search"])
|
||||||
|
async def allow_reads(ctx, value):
|
||||||
|
del value
|
||||||
|
return {"owner": ctx.user.identity}
|
||||||
|
|
||||||
|
assert auth._handlers == {
|
||||||
|
("threads", "read"): [allow_reads],
|
||||||
|
("threads", "search"): [allow_reads],
|
||||||
|
("assistants", "read"): [allow_reads],
|
||||||
|
("assistants", "search"): [allow_reads],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_handler_actions_are_scoped() -> None:
|
||||||
|
auth = Auth()
|
||||||
|
|
||||||
|
@auth.on
|
||||||
|
async def deny_all(ctx, value):
|
||||||
|
del ctx, value
|
||||||
|
return False
|
||||||
|
|
||||||
|
@auth.on.threads(actions=["create", "search"])
|
||||||
|
async def handler(ctx, value):
|
||||||
|
del ctx, value
|
||||||
|
return None
|
||||||
|
|
||||||
|
@auth.on.threads(actions="create_run")
|
||||||
|
async def run_handler(ctx, value):
|
||||||
|
del ctx, value
|
||||||
|
return None
|
||||||
|
|
||||||
|
assert auth._handlers == {
|
||||||
|
("threads", "create"): [handler],
|
||||||
|
("threads", "search"): [handler],
|
||||||
|
("threads", "create_run"): [run_handler],
|
||||||
|
}
|
||||||
|
assert auth._global_handlers == [deny_all]
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_handler_preserves_wildcard() -> None:
|
||||||
|
auth = Auth()
|
||||||
|
|
||||||
|
@auth.on.threads
|
||||||
|
async def handler(ctx, value):
|
||||||
|
del ctx, value
|
||||||
|
return None
|
||||||
|
|
||||||
|
assert auth._handlers == {("threads", "*"): [handler]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_handler_preserves_wildcard_with_parentheses() -> None:
|
||||||
|
auth = Auth()
|
||||||
|
|
||||||
|
@auth.on.threads()
|
||||||
|
async def handler(ctx, value):
|
||||||
|
del ctx, value
|
||||||
|
return None
|
||||||
|
|
||||||
|
assert auth._handlers == {("threads", "*"): [handler]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_handler_accepts_matching_resource() -> None:
|
||||||
|
auth = Auth()
|
||||||
|
|
||||||
|
@auth.on.threads(resources=["threads"], actions="read")
|
||||||
|
async def handler(ctx, value):
|
||||||
|
del ctx, value
|
||||||
|
return None
|
||||||
|
|
||||||
|
assert auth._handlers == {("threads", "read"): [handler]}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"resources", [["assistants"], ["threads", "assistants"], [], [1]]
|
||||||
|
)
|
||||||
|
def test_resource_handler_rejects_nonmatching_resources(resources) -> None:
|
||||||
|
auth = Auth()
|
||||||
|
|
||||||
|
async def handler(ctx, value):
|
||||||
|
del ctx, value
|
||||||
|
return None
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match=r"Use @auth\.on"):
|
||||||
|
auth.on.threads(resources=resources)(handler)
|
||||||
|
assert auth._handlers == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("resource", "actions", "error"),
|
||||||
|
[
|
||||||
|
("threads", [], ValueError),
|
||||||
|
("threads", ["reed"], ValueError),
|
||||||
|
("threads", ["create", "create"], ValueError),
|
||||||
|
("threads", {"create": True}, TypeError),
|
||||||
|
("crons", ["create_run"], ValueError),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_resource_handler_rejects_invalid_actions(resource, actions, error) -> None:
|
||||||
|
auth = Auth()
|
||||||
|
|
||||||
|
async def handler(ctx, value):
|
||||||
|
del ctx, value
|
||||||
|
return None
|
||||||
|
|
||||||
|
with pytest.raises(error):
|
||||||
|
getattr(auth.on, resource)(actions=actions)(handler)
|
||||||
|
assert auth._handlers == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_handler_registration_is_atomic() -> None:
|
||||||
|
auth = Auth()
|
||||||
|
|
||||||
|
@auth.on.threads.read
|
||||||
|
async def read_handler(ctx, value):
|
||||||
|
del ctx, value
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def handler(ctx, value):
|
||||||
|
del ctx, value
|
||||||
|
return None
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="already set"):
|
||||||
|
auth.on.threads(actions=["create", "read"])(handler)
|
||||||
|
assert auth._handlers == {("threads", "read"): [read_handler]}
|
||||||
Reference in New Issue
Block a user