fix(code): keep server hook state out of task results - #5164
Merged
Mason Daugherty (mdrxy) merged 6 commits intoJul 30, 2026
Conversation
Johannes du Plessis (johannes117)
approved these changes
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.
`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`.
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.
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.
This was referenced Jul 30, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Parallel
taskcompletions no longer crash parent agent turns when server hooks are active.ServerHooksMiddlewarekeeps two pieces of per-agent bookkeeping in graph state: a snapshot ofPreToolUseverdicts keyed by tool-call id, and aStop-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_modelwrites 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 twotaskcalls completed in the same graph step, LangGraph received two writes for one single-value channel and raisedInvalidUpdateError, failing the parent turn. With a singletaskthere was no error, but the subagent's snapshot silently replaced the parent's.Marking both fields
PrivateStateAttrkeeps them inside the agent that owns them.SubAgentMiddlewarealready filters private keys out of subagent results, andcreate_deep_agentderives that key set from each middleware'sstate_schema, so no extra wiring is required.This does not change how hook decisions reach the code that enforces them.
PrivateStateAttrisOmitFromSchema(input=True, output=True)— it removes the fields from the graph's input and output schemas only. The channels stay ordinary checkpointedLastValuechannels, and every node still runs against the full state schema. Adenywritten inafter_modelstill reacheswrap_tool_call, and the continuation count still survives interrupt/resume.PreToolUseallow, deny, and ask behavior is unchanged.One consequence worth knowing: because
input=Trueis also omitted, these keys can no longer be seeded through graph input. Nothing does that today, andupdate_statestill writes them.Tests
test_parallel_tasks_do_not_merge_subagent_server_hook_stateruns twotaskcalls that complete in one step, parametrized over both subagent shapes. Thecompiledcase is a bareCompiledSubAgentthat writes the keys directly, which exercisesSubAgentMiddleware's explicit key strip — the only layer protecting that shape. Therealcase is a declarative subagent carrying its ownServerHooksMiddleware, matching how subagents are built increate_cli_agent; it reproduces the crash through the unconditional snapshot write. Without the fix the two cases fail on different keys (_hooks_stop_continuation_countand_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_updatecovers the single-taskcase, which cannot tripInvalidUpdateErrorand so would otherwise regress silently. It is built throughcreate_deep_agentso the private-key derivation is exercised rather than reimplemented.test_pretool_deny_blocks_tool_through_real_graphdrives adenythrough 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_privateasserts through the production detector, so it also catches the case where annotation resolution degrades and silently yields an empty key set.Types and documentation
ServerHooksStatenow 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_modelreplaces 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 afterPrivateStateAttrin theAnnotatedmetadata: LangGraph inspects only the last metadata entry when detecting reducers, so one added before the marker is ignored without error._PreToolStatebecomes 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_behaviorbecomeshook_decided_permission, returning abool. 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.