Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
54 changes: 54 additions & 0 deletions docs/subagent-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Host-owned subagent context

`Run.create({ subagentContext })` lets a host authorize an individual child
execution, supply its initial messages, partition its tool sessions, and project
its successful result. The SDK exports `SUBAGENT_CONTEXT_VERSION = 1` so hosts
can reject an unsupported installation before enabling features that depend on
execution identity.

`prepare(input)` runs before the child graph is constructed or invoked. Its input
contains the SDK-owned `executionContext`, `parentThreadId`, `memberAgentIds`, an
abort `signal`, and a `resumed` marker. The last ancestry entry's `subagentRunId`
identifies this execution; saved agent IDs alone do not distinguish concurrent
copies of an agent. Nested children inherit the adapter and receive their full
ancestry. A graph subagent has one execution identity and multiple member IDs.

Preparation may return:

- `messages`: actual LangChain messages appended after the task description for
a fresh child. They may contain multimodal file content. Existing checkpoint
messages are preserved on resume without inserting these messages again.
- `configurable`: host runtime context. SDK run, thread, checkpoint, and execution
identities cannot be replaced through this object.
- `agentSessions`: entries keyed by child member ID, each replacing that member's
`codeSessionKey` and `initialSessions`. Omitting `initialSessions` in an entry
clears inherited session seeds. Unknown members are rejected. A paused live
graph cannot change its session partition when it resumes.

The SDK calls preparation again when a completed execution is retried and when a
host rebuilds a paused execution, allowing the host to reauthorize access. Make
authorization idempotent for the execution identity. An ordinary preparation
failure prevents child execution and produces a generic failure result. Aborting
the supplied signal instead propagates as an execution error, so callers must
handle it as cancellation rather than as a normal subagent result.

`complete(input, result)` runs after successful child work. It returns the text
delivered to the parent and can append durable file references. The SDK retains
the original completed result if delivery fails, so retrying delivery does not
repeat model or tool side effects. Completion must be idempotent. Failed and
cancelled child work does not invoke completion.

The same canonical `executionContext` reaches child tool hooks,
`ToolExecuteBatchRequest.executionContext`, and the `configurable.executionContext`
and `metadata.executionContext` of direct tool calls. Root tools have no child
context. Event-driven hosts should use the batch field as authority and overwrite
inherited configurable or metadata values before invoking tools and handling
their artifacts.

The adapter does not implement file authorization, storage, publication, or
sandbox isolation. In particular, `codeSessionKey` partitions the SDK's transient
session map; the host must also assign a private runtime workspace and prevent
inherited runtime hints or direct tool implementations from bypassing that
workspace. Published files and any private artifact recovery remain the host's
responsibility. The host must reconstruct its adapter and authorization when resuming. The SDK
does not replay private artifacts to the host when restoring a checkpoint.
77 changes: 77 additions & 0 deletions src/__tests__/stream.eagerEventExecution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,83 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
expect(graph.toolCallStepIds.has('call_weather')).toBe(true);
});

it('captures normalized code-session identity before eager dispatch', async () => {
const graph = createGraph({
sessions: new Map([
[
Constants.EXECUTE_CODE,
{
session_id: 'exec-session',
files: [{ id: 'old-file', name: 'report.csv' }],
lastUpdated: 1,
},
],
]),
getAgentContext: jest.fn(
(): Partial<AgentContext> => ({
provider: Providers.ANTHROPIC,
toolDefinitions: [{ name: Constants.EXECUTE_CODE }],
graphTools: [],
agentId: 'agent_1',
codeSessionKey: Constants.EXECUTE_CODE,
getCallerCapabilityProjectionSnapshot: jest.fn(() => ({
version: 1 as const,
directToolNames: [],
codeExecutionToolNames: [Constants.EXECUTE_CODE],
directOnlyToolNames: [],
codeExecutionOnlyToolNames: [Constants.EXECUTE_CODE],
})),
})
) as unknown as StandardGraph['getAgentContext'],
});
jest
.spyOn(events, 'safeDispatchCustomEvent')
.mockImplementation(async (event, data): Promise<void> => {
if (event !== GraphEvents.ON_TOOL_EXECUTE) return;
const batch = data as t.ToolExecuteBatchRequest;
batch.toolCalls[0].codeSessionContext = {
session_id: 'new-session',
files: [
{
id: 'new-file',
resource_id: 'new-file',
name: 'report.csv',
storage_session_id: 'new-storage',
kind: 'user',
},
],
};
batch.resolve([
{
toolCallId: 'call_code',
status: 'success',
content: '',
},
]);
});

await new ChatModelStreamHandler().handle(
GraphEvents.CHAT_MODEL_STREAM,
{
chunk: {
content: '',
tool_calls: [
{ id: 'call_code', name: Constants.EXECUTE_CODE, args: {} },
],
response_metadata: finalToolCallResponseMetadata,
} as unknown as t.StreamChunk,
},
{ langgraph_node: 'agent' },
graph
);

const record = graph.eagerEventToolExecutions.get('call_code');
expect(record?.codeSessionBaselineByName?.get('report.csv')).toBe(
'exec-session\0old-file'
);
expect(record?.request.codeSessionContext?.files?.[0].id).toBe('new-file');
});

it('prestarts when subagent callback forwarding can execute tools without a handler registry', async () => {
const graph = createGraph({
handlerRegistry: undefined,
Expand Down
3 changes: 3 additions & 0 deletions src/common/constants.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
/** Host context preparation and trusted subagent execution identity are supported. */
export const SUBAGENT_CONTEXT_VERSION = 1;

/**
* Anthropic direct API tool schema overhead multiplier.
* Empirically calibrated against real MCP tool sets (29 tools).
Expand Down
50 changes: 32 additions & 18 deletions src/graphs/Graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,18 @@ import {
annotateMessagesForLLM,
ToolOutputReferenceRegistry,
} from '@/tools/toolOutputReferences';
import {
prepareProviderRequest,
usesNativeOpenAIResponses,
} from '@/llm/prepareProviderRequest';
import {
resolveLangfuseRuntimeScope,
withLangfuseRuntimeScope,
} from '@/langfuseRuntimeScope';
import {
ManualSummarizationSkippedError,
shouldTriggerSummarization,
} from '@/summarization';
import {
getToolContentCharLength,
serializeToolContentBounded,
Expand All @@ -150,36 +158,28 @@ import {
PreparedSubagents,
PreparedSubagentError,
} from '@/tools/preparedSubagents';
import {
createRemoveAllMessage,
messagesStateReducer,
} from '@/messages/reducer';
import { createToolHistoryPreparation } from '@/messages/toolHistoryProjection';
import { ToolNode as CustomToolNode, toolsCondition } from '@/tools/ToolNode';
import { shouldTraceToolNodeForLangfuse } from '@/langfuseToolOutputTracing';
import { createLocalCodingToolBundle } from '@/tools/local/LocalCodingTools';
import { SUBAGENT_REPLAY_CONTROLLER } from '@/tools/subagent/SubagentReplay';
import { applyGraphRuntimeConfig } from '@/graphs/applyGraphRuntimeConfig';
import { isFadingTier, isInformativeFadingTier } from '@/messages/fading';
import { createContextPressureMeter } from '@/llm/contextPressureMeter';
import { createToolHistoryPreparation } from '@/messages/toolHistoryProjection';
import { safeDispatchCustomEvent, emitAgentLog } from '@/utils/events';
import {
prepareProviderRequest,
usesNativeOpenAIResponses,
} from '@/llm/prepareProviderRequest';
import { createCloudflareCodingToolBundle } from '@/tools/cloudflare';
import { calculateMaxToolCallInputChars } from '@/utils/truncation';
import { prepareToolsForPromptCache } from '@/llm/promptCacheTools';
import { providerRequiresStrictAlternation } from '@/llm/providers';
import { buildSubagentToolParams } from '@/tools/SubagentTool';
import { initializeLangfuseTracing } from '@/instrumentation';
import {
ManualSummarizationSkippedError,
shouldTriggerSummarization,
} from '@/summarization';
import { isRunStepResumeState } from '@/tools/runStepResume';
import { resolveLocalToolsForBinding } from '@/tools/local';
import { createSummarizeNode } from '@/summarization/node';
import {
createRemoveAllMessage,
messagesStateReducer,
} from '@/messages/reducer';
import { getTruncationStopReason } from '@/llm/truncation';
import { createSchemaOnlyTools } from '@/tools/schema';
import { AgentContext } from '@/agents/AgentContext';
Expand Down Expand Up @@ -1356,7 +1356,8 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
/** See {@link t.StandardGraphInput.subagentTasks}. */
subagentTasks: t.SubagentTaskConfig | undefined;
/** See {@link t.StandardGraphInput.subagentExecutionContext}. */
private readonly subagentExecutionContext?: t.SubagentExecutionContext;
readonly subagentExecutionContext?: t.SubagentExecutionContext;
private readonly subagentContext?: t.SubagentContextAdapter;
/** See {@link t.StandardGraphInput.preemption}. */
preemption?: t.StreamPreemption;
/**
Expand Down Expand Up @@ -1534,6 +1535,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
subagentTasks,
subagentScope,
subagentExecutionContext,
subagentContext,
preemption,
streamLimits,
toolExecution,
Expand All @@ -1560,6 +1562,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
this.subagentTasks = subagentTasks;
this.subagentScope = subagentScope === true;
this.subagentExecutionContext = subagentExecutionContext;
this.subagentContext = subagentContext;
this.preemption = preemption;
this.streamLimits = resolveStreamLimits(streamLimits);
this.toolExecution = toolExecution;
Expand Down Expand Up @@ -2881,6 +2884,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
// run_id); `executingAgentId` always identifies the owning agent.
agentId: this.subagentScope ? agentContext?.agentId : undefined,
executingAgentId: agentContext?.agentId,
executionContext: this.subagentExecutionContext,
executingAgentName: agentContext?.name,
rootAgentId: this.defaultAgentId,
rootAgentName: this.agentContexts.get(this.defaultAgentId)?.name,
Expand Down Expand Up @@ -2965,6 +2969,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
// hooks can attribute the batch even at the top level.
agentId: this.subagentScope ? agentContext?.agentId : undefined,
executingAgentId: agentContext?.agentId,
executionContext: this.subagentExecutionContext,
executingAgentName: agentContext?.name,
rootAgentId: this.defaultAgentId,
rootAgentName: this.agentContexts.get(this.defaultAgentId)?.name,
Expand Down Expand Up @@ -4627,7 +4632,9 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
fallbackProvider,
fallbackClientOptions
);
let projection: ReturnType<typeof measureProviderPayload> | undefined;
let projection:
| ReturnType<typeof measureProviderPayload>
| undefined;
const request = prepareProviderRequest({
model: fallbackModel,
messages: cached,
Expand All @@ -4644,7 +4651,9 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
},
});
if (projection == null) {
throw new Error('Fallback preparation did not measure its payload');
throw new Error(
'Fallback preparation did not measure its payload'
);
}
return { request, projection };
};
Expand All @@ -4664,8 +4673,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
if (!projection.fits) {
const compacted = compactSyntheticProviderContext(
servingFallbackMessages,
(candidate) =>
prepareFallback(candidate).projection
(candidate) => prepareFallback(candidate).projection
);
if (compacted !== servingFallbackMessages) {
preparedFallbackRequest = prepareFallback(compacted);
Expand Down Expand Up @@ -5025,6 +5033,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
runId,
threadId: configurable?.thread_id as string | undefined,
agentId: this.subagentScope ? agentId : undefined,
executionContext: this.subagentExecutionContext,
executingAgentId: agentId,
sealCount: this.preemptSealCount,
},
Expand Down Expand Up @@ -5247,6 +5256,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
tokenCounter: agentContext.tokenCounter,
usageSink: this.subagentUsageSink,
taskConfig: this.subagentTasks,
subagentContext: this.subagentContext,
streamLimits: this.streamLimits,
humanInTheLoop: this.humanInTheLoop,
checkpointer: this.compileOptions?.checkpointer,
Expand Down Expand Up @@ -5343,6 +5353,9 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
});
}
const result = await executor.execute(executeParams);
if (result.retryableDelivery === true) {
throw new Error(result.content);
}
return result.content;
},
buildSubagentToolParams(executableConfigs, {
Expand Down Expand Up @@ -5527,6 +5540,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
},
runId: this.runId,
isMultiAgent: this.isMultiAgentGraph(),
getSubagentExecutionContext: () => this.subagentExecutionContext,
hookRegistry: this.hookRegistry,
getToolsForBinding: (
provider: t.ProviderName,
Expand Down
38 changes: 38 additions & 0 deletions src/hooks/__tests__/compactHooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,21 @@ const callerConfig = {
version: 'v2' as const,
};

const childExecutionContext: t.SubagentExecutionContext = {
rootRunId: 'root-run',
hookSessionId: 'compact-run',
depth: 1,
ancestry: [
{
subagentRunId: 'child-run',
subagentType: 'worker',
subagentKind: 'agent',
subagentAgentId: 'worker',
parentRunId: 'root-run',
},
],
};

let getChatModelClassSpy: jest.SpyInstance;
const originalGetChatModelClass = providers.getChatModelClass;

Expand Down Expand Up @@ -145,6 +160,29 @@ describe('Compaction hook integration', () => {
});
});

it('carries child execution identity through compact hooks', async () => {
const registry = new HookRegistry();
let pre: PreCompactHookInput | undefined;
let post: PostCompactHookInput | undefined;
registry.register('PreCompact', {
hooks: [async (input) => ((pre = input), {})],
});
registry.register('PostCompact', {
hooks: [async (input) => ((post = input), {})],
});
const run = await createCompactingRun(tokenCounter, registry);
Object.defineProperty(run.Graph, 'subagentExecutionContext', {
value: childExecutionContext,
});
expect(run.Graph?.subagentExecutionContext).toEqual(childExecutionContext);
run.Graph!.overrideTestModel(['Final answer after compaction.']);

await run.processStream(buildConversation(), callerConfig);

expect(pre?.executionContext).toEqual(childExecutionContext);
expect(post?.executionContext).toEqual(childExecutionContext);
});

describe('PostCompact', () => {
it('fires with summary text after compaction (legacy retainRecent.turns=0 shape)', async () => {
const registry = new HookRegistry();
Expand Down
3 changes: 3 additions & 0 deletions src/hooks/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// src/hooks/types.ts
import type { BaseMessage } from '@langchain/core/messages';
import type { SubagentExecutionContext } from '@/types/graph';
import type { InjectedMessage } from '@/types/tools';

/**
Expand Down Expand Up @@ -57,6 +58,8 @@ export type StopDecision = 'continue' | 'block';
* hook to a specific agent regardless of subagent scope.
*/
export interface BaseHookInput {
/** SDK-owned child lineage, including distinct identities for concurrent self-spawns. */
executionContext?: SubagentExecutionContext;

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 Propagate execution context to compact hooks

When a child graph triggers summarization, its PreCompact and PostCompact calls in src/summarization/node.ts still construct hook inputs without executionContext (lines 920-929 and 1327-1339). Consequently, hosts relying on this newly exposed lineage cannot correlate or authorize compact lifecycle hooks for concurrent copies of the same saved agent, even though tool, preemption, and subagent start/stop hooks receive it. Thread the graph's subagent execution context through both compact hook inputs.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Propagated the canonical child execution context through both PreCompact and PostCompact hooks in commit 1083640, with focused compaction-hook coverage.

runId: string;
threadId?: string;
agentId?: string;
Expand Down
Loading
Loading