feat(deepagents): reload skills when skillsMetadata is set to null - #862
Merged
Richard Scarrott (richardscarrott) merged 8 commits intoSep 17, 2026
Merged
Conversation
Setting `skillsMetadata` to `null` between runs, with `updateState` or in the next run's input, makes the next run reload every skill source and replace the stored list. A stored `[]` now counts as loaded. Removes the unused `skillsMetadataReducer`, since subagents don't return `skillsMetadata`.
`createDeepAgent({ skills: [...] })` mounts `SkillsMiddleware` at runtime, but
the agent's state type was built only from the unconditionally mounted
middleware, so `skillsMetadata` was missing from `invoke` and `stream` inputs.
Reaching it meant mounting `createSkillsMiddleware` by hand.
Capture `skills` as a const type parameter and splice the skills middleware
into the inferred state when it is present. Inference mirrors the runtime
condition: an omitted option or an empty literal contributes no state, while a
non-literal `string[]` does.
Widen `SkillsMiddlewareOptions["sources"]` and `SubAgent["skills"]` to
`readonly string[]`, which the const type parameter requires and which also
lets callers pass readonly arrays. Export `SkillsMiddleware` and
`SkillMetadataEntry`.
`updateState` is deliberately left untested: LangGraph types its values
parameter as `unknown`, so any assertion there would pass regardless.
A middleware can only read and write the fields declared on its own state
schema, so a middleware that wanted to inspect `skillsMetadata`, or set it
to `null` to force a reload, had no way to declare the field: returning
`{ skillsMetadata: null }` without it fails to compile.
Export `skillsMetadataValue` for that declaration, and have
`SkillsStateSchema` consume it so the field has one definition. It stays a
plain nullish array with no reducer: a `ReducedValue` would accept more than
one write per step, which would make the parallel-subagent guard in
`subagent.test.ts` vacuous.
The value is documented as opaque. It exists to be passed to `StateSchema`,
so keeping its concrete type unpinned leaves room to swap it later without
breaking callers. `SkillMetadataEntrySchema` stays internal; the
`SkillMetadataEntry` type is the public surface for a single entry.
Also adds the missing `SkillMetadataEntry` to the browser entry point, which
the previous commit added to `index.ts` only.
`v24.x` is not a version mise can resolve ("semver range 24.x is not
supported"), which breaks the node and pnpm shims locally. `24` resolves
for mise and is equally acceptable to `actions/setup-node` via
`node-version-file`.
Loading in `beforeAgent` pinned `skillsMetadata` for a whole run, so a reload requested by setting it to `null` could not be served until the next run. Loading in `beforeModel` serves it on the next model call, which lets a middleware of your own invalidate mid-run — from `afterModel`, say — and have the following call see the fresh list. This also matches Python's `SkillsMiddleware`, which has always loaded in `before_model` with the same "`null` means reload, `[]` means loaded and empty" guard. Two further consequences. Resolving a `StateBackend` now sees `files` written during the run, so a `SKILL.md` the agent just wrote is picked up. And a fork spawned in the same iteration that nulled the field inherits the `null`, so it reloads from its own sources — which are the parent's, by construction. `wrapModelCall` keeps collapsing `null` into `[]`, as Python does. That leaves one gap: `jumpTo: "model"` from an `afterModel` hook routes straight to the model node and bypasses `beforeModel`, so a pending reload waits an extra iteration and that one call is told there are no skills. `humanInTheLoopMiddleware` jumps this way on a rejected tool call. Fixing the jump target is a separate change.
Co-authored-by: Hunter Lovell <40191806+hntrl@users.noreply.github.com> Signed-off-by: Richard Scarrott <riscarrott@googlemail.com>
Co-authored-by: Hunter Lovell <40191806+hntrl@users.noreply.github.com> Signed-off-by: Richard Scarrott <riscarrott@googlemail.com>
🦋 Changeset detectedLatest commit: bd23287 The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
deepagents-acp
deepagents
@langchain/sandbox-standard-tests
@langchain/daytona
@langchain/deno
@langchain/modal
@langchain/node-vfs
@langchain/quickjs
commit: |
Hunter Lovell (hntrl)
approved these changes
Sep 17, 2026
Richard Scarrott (richardscarrott)
deleted the
feat/skills-reload-on-null
branch
September 17, 2026 16:39
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.
Summary
With a checkpointer, a thread loads skills once and keeps them in
skillsMetadata. Skills added, edited or deleted in the backend after that never reach the model. There was no supported reset.updateStatewithnullfailed validation, the reducer ignored[], and a new list merged by name, so deleted skills stayed.Set
skillsMetadatatonullbetween runs, and the next run reloads every source and replaces the stored list:skillsMetadatais now a plain.nullish()field with no reducer and no default.beforeAgentloads when the value isnullor missing. A stored[]now means the sources had no skills, so those threads stop listing the backend on every run. That includes existing checkpoints that store[].skillsMetadataReduceris gone. No package entry point exported it.This is the same contract as langchain-ai/deepagents#6288, with
nullin place ofNone.Closes #562
Why removing the reducer is safe
[]default because parallel subagents returnedskillsMetadataand hit "LastValue can only receive one value per step". The reducer ignored empty updates because those subagents returned empty lists.[]default made a!= nullcheck always true.skillsMetadatafrom subagent state in both directions. That removed the reason for the reducer, but the reducer stayed.taskcall and a fork in one message and checks the parent's list afterwards. WithskillsMetadatataken out ofEXCLUDED_STATE_KEYS, it fails withINVALID_CONCURRENT_GRAPH_UPDATE. That test keeps the removal safe if someone edits the exclusion list.Rolling back after a reset
If a thread is reset and deepagents is then downgraded before that thread runs again,
agent.invoke/agent.streamon the older version throwsValidation failed for field "skillsMetadata", because older versions don't acceptnull. To recover, runagent.updateState(config, { skillsMetadata: [] })on the older version. LangGraph API server deployments aren't affected.Docs: langchain-ai/docs#6043
Supersedes #860, which is the same work on a branch in my fork (
richardscarrott/deepagentsjs). Identical commits — this branch isbd23287, the commit hntrl approved there, pushed unchanged.Moved here because CodeQL is on GitHub's default setup, which doesn't analyse fork PR refs, while the
mainruleset requires CodeQL results. #860 had 0 CodeQL runs and sat atCode scanning is waiting for results from CodeQL, unsatisfiable rather than merely pending. Origin branches get analysed, so the requirement can actually be met here.Review discussion and approval live on #860.