fix(core): thread nested agent identity into sidecar metadata

This commit is contained in:
tanzhenxin 2026-04-23 16:19:59 +08:00
parent 8877c5054f
commit a8a637e8ca
4 changed files with 194 additions and 6 deletions

View file

@ -0,0 +1,53 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { getCurrentAgentId, runWithAgentContext } from './agent-context.js';
describe('agent-context', () => {
it('returns null outside any frame', () => {
expect(getCurrentAgentId()).toBeNull();
});
it('exposes the agentId inside a frame', async () => {
await runWithAgentContext({ agentId: 'explore-abc' }, async () => {
expect(getCurrentAgentId()).toBe('explore-abc');
});
expect(getCurrentAgentId()).toBeNull();
});
it('propagates across awaits', async () => {
await runWithAgentContext({ agentId: 'outer-1' }, async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
expect(getCurrentAgentId()).toBe('outer-1');
});
});
it('nested frames shadow the outer agentId', async () => {
await runWithAgentContext({ agentId: 'outer-1' }, async () => {
expect(getCurrentAgentId()).toBe('outer-1');
await runWithAgentContext({ agentId: 'inner-2' }, async () => {
expect(getCurrentAgentId()).toBe('inner-2');
});
expect(getCurrentAgentId()).toBe('outer-1');
});
});
it('isolates concurrent frames', async () => {
const results: string[] = [];
await Promise.all([
runWithAgentContext({ agentId: 'a' }, async () => {
await new Promise((resolve) => setTimeout(resolve, 5));
results.push(getCurrentAgentId() ?? 'null');
}),
runWithAgentContext({ agentId: 'b' }, async () => {
await new Promise((resolve) => setTimeout(resolve, 1));
results.push(getCurrentAgentId() ?? 'null');
}),
]);
expect(results.sort()).toEqual(['a', 'b']);
});
});

View file

@ -0,0 +1,45 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Ambient agent-identity context for nested `agent` tool calls.
*
* When a subagent's model calls the `agent` tool, the resulting
* AgentToolInvocation's `this.config` is the main process Config (see
* comment in `fork-subagent.ts`) it has no way to know which subagent
* made the call. We carry the launching agent's id via AsyncLocalStorage
* so nested launches can record `parentAgentId` in their sidecar meta.
*/
import { AsyncLocalStorage } from 'node:async_hooks';
interface AgentContext {
readonly agentId: string;
}
const agentContextStorage = new AsyncLocalStorage<AgentContext>();
/**
* Runs `fn` with an ambient agent-identity frame.
*
* Wrap the subagent's execution (headless run loop and any hook-driven
* continuations) so every nested `agent` tool invocation inside it reads
* the launching agent's id via {@link getCurrentAgentId}.
*/
export function runWithAgentContext<T>(
context: AgentContext,
fn: () => Promise<T>,
): Promise<T> {
return agentContextStorage.run(context, fn);
}
/**
* Returns the id of the subagent whose execution is currently on the call
* stack, or `null` at the top-level user session.
*/
export function getCurrentAgentId(): string | null {
return agentContextStorage.getStore()?.agentId ?? null;
}

View file

@ -31,6 +31,10 @@ import type {
import { partToString } from '../../utils/partUtils.js';
import type { HookSystem } from '../../hooks/hookSystem.js';
import { PermissionMode } from '../../hooks/types.js';
import { runWithAgentContext } from './agent-context.js';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
// Type for accessing protected methods in tests
type AgentToolInvocation = {
@ -1577,6 +1581,77 @@ describe('AgentTool', () => {
expect.objectContaining({ toolUseId: 'call-xyz-789' }),
);
});
describe('parentAgentId sidecar', () => {
let tempProjectDir: string;
beforeEach(() => {
tempProjectDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'agent-parent-id-'),
);
(config as unknown as Record<string, unknown>)['storage'] = {
getProjectDir: () => tempProjectDir,
};
});
afterEach(() => {
fs.rmSync(tempProjectDir, { recursive: true, force: true });
});
const readSidecar = (agentId: string) => {
const metaPath = path.join(
tempProjectDir,
'subagents',
'test-session-id',
`agent-${agentId}.meta.json`,
);
return JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
};
it('writes parentAgentId: null at top-level launches', async () => {
const params: AgentParams = {
description: 'Start monitor',
prompt: 'Watch for changes',
subagent_type: 'monitor',
};
const invocation = (
agentTool as AgentToolWithProtectedMethods
).createInvocation(params);
(
invocation as unknown as { setCallId: (id: string) => void }
).setCallId('top-1');
await invocation.execute();
const meta = readSidecar('monitor-top-1');
expect(meta.parentAgentId).toBeNull();
});
it('records the launching agent id when launched from a subagent frame', async () => {
const params: AgentParams = {
description: 'Start monitor',
prompt: 'Watch for changes',
subagent_type: 'monitor',
};
const invocation = (
agentTool as AgentToolWithProtectedMethods
).createInvocation(params);
(
invocation as unknown as { setCallId: (id: string) => void }
).setCallId('nested-1');
await runWithAgentContext(
{ agentId: 'explore-parent-42' },
async () => {
await invocation.execute();
},
);
const meta = readSidecar('monitor-nested-1');
expect(meta.parentAgentId).toBe('explore-parent-42');
});
});
});
});

View file

@ -39,6 +39,7 @@ import {
isInForkExecution,
runInForkContext,
} from './fork-subagent.js';
import { getCurrentAgentId, runWithAgentContext } from './agent-context.js';
import {
AgentEventEmitter,
AgentEventType,
@ -1106,7 +1107,10 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
agentType: hookOpts.agentType,
description: this.params.description,
parentSessionId: sessionId,
parentAgentId: null,
// Populated when a subagent (whose reasoning loop is wrapped in
// runWithAgentContext below) launches a nested agent. Null at
// top-level launches from the user session.
parentAgentId: getCurrentAgentId(),
createdAt: new Date().toISOString(),
});
@ -1199,7 +1203,12 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
cleanupJsonl?.();
}
};
void (isFork ? runInForkContext(bgBody) : bgBody());
// Wrap in the agent-identity frame so nested `agent` tool calls
// from this subagent's model record this agent's id as their
// `parentAgentId` in the sidecar meta.
const framedBgBody = () =>
runWithAgentContext({ agentId: hookOpts.agentId }, bgBody);
void (isFork ? runInForkContext(framedBgBody) : framedBgBody());
this.updateDisplay({ status: 'background' as const }, updateOutput);
return {
@ -1208,18 +1217,24 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
};
}
// Same agent-identity frame as the background path: a foreground
// subagent can also launch nested agents, and those nested launches
// need to see this subagent's id as their `parentAgentId`.
const runFramed = () =>
runWithAgentContext({ agentId: hookOpts.agentId }, () =>
this.runSubagentWithHooks(subagent, contextState, hookOpts),
);
if (isFork) {
// Background fork execution. Run under an AsyncLocalStorage frame so
// nested `agent` tool calls by the fork's model can be detected.
void runInForkContext(() =>
this.runSubagentWithHooks(subagent, contextState, hookOpts),
);
void runInForkContext(runFramed);
return {
llmContent: [{ text: FORK_PLACEHOLDER_RESULT }],
returnDisplay: this.currentDisplay!,
};
} else {
await this.runSubagentWithHooks(subagent, contextState, hookOpts);
await runFramed();
const finalText = subagent.getFinalText();
const terminateMode = subagent.getTerminateMode();
if (terminateMode === AgentTerminateMode.ERROR) {