Compare commits

..
Author SHA1 Message Date
Sreekara YachamaneniandGitHub d5f4b2aa96 release(sdk-py): 0.4.4 (#8738)
Bumps the Python SDK version from 0.4.3 to 0.4.4.
2026-08-27 17:14:54 -04:00
Sreekara YachamaneniandGitHub 5a77be5e8b Merge commit from fork
* authz fix for custom auth

* add back resource param

* auth on multiple resources
2026-08-27 13:28:59 -07:00
Mason DaughertyGitHubopen-swe[bot] <open-swe@users.noreply.github.com>
bdb8a9c7a4 feat: route LangSmith traces from thread streams (#8723)
## Description
Expose the existing `langsmith_tracing` option on Python sync and async
thread-stream run starts and forward it through the protocol.

## Release Note
Python thread streams can route traces to an additional LangSmith
project per run.

## Test Plan
- [x] Verify sync and async run-start payloads include tracing settings

## Related PRs
- langchain-ai/agent-protocol#95
- langchain-ai/langgraphjs#2745
- langchain-ai/langgraph-api#4033

Made by [Open
SWE](https://openswe.vercel.app/agents/f9e34294-b9c3-52f0-815a-0102188e1181)

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-08-26 16:57:39 -04:00
John KennedyGitHublangsmith-fleet[bot] <langsmith-fleet[bot]@users.noreply.github.com>
38031739e5 docs: add Corridor agent instructions (#8677)
Add a repository-wide `<corridor>` block to `AGENTS.md` requiring agents
to plan first and run Corridor `analyzePlan` before generating or
modifying code. The tags are explicit and balanced so Corridor-specific
guidance remains scoped.

Validation: `git diff --check` and a tag-balance assertion.

---------

Co-authored-by: langsmith-fleet[bot] <langsmith-fleet[bot]@users.noreply.github.com>
2026-08-24 14:42:27 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
f09cfe8ffc chore(deps): bump langgraph-checkpoint-postgres from 3.0.5 to 3.1.1 in /libs/cli/uv-examples/monorepo in the uv group across 1 directory (#8646)
Bumps the uv group with 1 update in the /libs/cli/uv-examples/monorepo
directory:
[langgraph-checkpoint-postgres](https://github.com/langchain-ai/langgraph).

Updates `langgraph-checkpoint-postgres` from 3.0.5 to 3.1.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/langchain-ai/langgraph/releases">langgraph-checkpoint-postgres's
releases</a>.</em></p>
<blockquote>
<h2>langgraph-checkpoint-postgres==3.1.1</h2>
<p>Changes since checkpointpostgres==3.1.0</p>
<ul>
<li>release(checkpoint-postgres): 3.1.1 (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/8480">#8480</a>)</li>
<li>fix(checkpoint-postgres,checkpoint-sqlite): scope namespace matching
to segment boundaries (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/8478">#8478</a>)</li>
<li>feat(checkpoint,checkpoint-postgres): add opt-in omit_expired to
skip expired rows on read (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/8354">#8354</a>)</li>
<li>chore(deps): bump the minor-and-patch group in
/libs/checkpoint-postgres with 5 updates (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/8250">#8250</a>)</li>
<li>chore(deps): bump langsmith from 0.8.0 to 0.8.18 in
/libs/checkpoint-postgres (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/8171">#8171</a>)</li>
<li>docs: standardize package <code>README.md</code> structure (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/8064">#8064</a>)</li>
<li>chore: migrate Python type checking to ty (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/8002">#8002</a>)</li>
<li>chore(deps): bump the minor-and-patch group in
/libs/checkpoint-postgres with 7 updates (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/7965">#7965</a>)</li>
<li>release(checkpoint): 4.1.1 (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/7890">#7890</a>)</li>
<li>chore(deps): bump idna from 3.11 to 3.15 in
/libs/checkpoint-postgres (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/7861">#7861</a>)</li>
<li>chore(deps): bump langsmith from 0.7.31 to 0.8.0 in
/libs/checkpoint-postgres (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/7785">#7785</a>)</li>
</ul>
<h2>langgraph-checkpoint-sqlite==3.1.1</h2>
<p>Changes since checkpointsqlite==3.1.0</p>
<ul>
<li>release(checkpoint-sqlite): 3.1.1 (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/8481">#8481</a>)</li>
<li>fix(checkpoint-postgres,checkpoint-sqlite): scope namespace matching
to segment boundaries (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/8478">#8478</a>)</li>
<li>chore(deps): bump the minor-and-patch group in
/libs/checkpoint-sqlite with 4 updates (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/8249">#8249</a>)</li>
<li>chore(deps): bump langsmith from 0.8.0 to 0.8.18 in
/libs/checkpoint-sqlite (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/8177">#8177</a>)</li>
<li>docs: standardize package <code>README.md</code> structure (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/8064">#8064</a>)</li>
<li>chore: migrate Python type checking to ty (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/8002">#8002</a>)</li>
<li>chore(deps): bump the minor-and-patch group in
/libs/checkpoint-sqlite with 3 updates (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/7961">#7961</a>)</li>
<li>release(checkpoint): 4.1.1 (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/7890">#7890</a>)</li>
<li>chore(deps): bump langsmith from 0.7.31 to 0.8.0 in
/libs/checkpoint-sqlite (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/7786">#7786</a>)</li>
<li>chore(deps): bump idna from 3.11 to 3.15 in /libs/checkpoint-sqlite
(<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/7862">#7862</a>)</li>
</ul>
<h2>langgraph-checkpoint-postgres==3.1.0</h2>
<p>Changes since checkpointpostgres==3.1.0a4</p>
<ul>
<li>release: bump alpha packages to official versions (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/7775">#7775</a>)</li>
<li>chore(deps): bump urllib3 from 2.6.3 to 2.7.0 in
/libs/checkpoint-postgres (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/7761">#7761</a>)</li>
<li>chore(deps): bump langchain-core from 1.3.2 to 1.3.3 in
/libs/checkpoint-postgres (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/7754">#7754</a>)</li>
<li>fix(checkpoint-postgres): add column aliases to seed-blob branch of
delta stage-2 UNION ALL (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/7728">#7728</a>)</li>
</ul>
<h2>langgraph-checkpoint-sqlite==3.1.0</h2>
<p>Changes since checkpointsqlite==3.1.0a1</p>
<ul>
<li>release: bump alpha packages to official versions (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/7775">#7775</a>)</li>
<li>chore(deps): bump urllib3 from 2.6.3 to 2.7.0 in
/libs/checkpoint-sqlite (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/7760">#7760</a>)</li>
<li>chore(deps): bump langchain-core from 1.2.28 to 1.3.3 in
/libs/checkpoint-sqlite (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/7751">#7751</a>)</li>
<li>chore: remove keepset helper (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/7745">#7745</a>)</li>
<li>chore(langgraph): add guide/conformance for delta channel
checkpointer (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/7736">#7736</a>)</li>
</ul>
<h2>langgraph-checkpoint-postgres==3.1.0a4</h2>
<p>Changes since checkpointpostgres==3.1.0a3</p>
<ul>
<li>release: alpha bump (a4) for langgraph, checkpoint,
checkpoint-postgres (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/7701">#7701</a>)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/langchain-ai/langgraph/commit/b2926a0ff9589c28c7e01fe7cdbb337b86d5a4b4"><code>b2926a0</code></a>
release(checkpoint-sqlite): 3.1.1 (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/8481">#8481</a>)</li>
<li><a
href="https://github.com/langchain-ai/langgraph/commit/fcdf520938469c8e0992ca2075d6a9582c33260f"><code>fcdf520</code></a>
release(checkpoint-postgres): 3.1.1 (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/8480">#8480</a>)</li>
<li><a
href="https://github.com/langchain-ai/langgraph/commit/66ebe1a0da921e73f0f9f879ba105d314c079f7c"><code>66ebe1a</code></a>
fix(checkpoint-postgres,checkpoint-sqlite): scope namespace matching to
segme...</li>
<li><a
href="https://github.com/langchain-ai/langgraph/commit/41341457342327166d72fc11952ab28fb61ec0bf"><code>4134145</code></a>
release(langgraph): 1.2.10 (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/8462">#8462</a>)</li>
<li><a
href="https://github.com/langchain-ai/langgraph/commit/30c4d58db86455128e42ddec96b1ba53c553ba22"><code>30c4d58</code></a>
chore(deps): bump jupyterlab from 4.5.9 to 4.5.10 in /libs/langgraph (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/8440">#8440</a>)</li>
<li><a
href="https://github.com/langchain-ai/langgraph/commit/1f2f88b2b74aa4e26c6a8be877dcb95bad2cef48"><code>1f2f88b</code></a>
chore(deps): bump js-yaml from 4.2.0 to 4.3.0 in
/libs/cli/js-monorepo-exampl...</li>
<li><a
href="https://github.com/langchain-ai/langgraph/commit/270820363d56cf8a8e594d2d3b19d543230337fb"><code>2708203</code></a>
chore(deps): bump setuptools from 82.0.1 to 83.0.0 in /libs/cli (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/8434">#8434</a>)</li>
<li><a
href="https://github.com/langchain-ai/langgraph/commit/9f1e40bfeea28dee2155b4a57f5babd0a53534cf"><code>9f1e40b</code></a>
chore(deps): bump setuptools from 80.9.0 to 83.0.0 in /libs/langgraph
(<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/8435">#8435</a>)</li>
<li><a
href="https://github.com/langchain-ai/langgraph/commit/1e1ca88dad9c7e708263257fa9fc27a3fbdfff68"><code>1e1ca88</code></a>
feat(langgraph): type v3 stream_events return and native projections (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/8389">#8389</a>)</li>
<li><a
href="https://github.com/langchain-ai/langgraph/commit/31f90df3e6b0268fa77fd2d118a917d420b84a68"><code>31f90df</code></a>
revert(langgraph): delete TracePolicy (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/8403">#8403</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/langchain-ai/langgraph/compare/checkpointpostgres==3.0.5...checkpointsqlite==3.1.1">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=langgraph-checkpoint-postgres&package-manager=uv&previous-version=3.0.5&new-version=3.1.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

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

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

---

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

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/langchain-ai/langgraph/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-20 07:02:48 +00:00
Connor BraaandGitHub 837212b969 release(sdk-py): 0.4.3 (#8657)
## Summary

Release `langgraph-sdk` 0.4.3, including `DecryptResult` support.

## Test plan

- `make format`
- `make lint`
- `make test`
2026-08-19 10:59:36 -07:00
Connor BraaandGitHub 70918557ca feat(sdk-py): add decrypt replacement result (#8598)
## Summary

- add `DecryptResult` with plaintext and optional replacement ciphertext
- keep raw decrypt return values backward compatible
- export the result from `langgraph_sdk`

## Merge order

Independent of langgraph-api#3884. Merge and release this SDK change
before LSD-1489 consumes the result in langgraph-api.

## Test plan

- `make format`
- `make lint`
- `make test`
2026-08-19 10:45:55 -07:00
1e44bda48f fix(langgraph): detect subgraphs from bytecode instead of source (#8569)
Fixes langchain-ai/langgraph#8559

`find_subgraph_pregel` runs once per node at build time and recovers
each node function's reachable values with `inspect.getsource` +
`ast.parse`; langchain-ai/langgraph#8559 measures that source parsing at
80% of `StateGraph.compile()`. This replaces it with a `dis` walk over
`func.__code__`, which reads the same information already in memory.

Closure cells and the globals named in `co_names` supply the values
directly, and a walk over the instruction stream recovers the attribute
paths the function actually takes. The closure alone cannot express
those: a captured `holder` whose graph lives at `holder.graph` is
reachable only if something records that `graph` is loaded off `holder`.

Both implementations over-declare — a node can reference a graph it
never invokes — and this one over-declares a different set. It no longer
reports a graph named only along an attribute path in code the compiler
removed; such a path cannot execute, so that entry was always a phantom.
Nothing that can actually run stopped being detected.
`find_subgraph_pregel` returns the first `PregelProtocol` it finds, so
the remaining extra candidates only widen detection.

## Release note

Subgraph auto-detection now reads node functions' bytecode instead of
parsing their source. Graph builds with many function-backed nodes are
substantially faster, and subgraphs are now detected inside functions
with no retrievable source — defined in a REPL or notebook cell, or via
`exec` — where detection previously failed silently and returned
nothing.

## Performance

Against
[langgraph-build-bench](<https://github.com/soarez/langgraph-build-bench>)
— 713 nodes, 500 state fields, 264 tools; CPython 3.12.8, Apple silicon:

```
before   build 1412 ms   subgraph detection 1132 ms  (80%)
after    build  286 ms   subgraph detection    4 ms   (2%)
```

**4.9x on total build, \~266x on detection.** The bench prints whole ms;
at full precision detection is 1139 ms -> 4.23 ms.

Co-authored-by: Elior Nataf Lackritz <elior.nataflackritz@langchain.dev>
2026-08-18 12:04:28 -07:00
20 changed files with 739 additions and 402 deletions
+5
View File
@@ -77,6 +77,10 @@ __pypackages__/
# Environments
.env
.envrc
*.crt
*.key
*.pem
credentials.json
.venv
.venvs
env/
@@ -98,6 +102,7 @@ dmypy.json
.vercel
.turbo
node_modules/
.editorconfig
.scratch
.worktrees/
+8
View File
@@ -2,6 +2,14 @@
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:
- `make format` run code formatters
+8
View File
@@ -2,6 +2,14 @@
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:
- `make format` run code formatters
+9 -82
View File
@@ -1162,47 +1162,7 @@ def _build_runtime_env_vars(config: Config) -> list[str]:
return env_vars
# npm runs all of these as part of an install, before the source would be copied.
_NODE_INSTALL_HOOKS = (
"preinstall",
"install",
"postinstall",
"prepublish",
"preprepare",
"prepare",
"postprepare",
)
def _splittable_node_manifests(
project_dir: pathlib.Path, lockfile: str | None
) -> list[str] | None:
"""Return manifests to copy before installing, or None if unsafe to split.
A lockfile is required: without one the install resolves versions at build
time, so a cached layer could pin an older resolution than a clean build.
"""
if lockfile is None:
return None
manifest = project_dir / "package.json"
try:
if not manifest.is_file():
return None
with open(manifest) as f:
package_json = json.load(f)
except (OSError, ValueError):
return None
if not isinstance(package_json, dict):
return None
scripts = package_json.get("scripts") or {}
if any(hook in scripts for hook in _NODE_INSTALL_HOOKS):
return None
return ["package.json", lockfile]
def _get_node_pm_install_cmd(project_dir: pathlib.Path) -> tuple[str, str | None]:
"""Return the install command and the lockfile it was chosen from."""
def _get_node_pm_install_cmd(project_dir: pathlib.Path) -> str:
def test_file(file_name):
full_path = project_dir / file_name
try:
@@ -1241,19 +1201,13 @@ def _get_node_pm_install_cmd(project_dir: pathlib.Path) -> tuple[str, str | None
if yarn:
install_cmd = "yarn install --frozen-lockfile"
lockfile = "yarn.lock"
elif pnpm:
install_cmd = "pnpm i --frozen-lockfile"
lockfile = "pnpm-lock.yaml"
elif npm:
install_cmd = "npm ci"
lockfile = "package-lock.json"
elif bun:
install_cmd = "bun i"
lockfile = "bun.lockb"
else:
# No lockfile, so the install resolves versions at build time.
lockfile = None
pkg_manager_name = get_pkg_manager_name()
if pkg_manager_name == "yarn":
@@ -1265,7 +1219,7 @@ def _get_node_pm_install_cmd(project_dir: pathlib.Path) -> tuple[str, str | None
else:
install_cmd = "npm i"
return install_cmd, lockfile
return install_cmd
semver_pattern = re.compile(r":(\d+(?:\.\d+)?(?:\.\d+)?)(?:-|$)")
@@ -1469,7 +1423,7 @@ ADD {relpath} /deps/{name}
"# -- Installing JS dependencies --",
f"ENV NODE_VERSION={config.get('node_version') or DEFAULT_NODE_VERSION}",
f"WORKDIR {local_deps.working_dir}",
f"RUN {_get_node_pm_install_cmd(config_path.parent)[0]} && tsx /api/langgraph_api/js/build.mts",
f"RUN {_get_node_pm_install_cmd(config_path.parent)} && tsx /api/langgraph_api/js/build.mts",
"# -- End of JS dependencies install --",
]
)
@@ -1538,9 +1492,7 @@ def node_config_to_docker(
install_root = (
pathlib.Path(build_context).resolve() if build_context else config_path.parent
)
detected_cmd, detected_lockfile = _get_node_pm_install_cmd(install_root)
install_cmd = install_command or detected_cmd
relative_workdir = ""
install_cmd = install_command or _get_node_pm_install_cmd(install_root)
if build_context:
relative_workdir = _calculate_relative_workdir(config_path, build_context)
container_name = pathlib.Path(build_context).name
@@ -1578,41 +1530,16 @@ def node_config_to_docker(
else:
build_workdir = faux_path
source_root = faux_path if not build_context else container_root
# Excluded: a custom install command may read files we have not copied yet,
# and a nested config means workspace manifests the root copy would miss.
manifests = (
_splittable_node_manifests(install_root, detected_lockfile)
if install_command is None and not relative_workdir
else None
)
if manifests:
add_steps = [
*(f"ADD {name} {source_root}/{name}" for name in manifests),
"",
f"WORKDIR {install_workdir}",
"",
install_step,
"",
f"ADD . {source_root}",
]
else:
add_steps = [
f"ADD . {source_root}",
"",
f"WORKDIR {install_workdir}",
"",
install_step,
]
docker_file_contents = [
f"FROM {image_str}",
"",
os.linesep.join(config["dockerfile_lines"]),
"",
*add_steps,
f"ADD . {faux_path if not build_context else container_root}",
"",
f"WORKDIR {install_workdir}",
"",
install_step,
"",
os.linesep.join(env_vars),
"",
+17 -1
View File
@@ -970,6 +970,22 @@ def python_config_to_docker_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(
"RUN",
@@ -1019,7 +1035,7 @@ def python_config_to_docker_uv_lock(
docker_plan.add_instruction("WORKDIR", plan.working_dir)
docker_plan.add_instruction(
"RUN",
f"{_get_node_pm_install_cmd(plan.target_root)[0]} && "
f"{_get_node_pm_install_cmd(plan.target_root)} && "
"tsx /api/langgraph_api/js/build.mts",
)
docker_plan.add_raw("# -- End of JS dependencies install --")
+13 -140
View File
@@ -1403,6 +1403,19 @@ def test_config_to_docker_uv_lock():
"COPY --from=uv-workspace-root uv.lock /tmp/uv_export/project/uv.lock"
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 (
@@ -3424,143 +3437,3 @@ class TestHasDisallowedBuildCommandContent:
)
def test_valid_commands_allowed(self, cmd: str) -> None:
assert not has_disallowed_build_command_content(cmd)
class TestNodeDependencyLayerOrdering:
"""Dependency manifests are copied before source so the install layer caches.
Without this the first source change invalidates the install, and a JS
deployment reinstalls every dependency on every push.
"""
def _project(
self,
tmp_path: pathlib.Path,
*,
lockfile: str | None,
scripts: dict[str, str] | None = None,
) -> pathlib.Path:
package_json: dict = {"name": "agent"}
if scripts:
package_json["scripts"] = scripts
(tmp_path / "package.json").write_text(json.dumps(package_json))
if lockfile:
(tmp_path / lockfile).write_text("")
(tmp_path / "graphs").mkdir()
(tmp_path / "graphs" / "agent.js").write_text("")
config_path = tmp_path / "langgraph.json"
config_path.write_text("{}")
return config_path
def _dockerfile(self, config_path: pathlib.Path, **kwargs) -> str:
actual, _ = config_to_docker(
config_path,
validate_config(
{"node_version": "20", "graphs": {"agent": "./graphs/agent.js:graph"}}
),
base_image="langchain/langgraphjs-api",
**kwargs,
)
return clean_empty_lines(actual)
def test_manifests_copied_before_install(self, tmp_path: pathlib.Path) -> None:
config_path = self._project(tmp_path, lockfile="package-lock.json")
lines = self._dockerfile(config_path).splitlines()
add_manifest = lines.index(
f"ADD package.json /deps/{tmp_path.name}/package.json"
)
add_lock = lines.index(
f"ADD package-lock.json /deps/{tmp_path.name}/package-lock.json"
)
install = lines.index("RUN npm ci")
add_source = lines.index(f"ADD . /deps/{tmp_path.name}")
assert add_manifest < install
assert add_lock < install
assert install < add_source
def test_lockfile_choice_follows_package_manager(
self, tmp_path: pathlib.Path
) -> None:
config_path = self._project(tmp_path, lockfile="pnpm-lock.yaml")
dockerfile = self._dockerfile(config_path)
assert f"ADD pnpm-lock.yaml /deps/{tmp_path.name}/pnpm-lock.yaml" in dockerfile
assert "package-lock.json" not in dockerfile
def test_no_lockfile_keeps_source_first(self, tmp_path: pathlib.Path) -> None:
# No lockfile means the install resolves at build time, so caching it is wrong.
config_path = self._project(tmp_path, lockfile=None)
lines = self._dockerfile(config_path).splitlines()
assert lines.index(f"ADD . /deps/{tmp_path.name}") < lines.index("RUN npm i")
assert not any(line.startswith("ADD package.json") for line in lines)
def test_nested_config_keeps_source_first(self, tmp_path: pathlib.Path) -> None:
# A workspace keeps manifests in subdirectories the root copy would miss.
root = tmp_path / "repo"
root.mkdir()
(root / "package.json").write_text(json.dumps({"name": "root"}))
(root / "package-lock.json").write_text("")
pkg = root / "packages" / "agent"
pkg.mkdir(parents=True)
(pkg / "graphs").mkdir()
(pkg / "graphs" / "agent.js").write_text("")
config_path = pkg / "langgraph.json"
config_path.write_text("{}")
lines = self._dockerfile(config_path, build_context=str(root)).splitlines()
assert lines.index("ADD . /deps/repo") < lines.index("RUN npm ci")
assert not any(line.startswith("ADD package.json") for line in lines)
def test_custom_install_command_keeps_source_first(
self, tmp_path: pathlib.Path
) -> None:
# A custom command may read files the manifest copy would not include.
config_path = self._project(tmp_path, lockfile="package-lock.json")
lines = self._dockerfile(
config_path,
install_command="npm run bootstrap",
build_context=str(tmp_path),
).splitlines()
assert lines.index(f"ADD . /deps/{tmp_path.name}") < lines.index(
"RUN npm run bootstrap"
)
@pytest.mark.parametrize(
"hook",
[
"preinstall",
"install",
"postinstall",
"prepublish",
"preprepare",
"prepare",
"postprepare",
],
)
def test_install_hook_keeps_source_first(
self, tmp_path: pathlib.Path, hook: str
) -> None:
# A hook referencing a project file would hit ENOENT: source is not copied yet.
config_path = self._project(
tmp_path,
lockfile="package-lock.json",
scripts={hook: "node scripts/setup.js"},
)
lines = self._dockerfile(config_path).splitlines()
assert lines.index(f"ADD . /deps/{tmp_path.name}") < lines.index("RUN npm ci")
assert not any(line.startswith("ADD package.json") for line in lines)
def test_only_the_chosen_lockfile_is_copied(self, tmp_path: pathlib.Path) -> None:
# Install picks yarn, so copying the npm lockfile would bust the cache for nothing.
config_path = self._project(tmp_path, lockfile="yarn.lock")
(tmp_path / "package-lock.json").write_text("")
dockerfile = self._dockerfile(config_path)
assert f"ADD yarn.lock /deps/{tmp_path.name}/yarn.lock" in dockerfile
assert "ADD package-lock.json" not in dockerfile
+6 -6
View File
@@ -266,20 +266,20 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
version = "4.0.1"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "langchain-core" },
{ 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 = [
{ 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]]
name = "langgraph-checkpoint-postgres"
version = "3.0.5"
version = "3.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "langgraph-checkpoint" },
@@ -287,9 +287,9 @@ dependencies = [
{ name = "psycopg" },
{ 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 = [
{ 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]]
+71 -141
View File
@@ -1,11 +1,10 @@
from __future__ import annotations
import ast
import inspect
import dis
import re
import textwrap
from collections.abc import Callable, Sequence
from functools import partial
from types import CodeType, FunctionType
from typing import Any
from langchain_core.runnables import (
@@ -17,7 +16,6 @@ from langchain_core.runnables import (
from langchain_core.runnables.base import RunnableBindingBase
from langchain_core.runnables.config import run_in_executor
from langgraph.checkpoint.base import ChannelVersions
from typing_extensions import override
from langgraph._internal._runnable import RunnableCallable, RunnableSeq
from langgraph._internal._timeout import sync_timeout_unsupported
@@ -137,155 +135,87 @@ def validate_timeout_supported(runnable: Runnable, *, name: str) -> None:
raise sync_timeout_unsupported(name)
# Values treated as dead ends when deciding whether to walk a function's
# bytecode. A container can hold a graph, but `find_subgraph_pregel` does not
# look inside one, so skipping it costs nothing while that holds. Matched by
# exact type, since a subclass of a builtin can carry attributes.
_LEAF_TYPES = frozenset(
{
int,
float,
complex,
bool,
str,
bytes,
bytearray,
list,
tuple,
dict,
set,
frozenset,
type(None),
}
)
def get_function_nonlocals(func: Callable) -> list[Any]:
"""Get the nonlocal variables accessed by a function.
"""Get the values a function reaches from outside its own scope.
Args:
func: The function to check.
Returns:
List[Any]: The nonlocal variables accessed by the function.
Every captured cell value, the globals the function names, and each
value along an attribute path it loads. Over-approximates: a value can
come back without the function reaching it at runtime.
"""
try:
code = inspect.getsource(func)
tree = ast.parse(textwrap.dedent(code))
visitor = FunctionNonLocals()
visitor.visit(tree)
values: list[Any] = []
closure = (
inspect.getclosurevars(func.__wrapped__)
if hasattr(func, "__wrapped__") and callable(func.__wrapped__)
else inspect.getclosurevars(func)
)
candidates = {**closure.globals, **closure.nonlocals}
for k, v in candidates.items():
if k in visitor.nonlocals:
values.append(v)
for kk in visitor.nonlocals:
if "." in kk and kk.startswith(k):
vv = v
for part in kk.split(".")[1:]:
if vv is None:
break
else:
try:
vv = getattr(vv, part)
except AttributeError:
break
else:
values.append(vv)
except (SyntaxError, TypeError, OSError, SystemError):
func = getattr(func, "__func__", func) # bound method -> function
wrapped = getattr(func, "__wrapped__", None)
if callable(wrapped):
func = getattr(wrapped, "__func__", wrapped)
if not isinstance(func, FunctionType):
return []
code = func.__code__
cells: dict[str, Any] = {}
for name, cell in zip(code.co_freevars, func.__closure__ or ()):
try:
cells[name] = cell.cell_contents
except ValueError:
continue # empty cell: a recursive def not yet bound
# Every captured value counts, referenced or not: over-declaring costs an
# introspection entry, under-declaring drops the subgraph's checkpoints and
# stream events. Checking each cell against the bytecode would cost more and
# only trade the cheap error for the expensive one.
values: list[Any] = list(cells.values())
global_ns = func.__globals__
globals_ = {name: global_ns[name] for name in code.co_names if name in global_ns}
if all(type(v) in _LEAF_TYPES for v in (*cells.values(), *globals_.values())):
return values
# Nested code objects hold the references made by inner defs, lambdas and
# comprehensions, which resolve against the namespaces gathered above.
codes = [code]
for c in codes:
codes.extend(k for k in c.co_consts if isinstance(k, CodeType))
value: Any = None
for instruction in dis.get_instructions(c):
opname = instruction.opname
if opname == "LOAD_GLOBAL":
value = globals_.get(instruction.argval)
elif opname == "LOAD_DEREF":
value = cells.get(instruction.argval)
elif opname in ("LOAD_ATTR", "LOAD_METHOD"):
value = getattr(value, instruction.argval, None)
else:
value = None # anything else ends the chain: `a, b.c` is not `a.c`
continue
if value is not None:
values.append(value)
return values
class FunctionNonLocals(ast.NodeVisitor):
"""Get the nonlocal variables accessed of a function."""
def __init__(self) -> None:
self.nonlocals: set[str] = set()
@override
def visit_FunctionDef(self, node: ast.FunctionDef) -> Any:
"""Visit a function definition.
Args:
node: The node to visit.
Returns:
Any: The result of the visit.
"""
visitor = NonLocals()
visitor.visit(node)
self.nonlocals.update(visitor.loads - visitor.stores)
@override
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> Any:
"""Visit an async function definition.
Args:
node: The node to visit.
Returns:
Any: The result of the visit.
"""
visitor = NonLocals()
visitor.visit(node)
self.nonlocals.update(visitor.loads - visitor.stores)
@override
def visit_Lambda(self, node: ast.Lambda) -> Any:
"""Visit a lambda function.
Args:
node: The node to visit.
Returns:
Any: The result of the visit.
"""
visitor = NonLocals()
visitor.visit(node)
self.nonlocals.update(visitor.loads - visitor.stores)
class NonLocals(ast.NodeVisitor):
"""Get nonlocal variables accessed."""
def __init__(self) -> None:
self.loads: set[str] = set()
self.stores: set[str] = set()
@override
def visit_Name(self, node: ast.Name) -> Any:
"""Visit a name node.
Args:
node: The node to visit.
Returns:
Any: The result of the visit.
"""
if isinstance(node.ctx, ast.Load):
self.loads.add(node.id)
elif isinstance(node.ctx, ast.Store):
self.stores.add(node.id)
@override
def visit_Attribute(self, node: ast.Attribute) -> Any:
"""Visit an attribute node.
Args:
node: The node to visit.
Returns:
Any: The result of the visit.
"""
if isinstance(node.ctx, ast.Load):
parent = node.value
attr_expr = node.attr
while isinstance(parent, ast.Attribute):
attr_expr = parent.attr + "." + attr_expr
parent = parent.value
if isinstance(parent, ast.Name):
self.loads.add(parent.id + "." + attr_expr)
self.loads.discard(parent.id)
elif isinstance(parent, ast.Call):
if isinstance(parent.func, ast.Name):
self.loads.add(parent.func.id)
else:
parent = parent.func
attr_expr = ""
while isinstance(parent, ast.Attribute):
if attr_expr:
attr_expr = parent.attr + "." + attr_expr
else:
attr_expr = parent.attr
parent = parent.value
if isinstance(parent, ast.Name):
self.loads.add(parent.id + "." + attr_expr)
def is_xxh3_128_hexdigest(value: str) -> bool:
"""Check if the given string matches the format of xxh3_128_hexdigest."""
return bool(re.fullmatch(r"[0-9a-f]{32}", value))
@@ -0,0 +1,286 @@
"""Tests for subgraph auto-detection (`pregel/_utils.py`).
Detection failing is silent the graph still runs, only introspection goes
quiet so every shape a node can hold a graph in is pinned here. The expected
values are what the source-parsing implementation this replaced produced for
the same shapes, except for `sourceless`, whose source it could not read,
`empty_closure_cell`, on which it raised, and `unreachable_attribute_chain`,
where it reported a graph that dropped code could never invoke.
"""
import functools
import operator
from typing import Annotated, Any
import pytest
from typing_extensions import TypedDict
from langgraph.graph import END, START, StateGraph
from langgraph.pregel._utils import get_function_nonlocals
class State(TypedDict):
log: Annotated[list, operator.add]
def _leaf(tag: str) -> Any:
"""Return a compiled graph that reports itself as `tag`."""
builder = StateGraph(State)
builder.add_node(tag, lambda s: {"log": [tag]})
builder.add_edge(START, tag)
builder.add_edge(tag, END)
compiled = builder.compile()
compiled.name = tag
return compiled
def _detect(node: Any) -> str | None:
"""Return the name of the subgraph detected for `node`, or None."""
builder = StateGraph(State)
builder.add_node("n", node)
builder.add_edge(START, "n")
builder.add_edge("n", END)
subgraphs = builder.compile().nodes["n"].subgraphs
return getattr(subgraphs[0], "name", "?") if subgraphs else None
class _Box:
def __init__(self, payload: Any) -> None:
self.payload = payload
class _ListSubclass(list):
pass
class _MethodHolder:
def __init__(self) -> None:
self.graph = _leaf("via_self")
def as_node(self, state: State) -> Any:
return self.graph.invoke(state)
MODULE_GRAPH = _leaf("module_global")
CHAIN = _Box(_Box(_leaf("attr_chain")))
GRAPH_IN_PLAIN_LIST = [_leaf("in_list")]
METHOD_HOLDER = _MethodHolder()
def closure_capture() -> Any:
sub = _leaf("closure")
def node(state: State) -> Any:
return sub.invoke(state)
return node
def module_global() -> Any:
def node(state: State) -> Any:
return MODULE_GRAPH.invoke(state)
return node
def attribute_chain() -> Any:
def node(state: State) -> Any:
return CHAIN.payload.payload.invoke(state)
return node
def nested_def_captured_attribute() -> Any:
"""A chain on a captured holder, named only inside a nested code object.
The captured value is the holder, not the graph, so the chain itself has to
be recovered from the nested scope.
"""
holder = _Box(_leaf("nested_captured"))
def node(state: State) -> Any:
def inner() -> Any:
return holder.payload.invoke(state)
return inner()
return node
def unreachable_branch() -> Any:
"""A captured graph referenced only from code the compiler removes."""
sub = _leaf("unreachable")
def node(state: State) -> Any:
if False:
sub.invoke(state)
return {"log": []}
return node
def unreachable_attribute_chain() -> Any:
"""A graph named only along an attribute path the compiler dropped.
The closure keeps `holder`, but the `.payload` load is gone. The source
parser reported this one; dropped code cannot invoke anything, so that was
a phantom rather than a detection.
"""
holder = _Box(_leaf("unreachable_attr"))
def node(state: State) -> Any:
if False:
holder.payload.invoke(state)
return {"log": []}
return node
def wrapper_referencing_nothing() -> Any:
"""A wrapper whose own scope holds nothing, so only `__wrapped__` leads on.
`functools.wraps` would leave the wrapper closing over the inner function;
setting the attribute by hand does not.
"""
sub = _leaf("via_wrapped")
def inner(state: State) -> Any:
return sub.invoke(state)
def wrapper(state: State) -> Any:
return {"log": []}
wrapper.__wrapped__ = inner
return wrapper
def captured_list_subclass() -> Any:
"""A `list` subclass is not a leaf: it can carry a graph as an attribute."""
holder = _ListSubclass()
holder.payload = _leaf("list_subclass")
def node(state: State) -> Any:
return holder.payload.invoke(state)
return node
def empty_closure_cell() -> Any:
"""An unassigned closure variable leaves a cell that cannot be read."""
sub = _leaf("beside_empty_cell")
def node(state: State) -> Any:
return unassigned, sub.invoke(state)
return node
unassigned = 1 # never runs, so the cell it creates is never filled
def sourceless() -> Any:
"""A node compiled without a source file, which `getsource` could not read."""
namespace: dict[str, Any] = {"SOURCELESS": _leaf("sourceless")}
exec(
compile(
"def node(state):\n return SOURCELESS.invoke(state)", "<test>", "exec"
),
namespace,
)
return namespace["node"]
async def _async_node(state: State) -> Any:
return await MODULE_GRAPH.ainvoke(state)
def async_node() -> Any:
return _async_node
def no_subgraph() -> Any:
"""Nothing but leaf values in reach, so the bytecode walk is skipped."""
def node(state: State) -> Any:
return {"log": [len("abc") + 1]}
return node
def recombined_names() -> Any:
"""Loads `CHAIN.payload` and `local.payload`, never `CHAIN.payload.payload`."""
def node(state: State) -> Any:
local = _Box("not a graph")
return {"log": [CHAIN.payload, local.payload]}
return node
def broken_attribute_chain() -> Any:
holder = _Box("a string, so `.payload.missing` cannot resolve")
def node(state: State) -> Any:
return holder.payload.missing.invoke(state)
return node
def nested_def_global() -> Any:
"""A global named only in a nested code object: out of reach, as before."""
def node(state: State) -> Any:
def inner() -> Any:
return MODULE_GRAPH.invoke(state)
return inner()
return node
def graph_in_plain_list() -> Any:
def node(state: State) -> Any:
return GRAPH_IN_PLAIN_LIST[0].invoke(state)
return node
def bound_method_self() -> Any:
return METHOD_HOLDER.as_node
@pytest.mark.parametrize(
("factory", "expected"),
[
(closure_capture, "closure"),
(module_global, "module_global"),
(attribute_chain, "attr_chain"),
(nested_def_captured_attribute, "nested_captured"),
(unreachable_branch, "unreachable"),
(wrapper_referencing_nothing, "via_wrapped"),
(captured_list_subclass, "list_subclass"),
(empty_closure_cell, "beside_empty_cell"),
(sourceless, "sourceless"),
(async_node, "module_global"),
# Shapes no reference chain reaches: a subscript, an instance attribute
# of `self`, a global named only in a nested scope, and an attribute
# path the compiler dropped.
(no_subgraph, None),
(recombined_names, None),
(broken_attribute_chain, None),
(nested_def_global, None),
(graph_in_plain_list, None),
(bound_method_self, None),
(unreachable_attribute_chain, None),
],
ids=lambda value: value.__name__ if callable(value) else str(value),
)
def test_subgraph_detection(factory: Any, expected: str | None) -> None:
assert _detect(factory()) == expected
@pytest.mark.parametrize(
"candidate",
[functools.partial(lambda state, extra: {"log": [extra]}, extra="x"), len],
ids=["partial", "builtin"],
)
def test_callables_without_a_code_object_are_handled(candidate: Any) -> None:
assert get_function_nonlocals(candidate) == []
+9
View File
@@ -39,6 +39,15 @@
- `client.threads.stream()` now accepts `transport="sse"` (default) or
`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
- The v3 streaming surface (`AsyncThreadStream`, `SyncThreadStream`, and all
+10 -3
View File
@@ -1,8 +1,15 @@
from langgraph_sdk.auth import Auth
from langgraph_sdk.client import get_client, get_sync_client
from langgraph_sdk.encryption import Encryption
from langgraph_sdk.encryption.types import EncryptionContext
from langgraph_sdk.encryption.types import DecryptResult, EncryptionContext
__version__ = "0.4.2"
__version__ = "0.4.4"
__all__ = ["Auth", "Encryption", "EncryptionContext", "get_client", "get_sync_client"]
__all__ = [
"Auth",
"DecryptResult",
"Encryption",
"EncryptionContext",
"get_client",
"get_sync_client",
]
+4 -1
View File
@@ -24,7 +24,7 @@ from langchain_core.language_models.chat_model_stream import AsyncChatModelStrea
from langchain_protocol import Event, SubscribeParams
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.decoders import (
DataDecoder,
@@ -172,6 +172,7 @@ class RunModule:
input: Any = None,
config: dict[str, Any] | None = None,
metadata: dict[str, Any] | None = None,
langsmith_tracing: LangSmithTracing | None = None,
) -> dict[str, Any]:
"""Send `run.start` to the server. Returns the result (`{"run_id": ...}`)."""
params: dict[str, Any] = {"assistant_id": self._owner.assistant_id}
@@ -181,6 +182,8 @@ class RunModule:
params["config"] = config
if metadata is not None:
params["metadata"] = metadata
if langsmith_tracing is not None:
params["langsmith_tracer"] = langsmith_tracing
loop = asyncio.get_running_loop()
gate: asyncio.Future[None] = loop.create_future()
self._owner._run_start_ready = gate
+4 -1
View File
@@ -23,7 +23,7 @@ from langchain_core.language_models.chat_model_stream import ChatModelStream
from langchain_protocol import Event, SubscribeParams
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 (
DataDecoder,
Decoder,
@@ -215,6 +215,7 @@ class SyncRunModule:
input: Any = None,
config: dict[str, Any] | None = None,
metadata: dict[str, Any] | None = None,
langsmith_tracing: LangSmithTracing | None = None,
) -> dict[str, Any]:
"""Send `run.start` to the server. Returns the result (`{"run_id": ...}`)."""
params: dict[str, Any] = {"assistant_id": self._owner.assistant_id}
@@ -224,6 +225,8 @@ class SyncRunModule:
params["config"] = config
if metadata is not None:
params["metadata"] = metadata
if langsmith_tracing is not None:
params["langsmith_tracer"] = langsmith_tracing
result = self._owner._send_command("run.start", params)
self._owner._run_seen = True
controller = self._owner._controller
+67 -16
View File
@@ -341,9 +341,15 @@ VUpdate = typing.TypeVar("VUpdate", covariant=True)
VRead = typing.TypeVar("VRead", covariant=True)
VDelete = typing.TypeVar("VDelete", 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.
"""
@@ -392,8 +398,8 @@ class _ResourceOn(typing.Generic[VCreate, VRead, VUpdate, VDelete, VSearch]):
def __call__(
self,
*,
resources: str | Sequence[str],
actions: str | Sequence[str] | None = None,
resources: str | Sequence[str] | None = None,
actions: ResourceActionT | Sequence[ResourceActionT] | None = None,
) -> Callable[
[_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,
*,
resources: str | Sequence[str] | None = None,
actions: str | Sequence[str] | None = None,
actions: ResourceActionT | Sequence[ResourceActionT] | None = None,
) -> (
_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]
| Callable[
@@ -416,24 +422,66 @@ class _ResourceOn(typing.Generic[VCreate, VRead, VUpdate, 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(
handler: _ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch],
) -> _ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]:
_validate_handler(handler)
return typing.cast(
"_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]",
_register_handler(self.auth, self.resource, "*", handler),
if resources is None:
resource_list = [self.resource]
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.
_ = resources, actions
if fn is not None:
return decorator(
typing.cast(
"_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]",
fn,
)
)
return decorator
@@ -444,6 +492,7 @@ class _AssistantsOn(
types.AssistantsUpdate,
types.AssistantsDelete,
types.AssistantsSearch,
_ResourceAction,
]
):
value = (
@@ -467,6 +516,7 @@ class _ThreadsOn(
types.ThreadsUpdate,
types.ThreadsDelete,
types.ThreadsSearch,
_ThreadAction,
]
):
value = (
@@ -502,6 +552,7 @@ class _CronsOn(
types.CronsUpdate,
types.CronsDelete,
types.CronsSearch,
_ResourceAction,
]
):
value = type[
@@ -18,6 +18,9 @@ import warnings
from langgraph_sdk.encryption import types
_BlobDecryptorT = typing.TypeVar("_BlobDecryptorT", bound=types.BlobDecryptor)
_JsonDecryptorT = typing.TypeVar("_JsonDecryptorT", bound=types.JsonDecryptor)
class LangGraphBetaWarning(UserWarning):
"""Warning for beta features in LangGraph SDK."""
@@ -141,7 +144,7 @@ class _DecryptDecorators:
def __init__(self, parent: Encryption):
self._parent = parent
def blob(self, fn: types.BlobDecryptor) -> types.BlobDecryptor:
def blob(self, fn: _BlobDecryptorT) -> _BlobDecryptorT:
"""Register a blob decryption handler.
The handler will be called to decrypt opaque data like checkpoint blobs.
@@ -149,7 +152,9 @@ class _DecryptDecorators:
Example:
```python
@encryption.decrypt.blob
async def decrypt_blob(ctx: EncryptionContext, blob: bytes) -> bytes:
async def decrypt_blob(
ctx: EncryptionContext, blob: bytes
) -> bytes | DecryptResult[bytes]:
# Decrypt the blob using your encryption service
return decrypted_blob
```
@@ -170,13 +175,15 @@ class _DecryptDecorators:
self._parent._blob_decryptor = fn
return fn
def json(self, fn: types.JsonDecryptor) -> types.JsonDecryptor:
def json(self, fn: _JsonDecryptorT) -> _JsonDecryptorT:
"""Register the JSON decryption handler.
Example:
```python
@encryption.decrypt.json
async def decrypt_json(ctx: EncryptionContext, data: dict) -> dict:
async def decrypt_json(
ctx: EncryptionContext, data: dict
) -> dict | DecryptResult[dict]:
# Decrypt the data
return decrypt_data(data)
```
@@ -369,7 +376,7 @@ class Encryption:
"""Reference to encryption type definitions.
Provides access to all type definitions used in the encryption system,
including EncryptionContext, BlobEncryptor, BlobDecryptor,
including EncryptionContext, DecryptResult, BlobEncryptor, BlobDecryptor,
JsonEncryptor, and JsonDecryptor.
"""
+30 -4
View File
@@ -9,10 +9,30 @@ from __future__ import annotations
import typing
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
Json = dict[str, typing.Any]
"""JSON-serializable dictionary type for structured data encryption."""
T = typing.TypeVar("T")
@dataclass(frozen=True, slots=True)
class DecryptResult(typing.Generic[T]):
"""Decrypted data and optional replacement ciphertext.
Return this from a decrypt handler when encrypted data should be replaced,
such as after rotating its encryption key. Returning plaintext directly
remains supported when no replacement is needed.
Attributes:
plaintext: Decrypted data returned to the caller
replacement: New encrypted data to persist in place of the input
"""
plaintext: T
replacement: T | None = None
class EncryptionContext:
"""Context passed to encryption/decryption handlers.
@@ -57,7 +77,9 @@ Returns:
Awaitable that resolves to encrypted bytes
"""
BlobDecryptor = Callable[[EncryptionContext, bytes], Awaitable[bytes]]
BlobDecryptor = Callable[
[EncryptionContext, bytes], Awaitable[bytes | DecryptResult[bytes]]
]
"""Handler for decrypting opaque blob data like checkpoints.
Note: Must be an async function. Decryption typically involves I/O operations
@@ -68,7 +90,8 @@ Args:
blob: The encrypted bytes to decrypt
Returns:
Awaitable that resolves to decrypted bytes
Awaitable that resolves to decrypted bytes, or a DecryptResult containing
decrypted bytes and replacement ciphertext
"""
JsonEncryptor = Callable[[EncryptionContext, Json], Awaitable[Json]]
@@ -101,7 +124,9 @@ Returns:
Awaitable that resolves to encrypted JSON dictionary
"""
JsonDecryptor = Callable[[EncryptionContext, Json], Awaitable[Json]]
JsonDecryptor = Callable[
[EncryptionContext, Json], Awaitable[Json | DecryptResult[Json]]
]
"""Handler for decrypting structured JSON data.
Note: Must be an async function. Decryption typically involves I/O operations
@@ -115,7 +140,8 @@ Args:
data: The encrypted JSON dictionary
Returns:
Awaitable that resolves to decrypted JSON dictionary
Awaitable that resolves to a decrypted JSON dictionary, or a DecryptResult
containing decrypted JSON and replacement ciphertext
"""
if typing.TYPE_CHECKING:
@@ -426,11 +426,17 @@ def test_sync_run_start_sends_command():
with httpx.Client(transport=fake.transport, base_url="http://test") as raw:
threads = SyncThreadsClient(SyncHttpClient(raw))
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 fake.received_commands[0]["method"] == "run.start"
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():
@@ -287,7 +287,7 @@ async def test_command_ids_are_monotonic():
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()
transport = httpx.ASGITransport(app=fake.app)
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},
config={"recursion_limit": 5},
metadata={"trace": "abc"},
langsmith_tracing={
"project_name": "replica-project",
"example_id": "example-1",
},
)
params = fake.received_commands[0]["params"]
assert params["config"] == {"recursion_limit": 5}
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():
+132
View File
@@ -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]}
+32
View File
@@ -1,8 +1,40 @@
from collections.abc import Awaitable, Callable
import pytest
from langgraph_sdk import DecryptResult, EncryptionContext
from langgraph_sdk.encryption import DuplicateHandlerError, Encryption
def test_decrypt_result():
result = DecryptResult(plaintext=b"plain", replacement=b"rotated")
assert result.plaintext == b"plain"
assert result.replacement == b"rotated"
assert DecryptResult(plaintext={"plain": True}).replacement is None
def test_decrypt_decorators_preserve_return_types():
encryption = Encryption()
@encryption.decrypt.blob
async def blob_dec(_ctx: EncryptionContext, data: bytes) -> bytes:
return data
@encryption.decrypt.json
async def json_dec(
_ctx: EncryptionContext, data: dict[str, object]
) -> dict[str, object]:
return data
blob_handler: Callable[[EncryptionContext, bytes], Awaitable[bytes]] = blob_dec
json_handler: Callable[
[EncryptionContext, dict[str, object]], Awaitable[dict[str, object]]
] = json_dec
assert blob_handler is blob_dec
assert json_handler is json_dec
class TestHandlerValidation:
"""Test duplicate handler and signature validation."""