Skip to content

fix(code): keep server hook state out of task results - #5164

Merged
Mason Daugherty (mdrxy) merged 6 commits into
mainfrom
mdrxy/code/private-server-hook-state
Jul 30, 2026
Merged

fix(code): keep server hook state out of task results#5164
Mason Daugherty (mdrxy) merged 6 commits into
mainfrom
mdrxy/code/private-server-hook-state

Conversation

@mdrxy

@mdrxy Mason Daugherty (mdrxy) commented Jul 29, 2026

Copy link
Copy Markdown
Member

Parallel task completions no longer crash parent agent turns when server hooks are active.


ServerHooksMiddleware keeps two pieces of per-agent bookkeeping in graph state: a snapshot of PreToolUse verdicts keyed by tool-call id, and a Stop-hook continuation count. Both were declared as ordinary state fields, so a finished subagent copied them into the update it returned to its parent.

_after_model writes the verdict snapshot on every pass — including an empty dict when no hooks are configured — so every subagent emits that key regardless of hook setup. When two task calls completed in the same graph step, LangGraph received two writes for one single-value channel and raised InvalidUpdateError, failing the parent turn. With a single task there was no error, but the subagent's snapshot silently replaced the parent's.

Marking both fields PrivateStateAttr keeps them inside the agent that owns them. SubAgentMiddleware already filters private keys out of subagent results, and create_deep_agent derives that key set from each middleware's state_schema, so no extra wiring is required.

This does not change how hook decisions reach the code that enforces them. PrivateStateAttr is OmitFromSchema(input=True, output=True) — it removes the fields from the graph's input and output schemas only. The channels stay ordinary checkpointed LastValue channels, and every node still runs against the full state schema. A deny written in after_model still reaches wrap_tool_call, and the continuation count still survives interrupt/resume. PreToolUse allow, deny, and ask behavior is unchanged.

One consequence worth knowing: because input=True is also omitted, these keys can no longer be seeded through graph input. Nothing does that today, and update_state still writes them.

Tests

test_parallel_tasks_do_not_merge_subagent_server_hook_state runs two task calls that complete in one step, parametrized over both subagent shapes. The compiled case is a bare CompiledSubAgent that writes the keys directly, which exercises SubAgentMiddleware's explicit key strip — the only layer protecting that shape. The real case is a declarative subagent carrying its own ServerHooksMiddleware, matching how subagents are built in create_cli_agent; it reproduces the crash through the unconditional snapshot write. Without the fix the two cases fail on different keys (_hooks_stop_continuation_count and _hooks_pre_tool_outcomes). Both assert the tool messages come back intact and that neither key lands in the parent's checkpointed state, so swapping in a reducer instead of privacy would not satisfy them.

test_task_omits_private_server_hook_state_from_subagent_update covers the single-task case, which cannot trip InvalidUpdateError and so would otherwise regress silently. It is built through create_deep_agent so the private-key derivation is exercised rather than reimplemented.

test_pretool_deny_blocks_tool_through_real_graph drives a deny through a compiled graph and asserts the tool never executes, with and without a resume round trip. The other deny tests call the middleware hooks directly and copy state between them by hand, so none of them can detect an outcome that fails to cross the node boundary.

test_server_hook_state_fields_are_private asserts through the production detector, so it also catches the case where annotation resolution degrades and silently yields an empty key set.

Types and documentation

ServerHooksState now documents why the fields are private, that the channels remain checkpointed, and that the verdict dict is a per-turn snapshot rather than an accumulator — every _after_model replaces it wholesale, so stale tool-call ids cannot survive into a later turn. It also records that a future reducer on either field must be placed after PrivateStateAttr in the Annotated metadata: LangGraph inspects only the last metadata entry when detecting reducers, so one added before the marker is ignored without error.

_PreToolState becomes a discriminated union of a denied and a passed variant, making a denial without a reason unrepresentable at the three sites that construct one.

pre_tool_behavior becomes hook_decided_permission, returning a bool. It previously returned one of four values while both call sites only asked whether a hook had settled permission, leaving "hook expressed no opinion" and "no outcome recorded" indistinguishable to every caller.

@github-actions github-actions Bot added dcode Related to `deepagents-code` fix A bug fix (PATCH) internal User is a member of the `langchain-ai` GitHub organization size: S 50-199 LOC labels Jul 29, 2026
Follow-up to the `PrivateStateAttr` fix, addressing review findings.

Tests now cover the production trigger and wiring rather than approximating
them. The parallel-task test is parametrized over both subagent shapes: the
existing compiled runnable (which exercises `SubAgentMiddleware`'s explicit
strip) and a real subagent carrying its own `ServerHooksMiddleware`, which is
what actually crashed -- `_after_model` writes `_hooks_pre_tool_outcomes`
unconditionally, so any two parallel tasks collide even with no hooks
configured. Pre-fix, the two variants fail on different keys. The single-task
test is rebuilt on `create_deep_agent` so it exercises the private-key
derivation in `deepagents.graph` instead of passing `private_state_keys`
itself; it now also fails pre-fix. A new test drives a `deny` through a
compiled graph, with and without a resume round trip, since every other deny
test copies state between hooks by hand and so cannot detect a dropped
channel.

`ServerHooksState` documents why the fields are private, that the channels
remain checkpointed, that the outcomes dict is a per-turn snapshot rather
than an accumulator, and that a future reducer must follow the marker in the
`Annotated` metadata or LangGraph will ignore it.

`_PreToolState` becomes a discriminated union so a denial cannot be recorded
without a reason, and `pre_tool_behavior` becomes `hook_decided_permission`,
returning the boolean both call sites actually used instead of four states.

`private_state_field_names` no longer swallows every exception: unresolvable
annotations are logged and skipped per schema. Silently returning an empty
set would revert this fix with no diagnostic.
@mdrxy Mason Daugherty (mdrxy) changed the title fix(code): keep server hook state out of task results fix(code, deepagents): keep server hook state out of task results Jul 29, 2026
@github-actions github-actions Bot removed the size: S 50-199 LOC label Jul 29, 2026
@github-actions github-actions Bot changed the title fix(code, deepagents): keep server hook state out of task results fix(code,sdk): keep server hook state out of task results Jul 29, 2026
@github-actions github-actions Bot added the deepagents Related to the `deepagents` SDK / agent harness label Jul 29, 2026
`private_state_field_names` lives in `libs/deepagents`, so including it made
this a bump-worthy PR touching two managed components. release-please scopes
by changed path, so merging would cut a `deepagents` release for what is a
logging-only internal change. Split out per `.github/RELEASING.md`.
@mdrxy Mason Daugherty (mdrxy) changed the title fix(code,sdk): keep server hook state out of task results fix(code): keep server hook state out of task results Jul 29, 2026
Mason Daugherty (mdrxy) added a commit that referenced this pull request Jul 29, 2026
…5166)

`private_state_field_names` resolved annotations under a blanket
`contextlib.suppress(Exception)`.

A schema whose `PrivateStateAttr` annotation references a
`TYPE_CHECKING`-only name raises `NameError` from `get_type_hints`, so
the function returned an empty set with no diagnostic. Every private
field on that schema is then forwarded to, and merged back from,
subagents — the exact leak the marker exists to prevent, with nothing in
the logs to explain it.

This catches only the annotation-resolution errors (`NameError`,
`TypeError`, `AttributeError`), logs which schema failed and what the
consequence is, and still skips that schema rather than failing the
whole agent — a caller may own several unrelated schemas.

Split out of #5164.
@github-actions github-actions Bot added the size: M 200-499 LOC label Jul 29, 2026
Marcelo5444 pushed a commit to Marcelo5444/deepagents that referenced this pull request Jul 30, 2026
…angchain-ai#5166)

`private_state_field_names` resolved annotations under a blanket
`contextlib.suppress(Exception)`.

A schema whose `PrivateStateAttr` annotation references a
`TYPE_CHECKING`-only name raises `NameError` from `get_type_hints`, so
the function returned an empty set with no diagnostic. Every private
field on that schema is then forwarded to, and merged back from,
subagents — the exact leak the marker exists to prevent, with nothing in
the logs to explain it.

This catches only the annotation-resolution errors (`NameError`,
`TypeError`, `AttributeError`), logs which schema failed and what the
consequence is, and still skips that schema rather than failing the
whole agent — a caller may own several unrelated schemas.

Split out of langchain-ai#5164.
@mdrxy
Mason Daugherty (mdrxy) merged commit 88ea7da into main Jul 30, 2026
55 checks passed
@mdrxy
Mason Daugherty (mdrxy) deleted the mdrxy/code/private-server-hook-state branch July 30, 2026 15:25
Mason Daugherty (mdrxy) pushed a commit that referenced this pull request Jul 30, 2026
> [!CAUTION]
> Merging this PR will automatically publish to **PyPI** and create a
**GitHub release**.

For the full release process, see
[`.github/RELEASING.md`](https://github.com/langchain-ai/deepagents/blob/main/.github/RELEASING.md).

---

_Release notes preview: keep this section in sync with the package
`CHANGELOG.md`. Publish reads the merged CHANGELOG via `release.yml`,
not this PR description — keep them aligned anyway so the PR stays an
accurate historical record for reviewers and anyone returning later._

---


##
[0.1.50](deepagents-code==0.1.49...deepagents-code==0.1.50)
(2026-07-30)

### Highlights

- Added project hooks workspace trust and expanded Hooks v2 support with
client and server lifecycle events plus runtime feedback
([#5105](#5105),
[#5104](#5104),
[#4997](#4997),
[#5045](#5045)).
- Added an option to mute the “YOLO is active” toast
([#5103](#5103)).
- Made the splash screen `thread` ID clickable to copy it
([#5173](#5173)).
- Show `ask_user` answers directly on the answered tool row
([#5100](#5100)).
- Show a toast when submitting an empty required `ask_user` answer
([#5095](#5095)).
- Added thread message counts to the Debug Console
([#5117](#5117)).

### Fixes and improvements

- Gated Hooks v2 behind `DEEPAGENTS_CODE_EXPERIMENTAL` and improved hook
resume stability across identity and Command tool results
([#5146](#5146),
[#5176](#5176)).
- Kept server hook state out of task results
([#5164](#5164)).
- Stopped duplicate Auto transcript events during interrupt replay
([#5157](#5157)).
- Kept `/update` and `/install --package` prompts responsive
([#5127](#5127)).
- Refreshed the `/threads` cache after each turn
([#5174](#5174)).
- Anchored toasts above the chat input and added a toast when media is
dropped into a free-text question
([#5101](#5101),
[#5099](#5099)).
- Improved thread status message styling and links
([#5118](#5118)).
- Made resume hints echo the launched command name
([#5119](#5119)).
- Scoped selection copy to the clicked screen
([#5140](#5140)).
- Ignored mouse hits on detached widgets
([#5114](#5114)).

_End release notes preview._

---

> [!NOTE]
> A **New Contributors** section is appended to the GitHub release notes
automatically at publish time (see [Release
Pipeline](https://github.com/langchain-ai/deepagents/blob/main/.github/RELEASING.md#release-pipeline),
step 2).

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: langchain-oss-automated-triage[bot] <248757908+langchain-oss-automated-triage[bot]@users.noreply.github.com>
Co-authored-by: Johannes du Plessis <johannes@langchain.dev>
Mason Daugherty (mdrxy) added a commit that referenced this pull request Jul 30, 2026
…5166)

`private_state_field_names` resolved annotations under a blanket
`contextlib.suppress(Exception)`.

A schema whose `PrivateStateAttr` annotation references a
`TYPE_CHECKING`-only name raises `NameError` from `get_type_hints`, so
the function returned an empty set with no diagnostic. Every private
field on that schema is then forwarded to, and merged back from,
subagents — the exact leak the marker exists to prevent, with nothing in
the logs to explain it.

This catches only the annotation-resolution errors (`NameError`,
`TypeError`, `AttributeError`), logs which schema failed and what the
consequence is, and still skips that schema rather than failing the
whole agent — a caller may own several unrelated schemas.

Split out of #5164.
Mason Daugherty (mdrxy) added a commit that referenced this pull request Jul 30, 2026
Parallel `task` completions no longer crash parent agent turns when
server hooks are active.

---

`ServerHooksMiddleware` keeps two pieces of per-agent bookkeeping in
graph state: a snapshot of `PreToolUse` verdicts keyed by tool-call id,
and a `Stop`-hook continuation count. Both were declared as ordinary
state fields, so a finished subagent copied them into the update it
returned to its parent.

`_after_model` writes the verdict snapshot on every pass — including an
empty dict when no hooks are configured — so *every* subagent emits that
key regardless of hook setup. When two `task` calls completed in the
same graph step, LangGraph received two writes for one single-value
channel and raised `InvalidUpdateError`, failing the parent turn. With a
single `task` there was no error, but the subagent's snapshot silently
replaced the parent's.

Marking both fields `PrivateStateAttr` keeps them inside the agent that
owns them. `SubAgentMiddleware` already filters private keys out of
subagent results, and `create_deep_agent` derives that key set from each
middleware's `state_schema`, so no extra wiring is required.

**This does not change how hook decisions reach the code that enforces
them.** `PrivateStateAttr` is `OmitFromSchema(input=True, output=True)`
— it removes the fields from the graph's *input and output* schemas
only. The channels stay ordinary checkpointed `LastValue` channels, and
every node still runs against the full state schema. A `deny` written in
`after_model` still reaches `wrap_tool_call`, and the continuation count
still survives interrupt/resume. `PreToolUse` allow, deny, and ask
behavior is unchanged.

One consequence worth knowing: because `input=True` is also omitted,
these keys can no longer be seeded through graph *input*. Nothing does
that today, and `update_state` still writes them.

## Tests

`test_parallel_tasks_do_not_merge_subagent_server_hook_state` runs two
`task` calls that complete in one step, parametrized over both subagent
shapes. The `compiled` case is a bare `CompiledSubAgent` that writes the
keys directly, which exercises `SubAgentMiddleware`'s explicit key strip
— the only layer protecting that shape. The `real` case is a declarative
subagent carrying its own `ServerHooksMiddleware`, matching how
subagents are built in `create_cli_agent`; it reproduces the crash
through the unconditional snapshot write. Without the fix the two cases
fail on different keys (`_hooks_stop_continuation_count` and
`_hooks_pre_tool_outcomes`). Both assert the tool messages come back
intact *and* that neither key lands in the parent's checkpointed state,
so swapping in a reducer instead of privacy would not satisfy them.

`test_task_omits_private_server_hook_state_from_subagent_update` covers
the single-`task` case, which cannot trip `InvalidUpdateError` and so
would otherwise regress silently. It is built through
`create_deep_agent` so the private-key derivation is exercised rather
than reimplemented.

`test_pretool_deny_blocks_tool_through_real_graph` drives a `deny`
through a compiled graph and asserts the tool never executes, with and
without a resume round trip. The other deny tests call the middleware
hooks directly and copy state between them by hand, so none of them can
detect an outcome that fails to cross the node boundary.

`test_server_hook_state_fields_are_private` asserts through the
production detector, so it also catches the case where annotation
resolution degrades and silently yields an empty key set.

## Types and documentation

`ServerHooksState` now documents why the fields are private, that the
channels remain checkpointed, and that the verdict dict is a per-turn
snapshot rather than an accumulator — every `_after_model` replaces it
wholesale, so stale tool-call ids cannot survive into a later turn. It
also records that a future reducer on either field must be placed
*after* `PrivateStateAttr` in the `Annotated` metadata: LangGraph
inspects only the last metadata entry when detecting reducers, so one
added before the marker is ignored without error.

`_PreToolState` becomes a discriminated union of a denied and a passed
variant, making a denial without a reason unrepresentable at the three
sites that construct one.

`pre_tool_behavior` becomes `hook_decided_permission`, returning a
`bool`. It previously returned one of four values while both call sites
only asked whether a hook had settled permission, leaving "hook
expressed no opinion" and "no outcome recorded" indistinguishable to
every caller.
Mason Daugherty (mdrxy) pushed a commit that referenced this pull request Jul 30, 2026
> [!CAUTION]
> Merging this PR will automatically publish to **PyPI** and create a
**GitHub release**.

For the full release process, see
[`.github/RELEASING.md`](https://github.com/langchain-ai/deepagents/blob/main/.github/RELEASING.md).

---

_Release notes preview: keep this section in sync with the package
`CHANGELOG.md`. Publish reads the merged CHANGELOG via `release.yml`,
not this PR description — keep them aligned anyway so the PR stays an
accurate historical record for reviewers and anyone returning later._

---


##
[0.1.50](deepagents-code==0.1.49...deepagents-code==0.1.50)
(2026-07-30)

### Highlights

- Added project hooks workspace trust and expanded Hooks v2 support with
client and server lifecycle events plus runtime feedback
([#5105](#5105),
[#5104](#5104),
[#4997](#4997),
[#5045](#5045)).
- Added an option to mute the “YOLO is active” toast
([#5103](#5103)).
- Made the splash screen `thread` ID clickable to copy it
([#5173](#5173)).
- Show `ask_user` answers directly on the answered tool row
([#5100](#5100)).
- Show a toast when submitting an empty required `ask_user` answer
([#5095](#5095)).
- Added thread message counts to the Debug Console
([#5117](#5117)).

### Fixes and improvements

- Gated Hooks v2 behind `DEEPAGENTS_CODE_EXPERIMENTAL` and improved hook
resume stability across identity and Command tool results
([#5146](#5146),
[#5176](#5176)).
- Kept server hook state out of task results
([#5164](#5164)).
- Stopped duplicate Auto transcript events during interrupt replay
([#5157](#5157)).
- Kept `/update` and `/install --package` prompts responsive
([#5127](#5127)).
- Refreshed the `/threads` cache after each turn
([#5174](#5174)).
- Anchored toasts above the chat input and added a toast when media is
dropped into a free-text question
([#5101](#5101),
[#5099](#5099)).
- Improved thread status message styling and links
([#5118](#5118)).
- Made resume hints echo the launched command name
([#5119](#5119)).
- Scoped selection copy to the clicked screen
([#5140](#5140)).
- Ignored mouse hits on detached widgets
([#5114](#5114)).

_End release notes preview._

---

> [!NOTE]
> A **New Contributors** section is appended to the GitHub release notes
automatically at publish time (see [Release
Pipeline](https://github.com/langchain-ai/deepagents/blob/main/.github/RELEASING.md#release-pipeline),
step 2).

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: langchain-oss-automated-triage[bot] <248757908+langchain-oss-automated-triage[bot]@users.noreply.github.com>
Co-authored-by: Johannes du Plessis <johannes@langchain.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dcode Related to `deepagents-code` deepagents Related to the `deepagents` SDK / agent harness fix A bug fix (PATCH) internal User is a member of the `langchain-ai` GitHub organization size: M 200-499 LOC

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants