Repository navigation
⏯️ feat: Enable Graph Subagent Approval and Resume #599
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 9 commits
c66dc21
704f27c
6e0f551
a55b691
e4adb46
48c821f
3cc511a
421f7ef
48454f6
6e5683b
61d70d0
9aa4d11
ce0c9bb
1d02555
372c280
498179b
d181a80
124d8b2
f3c72a1
9400cbc
dbafd0c
c82260e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,6 +22,7 @@ import type { | |
| } from '@langchain/core/messages'; | ||
| import type { StringPromptValue } from '@langchain/core/prompt_values'; | ||
| import type { RunnableConfig } from '@langchain/core/runnables'; | ||
| import type { SubagentResumeManifest } from '@/tools/subagent/SubagentReplay'; | ||
| import type { AggregatedHookResult, HookRegistry } from '@/hooks'; | ||
| import type { MultiAgentGraph } from '@/graphs/MultiAgentGraph'; | ||
| import type { StandardGraph } from '@/graphs/Graph'; | ||
|
|
@@ -66,6 +67,7 @@ import { | |
| } from '@/prompts/reasoningLabel'; | ||
| import { | ||
| TOOL_BATCH_REPLAY_KEY, | ||
| TOOL_REPLAY_CONFIGS_KEY, | ||
| restoreToolReplayConfig, | ||
| stripToolBatchReplayState, | ||
| getPublicToolInterruptPayload, | ||
|
|
@@ -268,12 +270,24 @@ function getToolCompletion( | |
|
|
||
| function isLangGraphResumeMapForInterrupt( | ||
| value: unknown, | ||
| interruptId: string | ||
| interruptId: string, | ||
| manifest?: SubagentResumeManifest | ||
| ): value is Record<string, unknown> { | ||
| if (value === null || typeof value !== 'object' || Array.isArray(value)) { | ||
| return false; | ||
| } | ||
| return Object.prototype.hasOwnProperty.call(value, interruptId); | ||
| if (Object.prototype.hasOwnProperty.call(value, interruptId)) return true; | ||
| const pending = [...(manifest?.executions ?? [])]; | ||
| while (pending.length > 0) { | ||
| const execution = pending.pop()!; | ||
| if ( | ||
| execution.pendingInterruptIds?.some( | ||
| (id) => Object.prototype.hasOwnProperty.call(value, id) | ||
| ) === true | ||
| ) return true; | ||
| pending.push(...(execution.descendant?.executions ?? [])); | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| function getInterruptHookSessionId(payload: unknown): string | undefined { | ||
|
|
@@ -1314,6 +1328,7 @@ export class Run<_T extends t.BaseGraphState> { | |
| }; | ||
| delete config.configurable?.[TOOL_APPROVAL_REVIEW_CONFIG_KEY]; | ||
| delete config.configurable?.[TOOL_BATCH_REPLAY_KEY]; | ||
| delete config.configurable?.[TOOL_REPLAY_CONFIGS_KEY]; | ||
| if (!isResume) { | ||
| delete config.configurable?.[SUBAGENT_RESUME_ATTEMPT_CONFIG_KEY]; | ||
| delete config.configurable?.[SUBAGENT_RESUME_MANIFEST_CONFIG_KEY]; | ||
|
|
@@ -1581,6 +1596,9 @@ export class Run<_T extends t.BaseGraphState> { | |
| interruptId: first.id ?? '', | ||
| threadId, | ||
| payload: cloneToolApprovalInterruptPayload(first.value), | ||
| pendingInterrupts: interrupts.map((pending) => ({ | ||
| interruptId: pending.id ?? '', payload: pending.value, | ||
| })), | ||
| }; | ||
| } | ||
| } | ||
|
|
@@ -1960,11 +1978,33 @@ export class Run<_T extends t.BaseGraphState> { | |
| if (this._interrupt == null) { | ||
| return undefined; | ||
| } | ||
| const pendingInterrupts = new Map< | ||
| string, Pick<t.RunInterruptResult<unknown>, 'interruptId' | 'payload'> | ||
| >(); | ||
| for (const pending of this._interrupt.pendingInterrupts ?? [this._interrupt]) { | ||
| pendingInterrupts.set(pending.interruptId, pending); | ||
| } | ||
| const replayPayload = stripRunStepResumeState(this._interrupt.payload); | ||
| const manifest = | ||
| requireValidSubagentResumeManifest(replayPayload) ?? | ||
| requireValidSubagentResumeManifest(stripToolBatchReplayState(replayPayload)); | ||
| const executions = [...(manifest?.executions ?? [])]; | ||
| while (executions.length > 0) { | ||
| const execution = executions.pop()!; | ||
| for (const pending of execution.pendingInterrupts ?? []) { | ||
| pendingInterrupts.set(pending.interruptId, pending); | ||
| } | ||
| executions.push(...(execution.descendant?.executions ?? [])); | ||
| } | ||
| return { | ||
| ...this._interrupt, | ||
| payload: cloneToolApprovalInterruptPayload( | ||
| getPublicToolInterruptPayload(this._interrupt.payload) | ||
| ), | ||
| pendingInterrupts: [...pendingInterrupts.values()].map((pending) => ({ | ||
| interruptId: pending.interruptId, | ||
| payload: structuredClone(getPublicToolInterruptPayload(pending.payload)), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a custom graph node interrupts with a non-structured-cloneable payload, such as one containing a function or symbol, Useful? React with 👍 / 👎.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 9aa4d11. Pending proposals use the same approval-only cloning semantics as the primary payload. Custom functions, symbols and class instances are preserved rather than structured-cloned. Public result tests cover all three and primitives; existing approval isolation coverage remains passing. |
||
| })), | ||
| } as t.RunInterruptResult<TPayload>; | ||
| } | ||
|
|
||
|
|
@@ -2084,7 +2124,11 @@ export class Run<_T extends t.BaseGraphState> { | |
| const scopedResume = | ||
| typeof interruptId === 'string' && | ||
| interruptId.length > 0 && | ||
| !isLangGraphResumeMapForInterrupt(resumeValue, interruptId) | ||
| !isLangGraphResumeMapForInterrupt( | ||
| resumeValue, | ||
| interruptId, | ||
| requireValidSubagentResumeManifest(resumeConfig.configurable) | ||
| ) | ||
| ? { [interruptId]: resumeValue } | ||
| : resumeValue; | ||
| // langgraph 1.4.5 applies resume + state update + reroute in one superstep | ||
|
|
@@ -2119,6 +2163,7 @@ export class Run<_T extends t.BaseGraphState> { | |
| const resumeConfigurable = { ...callerConfig.configurable }; | ||
| delete resumeConfigurable[TOOL_APPROVAL_REVIEW_CONFIG_KEY]; | ||
| delete resumeConfigurable[TOOL_BATCH_REPLAY_KEY]; | ||
| delete resumeConfigurable[TOOL_REPLAY_CONFIGS_KEY]; | ||
| delete resumeConfigurable[SUBAGENT_RESUME_ATTEMPT_CONFIG_KEY]; | ||
| delete resumeConfigurable[SUBAGENT_RESUME_MANIFEST_CONFIG_KEY]; | ||
| resumeConfigurable[SUBAGENT_RESUME_ATTEMPT_CONFIG_KEY] = nanoid(); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When two top-level graph members pause on tool approvals in the same superstep, this list advertises both IDs and a live caller can pass decisions for both to
Run.resume(). However,resolveInterruptResumeConfig()still callsrestoreToolReplayConfig()only forthis._interrupt(the first entry), so both resumed ToolNodes receive only the first member's owner-bound review evidence and the second approval cannot be validated. Preserve and restore per-interrupt replay evidence at the root, as the child-graph path now does, before advertising simultaneous root interrupts as resumable.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 1d02555. Root Runs now retain all current pending proposals and restore per-owner checkpoint evidence for every pending member, authorizing only addressed IDs. Fresh Run restoration handles the whole pending set. Multi-agent continuation selects the checkpoint without replaying completed Pregel siblings. Real direct/event tests cover simultaneous and partial maps in both orders, rebuilt Runs, edited/rejected decisions, mixed questions, and direct processStream Commands. Owner safeguards remain intact.