diff --git a/packages/core/src/tools/agent/agent-context.test.ts b/packages/core/src/tools/agent/agent-context.test.ts new file mode 100644 index 000000000..e486d9b1f --- /dev/null +++ b/packages/core/src/tools/agent/agent-context.test.ts @@ -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']); + }); +}); diff --git a/packages/core/src/tools/agent/agent-context.ts b/packages/core/src/tools/agent/agent-context.ts new file mode 100644 index 000000000..87643aefb --- /dev/null +++ b/packages/core/src/tools/agent/agent-context.ts @@ -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(); + +/** + * 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( + context: AgentContext, + fn: () => Promise, +): Promise { + 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; +} diff --git a/packages/core/src/tools/agent/agent.test.ts b/packages/core/src/tools/agent/agent.test.ts index deebda503..3d7e3531c 100644 --- a/packages/core/src/tools/agent/agent.test.ts +++ b/packages/core/src/tools/agent/agent.test.ts @@ -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)['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'); + }); + }); }); }); diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index e5bec95b0..e82e5735e 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -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 { 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 { 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 { }; } + // 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) {