mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-10 03:59:33 +00:00
fix(core): thread nested agent identity into sidecar metadata
This commit is contained in:
parent
8877c5054f
commit
a8a637e8ca
4 changed files with 194 additions and 6 deletions
53
packages/core/src/tools/agent/agent-context.test.ts
Normal file
53
packages/core/src/tools/agent/agent-context.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
45
packages/core/src/tools/agent/agent-context.ts
Normal file
45
packages/core/src/tools/agent/agent-context.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue