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.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@librechat/agents",
"version": "3.8.5",
"version": "3.8.6",
"reova": {
"enabled": true,
"endpoint": "https://telemetry.reo.dev/data"
Expand Down
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
Loading
Loading