Skip to content

ai, ai-persistence: a nested subagent that waits runs again from the start under withPersistence #1878

Closed

Description

@tombeckenham

TanStack AI version

@tanstack/ai and @tanstack/ai-persistence on main, after #1806.

Framework/Library version

Node 24.8.0. Server only.

Describe the bug and the steps to reproduce it

Under withPersistence, a nested subagent that waits on an interrupt runs again from the start after the resume.

Since #1806, a waiting child resumes from loadChild(...).pending, the stored messages, not from the client card. The store keeps a nested child in its own thread (subagent:<id>). So the pending of the outer child has its tool call, but no card for the nested child that waits. Core then finds no suspended child inside the outer child (readSubagentTurn in agents/turn.ts), and runs the nested child again.

Steps:

  1. A lead agent calls a researcher agent tool. The researcher calls a writer agent tool. The writer calls write_file, which needs approval.
  2. Run the turn. It stops on the writer's approval.
  3. Resolve the approval.
  4. The writer runs again from its prompt. write_file does not run. A real model asks for the approval again.

packages/ai-harness/tests/subagent-waits.test.ts "waits for an agent that an agent tool calls, and both go on" on feat/harness-core fails on this.

A quick fix copies the nested cards from the client onto the stored message. That trusts the client's messages for the nested child, which #1806 closed one level up: a forged card can change the args of the nested tool call before the approval runs it. Do not ship that.

Fix design: the card is a pointer, the store gives the contents

  1. LoadChild takes an optional parent (packages/ai/src/activities/chat/middleware/load-child.ts): (subagentRunId, options?: { parentSubagentRunId?: string }). The result also gets interruptIds? and parentToolCallId?, read from the stored info, so the card is not the source.
  2. withPersistence passes the parent on (packages/ai-persistence/src/middleware.ts, provideLoadChild): with a parent, the caller is { threadId: ctx.threadId, subagentRunId: parentSubagentRunId }. The existing check in loadChild (marker.parentThreadId !== ownerThread(caller)) proves that the nested child belongs to that parent. Core asks for a nested child only after it loaded the parent, so the chain is checked from the top.
  3. loadChild returns interruptIds and parentToolCallId (packages/ai-persistence/src/subagent-runs.ts), from storedSubagentInfo.
  4. Core rebuilds nested cards from the store (packages/ai/src/activities/chat/agents/spawn.ts, runChild): for each card subagent part whose parentToolCallId is a tool call in the stored pending, call load(id, { parentSubagentRunId }). No pending means throw "not waiting", as the top level does. Build the part from the store: messages from pending, interruptIds, parentToolCallId, status: 'suspended'. Take only the id from the card. Repeat for deeper nesting.

The LoadChild change is additive. A loader that ignores the parent fails closed for nested children.

Tests:

Docs: the pending line in docs/chat/subagents.md. Add a changeset.

Your Minimal, Reproducible Example - (Sandbox Highly Recommended)

packages/ai-harness/tests/subagent-waits.test.ts on feat/harness-core, test "waits for an agent that an agent tool calls, and both go on". The same three-level setup works with plain chat() and withPersistence on main.

Do you intend to try to help solve this bug with your own PR?

Yes, I am already working on a fix

Terms & Code of Conduct

  • I agree to follow this project's Code of Conduct
  • I understand that if my bug cannot be reliable reproduced in a debuggable environment, it will probably not be fixed and this issue may even be closed.

Activity

  1. self-assigned this
    on Oct 22, 2026
  2. added a commit that references this issue on Oct 22, 2026
    2106e60
  3. tombeckenham commented on Nov 5, 2026

    @tombeckenham
    ContributorAuthor

    Fixed by #1879, which merged into feat/harness-core and reached main in #1755. Released in #1861. The nested-resume, forged-card, and other-parent tests pass on main.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions