Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
c66dc21
feat: support foreground graph subagent approvals and resume
lia-librechat Oct 5, 2026
704f27c
test: retain graph host argument rejection with revision support
lia-librechat Oct 5, 2026
6e0f551
fix: Bind Graph Resume to Tool Capabilities
lia-librechat Oct 5, 2026
a55b691
fix: Bind Graph Resume to Member Session Partitions
lia-librechat Oct 5, 2026
e4adb46
fix: Restore Graph Approval Evidence Per Member
lia-librechat Oct 5, 2026
48c821f
fix: Preserve Settled Graph Effects During Partial Resume
lia-librechat Oct 5, 2026
3cc511a
fix: Bind Functional Prompts and Restore Refreshed Sessions
lia-librechat Oct 5, 2026
421f7ef
Merge remote-tracking branch 'origin/main' into lia/graph-subagent-hitl
lia-librechat Oct 17, 2026
48454f6
fix: Expose Pending Graph Approvals and Bind Tool Discovery
lia-librechat Oct 17, 2026
6e5683b
Merge remote-tracking branch 'origin/main' into lia/graph-subagent-hitl
lia-librechat Oct 22, 2026
61d70d0
fix: Bind Model Runtime Options and Isolate Question Replay
lia-librechat Oct 22, 2026
9aa4d11
fix: Bind Member Execution and Preserve Custom Interrupts
lia-librechat Oct 22, 2026
ce0c9bb
fix: Bind Cached Tool Schema Budgets During Resume
lia-librechat Oct 22, 2026
1d02555
fix: Restore Root Parallel Interrupt Evidence
lia-librechat Oct 23, 2026
372c280
fix: Scope Parent Replay Records and Child Manifests
lia-librechat Oct 23, 2026
498179b
fix: Merge Question Parent State and Enumerate All Child Pauses
lia-librechat Oct 23, 2026
d181a80
fix: Discover All Pending Nested Checkpoint Interrupts
lia-librechat Oct 23, 2026
124d8b2
test: Type Mixed-Depth Interrupt Resume Values
lia-librechat Oct 23, 2026
f3c72a1
fix: Unify Command Resume and Preserve Opaque Interrupt Payloads
lia-librechat Oct 23, 2026
9400cbc
fix: Isolate Root Resume Manifests and Private Replay State
lia-librechat Oct 23, 2026
dbafd0c
fix: Reject Unmatched Pending Approval Owners
lia-librechat Oct 23, 2026
c82260e
fix: Preserve Unaddressed Checkpoint Approval Barriers
lia-librechat Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/common/constants.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
/** Host context preparation and trusted subagent execution identity are supported. */
export const SUBAGENT_CONTEXT_VERSION = 1;

/** Foreground graph-subagent approval and durable resume support. */
export const GRAPH_SUBAGENT_HITL_VERSION = 1;

/**
* Anthropic direct API tool schema overhead multiplier.
* Empirically calibrated against real MCP tool sets (29 tools).
Expand Down
60 changes: 58 additions & 2 deletions src/graphs/__tests__/Graph.subagentResumeState.test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import { describe, it, expect } from '@jest/globals';
import type { SubagentToolNodeResumeState } from '@/tools/subagent/SubagentReplay';
import { StandardGraph } from '../Graph';
import { Providers } from '@/common';
import { Constants, Providers } from '@/common';

type ResumeToolNode = {
createSubagentResumeState(): SubagentToolNodeResumeState;
restoreSubagentResumeState(state: SubagentToolNodeResumeState): void;
};

const makeGraph = (runId: string): StandardGraph =>
const makeGraph = (runId: string, codeSessionKey?: string): StandardGraph =>
new StandardGraph({
runId,
agents: [
{
agentId: 'child-agent',
codeSessionKey,
provider: Providers.OPENAI,
instructions: 'Test child state restoration.',
},
Expand Down Expand Up @@ -77,4 +78,59 @@ describe('subagent graph resume state', () => {
new Set(['unstable_search'])
);
});

it('keeps the legacy default partition identity', () => {
const implicit = initializeToolNode(makeGraph('implicit'));
const explicit = initializeToolNode(
makeGraph('explicit', Constants.EXECUTE_CODE)
);
expect(explicit.createSubagentResumeState().stateKey).toBe(
implicit.createSubagentResumeState().stateKey
);
});

it.each([undefined, 'partition-B'])(
'rejects partition-A restoration into %s before replacing state',
(codeSessionKey) => {
const source = makeGraph('source', 'partition-A');
initializeToolNode(source);
source.sessions.set('partition-A', {
session_id: 'session-A',
lastUpdated: 1,
});
const target = makeGraph('target', codeSessionKey);
initializeToolNode(target);
target.sessions.set('sentinel', {
session_id: 'unchanged',
lastUpdated: 2,
});
target.toolCallStepIds.set('existing', 'unchanged-step');
const before = target.createSubagentResumeState('target-scope');
expect(() =>
target.restoreSubagentResumeState(
source.createSubagentResumeState('source-scope'),
'target-scope'
)
).toThrow('Cannot restore subagent tool state');
expect(target.createSubagentResumeState('target-scope')).toEqual(before);
}
);

it('restores an unchanged custom partition', () => {
const source = makeGraph('source', 'partition-A');
initializeToolNode(source);
source.sessions.set('partition-A', {
session_id: 'session-A',
lastUpdated: 1,
});
const target = makeGraph('target', 'partition-A');
initializeToolNode(target);
target.restoreSubagentResumeState(
source.createSubagentResumeState('source-scope'),
'target-scope'
);
expect(target.sessions.get('partition-A')).toEqual(
source.sessions.get('partition-A')
);
});
});
51 changes: 48 additions & 3 deletions src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -66,6 +67,7 @@ import {
} from '@/prompts/reasoningLabel';
import {
TOOL_BATCH_REPLAY_KEY,
TOOL_REPLAY_CONFIGS_KEY,
restoreToolReplayConfig,
stripToolBatchReplayState,
getPublicToolInterruptPayload,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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,
})),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore evidence for every advertised root interrupt

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 calls restoreToolReplayConfig() only for this._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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

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.

};
}
}
Expand Down Expand Up @@ -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)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve custom payloads when listing pending interrupts

When a custom graph node interrupts with a non-structured-cloneable payload, such as one containing a function or symbol, getInterrupt() now throws here instead of returning the custom payload; structured cloning can also erase a custom class prototype. The primary payload path intentionally preserves non-tool-approval payloads, and the public generic documents arbitrary custom interrupt payloads, so pendingInterrupts should use the same preservation semantics rather than cloning every value unconditionally.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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>;
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading