mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-19 07:54:38 +00:00
fix(core): per-agent ContentGenerator view via AsyncLocalStorage (#3707)
* fix(core): per-agent ContentGenerator view via AsyncLocalStorage Sub-agents launched with a model or provider different from the parent session were not consistently seeing their own ContentGenerator config during tool execution. Tools that capture `this.config` at construction time read from the parent's instance fields, and the previous prototype- chained Config override only intercepted some of those reads. The most visible effect: a sub-agent on an image-capable model under a text-only parent would have its image reads filtered out as "Unsupported image file" because the modality check resolved against the parent's config. Replace the prototype-chained override with a runtime view published via AsyncLocalStorage. The agent runtime wraps each reasoning loop in a frame carrying the sub-agent's ContentGenerator and config; the Config getters consult the frame before falling back to the instance fields, so any tool — regardless of which Config it captured — sees the sub-agent's view inside the run. Also re-enter the runtime view in the deferred-approval continuation. The `respond` callback runs from the parent UI's input handler, on a different async chain than the reasoning loop, so without explicit re-entry the resumed tool body would fall back to the parent's view — exactly the modality-misresolution bug, but only on the user-approved path. Consolidate the two AsyncLocalStorage frames previously living in separate modules (subagent identity for nested-agent parent tracking, and the runtime CG view) into a single agent-context module with merge-on-write helpers, so the wrap layers preserve each other's fields. Auto-fill modalities from the model name in the model registry's resolved config, so sub-agents reading straight from the registry pick up the right defaults instead of inheriting the parent's. * fix(core): address per-agent ContentGenerator view review feedback Wrap the deferred-approval `onConfirm` continuation in the agent's `subagentNameContext` frame too, not just the runtime ContentGenerator view. Without it, an LLM call triggered from inside a resumed tool body (e.g. a nested agent) would mis-attribute its tokens in `/stats`. Extract the dual-frame wrap into `AgentCore.runInAgentFrames` so the reasoning loop and the deferred-approval `respond` callback share a single, testable contract. New `agent-core.test.ts` pins it: with and without a runtime view, including a fresh-async-chain invocation that mirrors the UI handing the captured `respond` back to AgentCore after the loop frame has unwound. Consolidate `RuntimeContentGeneratorView` construction. Both the Arena backend and SubagentManager were duplicating the `buildAgentContentGeneratorConfig` -> `createContentGenerator` -> wrap recipe; route both through a shared `createRuntimeContentGeneratorView` helper. Rename `SubagentManager.buildAgentRuntimeView` -> `buildRuntimeContentGeneratorView` so its name matches its return type. Add Config-getter ALS integration tests covering `getContentGenerator`, `getContentGeneratorConfig`, `getModel`, `getAuthType` inside and outside a `runWithRuntimeContentGenerator` frame, plus modality auto-fill tests for `ModelRegistry.resolveModelConfig`. Replace positional `lastCall![8]` indexing in the backend and manager tests with named `destructureAgentCoreCall` / `destructureAgentHeadlessCall` helpers, so adding a new constructor argument can no longer silently shift assertions onto the wrong slot. * fix(core): address per-agent ContentGenerator view PR review feedback - Drop the dead `?? defaultModalities` fallback in `getModelsForAuthType`; `resolveModelConfig` already guarantees the field, so dual-tracking the same invariant is just rot waiting to drift. - Refresh three stale references to the old `maybeOverrideContentGenerator` name (split into `buildSubagentContextOverride` and `buildRuntimeContentGeneratorView` in this PR). - Tighten the `expect.anything()` assertions on `createContentGenerator`'s owner argument across the in-process and subagent paths so a regression that swaps owner for base gets caught. - Add a focused unit test for `createRuntimeContentGeneratorView` asserting the new ContentGenerator binds to the per-agent owner Config rather than the parent. * fix(core): preserve inherited runtime view across deferred approvals A nested agent with `model: inherit` owns no `runtimeView` of its own and relies on its parent's view via ALS during the reasoning loop. The deferred-approval `respond` callback, however, is invoked later from the UI's input handler — a fresh async chain where the parent's ALS frame is gone — so the resumed tool body fell back to the top-level ContentGenerator. For approval-gated tools that consult modalities or auth (e.g. `read_file` of an image/PDF), this meant the wrong provider/modality table was used. Snapshot the ambient view at approval-emit time (still inside the loop frame) and pass it through `runInAgentFrames` as a fallback. The agent's own view, when set, still wins.
This commit is contained in:
parent
cfbcea1e88
commit
b8a2a245ab
21 changed files with 978 additions and 464 deletions
|
|
@ -81,6 +81,28 @@ vi.mock('../runtime/agent-core.js', () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
// Mirrors the positional AgentCore constructor parameters so tests can
|
||||
// destructure by name instead of indexing — adding new parameters can't
|
||||
// silently shift assertions onto the wrong slot.
|
||||
function destructureAgentCoreCall(call: unknown[]) {
|
||||
return {
|
||||
name: call[0] as string,
|
||||
runtimeContext: call[1] as Record<string, unknown>,
|
||||
promptConfig: call[2],
|
||||
modelConfig: call[3],
|
||||
runConfig: call[4],
|
||||
toolConfig: call[5],
|
||||
eventEmitter: call[6],
|
||||
hooks: call[7],
|
||||
runtimeView: call[8] as
|
||||
| {
|
||||
contentGenerator: unknown;
|
||||
contentGeneratorConfig: { authType: string; model?: string };
|
||||
}
|
||||
| undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockToolRegistry() {
|
||||
return {
|
||||
getFunctionDeclarations: vi.fn().mockReturnValue([]),
|
||||
|
|
@ -388,8 +410,8 @@ describe('InProcessBackend', () => {
|
|||
const lastCall = MockAgentCore.mock.calls.at(-1);
|
||||
expect(lastCall).toBeDefined();
|
||||
|
||||
// Second arg is the runtime context (Config)
|
||||
const agentContext = lastCall![1] as {
|
||||
const { runtimeContext } = destructureAgentCoreCall(lastCall!);
|
||||
const agentContext = runtimeContext as unknown as {
|
||||
getWorkingDir: () => string;
|
||||
getTargetDir: () => string;
|
||||
getToolRegistry: () => unknown;
|
||||
|
|
@ -560,6 +582,13 @@ describe('InProcessBackend', () => {
|
|||
await backend.spawnAgent(config);
|
||||
|
||||
const mockCreate = createContentGenerator as ReturnType<typeof vi.fn>;
|
||||
// Owner must be the per-agent override Config (the same instance
|
||||
// AgentCore receives as runtimeContext) — NOT the parent. Asserting
|
||||
// that match exactly catches a regression where `base` slips in.
|
||||
const MockAgentCore = AgentCore as unknown as ReturnType<typeof vi.fn>;
|
||||
const { runtimeContext: agentContext } = destructureAgentCoreCall(
|
||||
MockAgentCore.mock.calls.at(-1)!,
|
||||
);
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
authType: 'anthropic',
|
||||
|
|
@ -567,11 +596,11 @@ describe('InProcessBackend', () => {
|
|||
baseUrl: 'https://agent.example.com',
|
||||
model: 'test-model',
|
||||
}),
|
||||
expect.anything(),
|
||||
agentContext,
|
||||
);
|
||||
});
|
||||
|
||||
it('should override getContentGenerator on per-agent config', async () => {
|
||||
it('should pass per-agent ContentGenerator via runtimeView', async () => {
|
||||
const agentGenerator = { generateContentStream: vi.fn() };
|
||||
const mockCreate = createContentGenerator as ReturnType<typeof vi.fn>;
|
||||
mockCreate.mockResolvedValueOnce(agentGenerator);
|
||||
|
|
@ -588,14 +617,11 @@ describe('InProcessBackend', () => {
|
|||
|
||||
const MockAgentCore = AgentCore as unknown as ReturnType<typeof vi.fn>;
|
||||
const lastCall = MockAgentCore.mock.calls.at(-1);
|
||||
const agentContext = lastCall![1] as {
|
||||
getContentGenerator: () => unknown;
|
||||
getAuthType: () => string | undefined;
|
||||
getModel: () => string;
|
||||
};
|
||||
const { runtimeView } = destructureAgentCoreCall(lastCall!);
|
||||
|
||||
expect(agentContext.getContentGenerator()).toBe(agentGenerator);
|
||||
expect(agentContext.getAuthType()).toBe('anthropic');
|
||||
expect(runtimeView).toBeDefined();
|
||||
expect(runtimeView!.contentGenerator).toBe(agentGenerator);
|
||||
expect(runtimeView!.contentGeneratorConfig.authType).toBe('anthropic');
|
||||
expect(backend.getAgentContentGenerator('agent-1')).toBe(agentGenerator);
|
||||
});
|
||||
|
||||
|
|
@ -629,12 +655,9 @@ describe('InProcessBackend', () => {
|
|||
|
||||
const MockAgentCore = AgentCore as unknown as ReturnType<typeof vi.fn>;
|
||||
const lastCall = MockAgentCore.mock.calls.at(-1);
|
||||
const agentContext = lastCall![1] as {
|
||||
getContentGenerator: () => unknown;
|
||||
};
|
||||
|
||||
// Falls back to parent's content generator
|
||||
expect(agentContext.getContentGenerator()).toBe(mockContentGenerator);
|
||||
// No runtimeView when per-agent creation failed; agent inherits parent.
|
||||
expect(destructureAgentCoreCall(lastCall!).runtimeView).toBeUndefined();
|
||||
expect(backend.getAgentContentGenerator('agent-1')).toBeUndefined();
|
||||
});
|
||||
|
||||
|
|
@ -665,16 +688,12 @@ describe('InProcessBackend', () => {
|
|||
const MockAgentCore = AgentCore as unknown as ReturnType<typeof vi.fn>;
|
||||
const calls = MockAgentCore.mock.calls;
|
||||
|
||||
const ctx1 = calls.at(-2)![1] as {
|
||||
getContentGenerator: () => unknown;
|
||||
};
|
||||
const ctx2 = calls.at(-1)![1] as {
|
||||
getContentGenerator: () => unknown;
|
||||
};
|
||||
const view1 = calls.at(-2)![8] as { contentGenerator: unknown };
|
||||
const view2 = calls.at(-1)![8] as { contentGenerator: unknown };
|
||||
|
||||
expect(ctx1.getContentGenerator()).toBe(gen1);
|
||||
expect(ctx2.getContentGenerator()).toBe(gen2);
|
||||
expect(ctx1.getContentGenerator()).not.toBe(ctx2.getContentGenerator());
|
||||
expect(view1.contentGenerator).toBe(gen1);
|
||||
expect(view2.contentGenerator).toBe(gen2);
|
||||
expect(view1.contentGenerator).not.toBe(view2.contentGenerator);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,16 +13,12 @@
|
|||
|
||||
import { createDebugLogger } from '../../utils/debugLogger.js';
|
||||
import type { Config } from '../../config/config.js';
|
||||
import {
|
||||
type AuthType,
|
||||
type ContentGenerator,
|
||||
type ContentGeneratorConfig,
|
||||
createContentGenerator,
|
||||
} from '../../core/contentGenerator.js';
|
||||
import { type ContentGenerator } from '../../core/contentGenerator.js';
|
||||
import type { RuntimeContentGeneratorView } from '../runtime/agent-context.js';
|
||||
import type { ToolRegistry } from '../../tools/tool-registry.js';
|
||||
import { WorkspaceContext } from '../../utils/workspaceContext.js';
|
||||
import { FileDiscoveryService } from '../../services/fileDiscoveryService.js';
|
||||
import { buildAgentContentGeneratorConfig } from '../../models/content-generator-config.js';
|
||||
import { createRuntimeContentGeneratorView } from '../../models/content-generator-config.js';
|
||||
import { AgentStatus, isTerminalStatus } from '../runtime/agent-types.js';
|
||||
import { AgentCore } from '../runtime/agent-core.js';
|
||||
import { AgentEventEmitter } from '../runtime/agent-events.js';
|
||||
|
|
@ -122,6 +118,8 @@ export class InProcessBackend implements Backend {
|
|||
runConfig,
|
||||
toolConfig,
|
||||
eventEmitter,
|
||||
undefined,
|
||||
perAgent.runtimeView,
|
||||
);
|
||||
|
||||
const interactive = new AgentInteractive(
|
||||
|
|
@ -382,19 +380,26 @@ export class InProcessBackend implements Backend {
|
|||
* - `getFileService()` → FileDiscoveryService rooted at agent's cwd
|
||||
* - `getToolRegistry()` → per-agent tool registry with core tools bound to
|
||||
* the agent Config
|
||||
* - `getContentGenerator()` / `getContentGeneratorConfig()` / `getAuthType()`
|
||||
* → per-agent ContentGenerator when `authOverrides` is provided
|
||||
* - returned `contentGenerator` → the generator safe to use for summaries
|
||||
*
|
||||
* When `authOverrides` is provided, also returns a `runtimeView` describing
|
||||
* the per-agent ContentGenerator. The agent runtime publishes the view via
|
||||
* AsyncLocalStorage so the CG-related Config getters resolve to the
|
||||
* agent's values during the run.
|
||||
*/
|
||||
async function createPerAgentConfig(
|
||||
base: Config,
|
||||
cwd: string,
|
||||
modelId?: string,
|
||||
authOverrides?: InProcessSpawnConfig['authOverrides'],
|
||||
): Promise<{ config: Config; contentGenerator?: ContentGenerator }> {
|
||||
): Promise<{
|
||||
config: Config;
|
||||
contentGenerator?: ContentGenerator;
|
||||
runtimeView?: RuntimeContentGeneratorView;
|
||||
}> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const override = Object.create(base) as any;
|
||||
let dedicatedContentGenerator: ContentGenerator | undefined;
|
||||
let runtimeView: RuntimeContentGeneratorView | undefined;
|
||||
|
||||
override.getWorkingDir = () => cwd;
|
||||
override.getTargetDir = () => cwd;
|
||||
|
|
@ -415,25 +420,16 @@ async function createPerAgentConfig(
|
|||
|
||||
if (authOverrides?.authType) {
|
||||
try {
|
||||
const agentGeneratorConfig = buildAgentContentGeneratorConfig(
|
||||
runtimeView = await createRuntimeContentGeneratorView(
|
||||
base,
|
||||
override as Config,
|
||||
modelId,
|
||||
authOverrides,
|
||||
);
|
||||
const agentGenerator = await createContentGenerator(
|
||||
agentGeneratorConfig,
|
||||
override as Config,
|
||||
);
|
||||
dedicatedContentGenerator = agentGenerator;
|
||||
override.getContentGenerator = (): ContentGenerator => agentGenerator;
|
||||
override.getContentGeneratorConfig = (): ContentGeneratorConfig =>
|
||||
agentGeneratorConfig;
|
||||
override.getAuthType = (): AuthType | undefined =>
|
||||
agentGeneratorConfig.authType;
|
||||
override.getModel = (): string => agentGeneratorConfig.model;
|
||||
dedicatedContentGenerator = runtimeView.contentGenerator;
|
||||
|
||||
debugLogger.info(
|
||||
`Created per-agent ContentGenerator: authType=${authOverrides.authType}, model=${agentGeneratorConfig.model}`,
|
||||
`Created per-agent ContentGenerator: authType=${authOverrides.authType}, model=${runtimeView.contentGeneratorConfig.model}`,
|
||||
);
|
||||
} catch (error) {
|
||||
debugLogger.error(
|
||||
|
|
@ -448,5 +444,6 @@ async function createPerAgentConfig(
|
|||
contentGenerator:
|
||||
dedicatedContentGenerator ??
|
||||
(authOverrides?.authType ? undefined : base.getContentGenerator()),
|
||||
runtimeView,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import type { ChatRecord } from '../services/chatRecordingService.js';
|
|||
import { getInitialChatHistory } from '../utils/environmentContext.js';
|
||||
import { getGitBranch } from '../utils/gitUtils.js';
|
||||
import { PermissionMode, type StopHookOutput } from '../hooks/types.js';
|
||||
import { runWithAgentContext } from '../tools/agent/agent-context.js';
|
||||
import { runWithAgentContext } from './runtime/agent-context.js';
|
||||
import { createApprovalModeOverride } from '../tools/agent/agent.js';
|
||||
import type { ApprovalMode } from '../config/config.js';
|
||||
import {
|
||||
|
|
@ -784,8 +784,7 @@ export class BackgroundAgentResumeService {
|
|||
}
|
||||
};
|
||||
|
||||
const framedRunBody = () =>
|
||||
runWithAgentContext({ agentId: meta.agentId }, runBody);
|
||||
const framedRunBody = () => runWithAgentContext(meta.agentId, runBody);
|
||||
void (target.isFork ? runInForkContext(framedRunBody) : framedRunBody());
|
||||
return entry;
|
||||
} catch (error) {
|
||||
|
|
|
|||
163
packages/core/src/agents/runtime/agent-context.test.ts
Normal file
163
packages/core/src/agents/runtime/agent-context.test.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getCurrentAgentId,
|
||||
getRuntimeContentGenerator,
|
||||
runWithAgentContext,
|
||||
runWithRuntimeContentGenerator,
|
||||
type RuntimeContentGeneratorView,
|
||||
} from './agent-context.js';
|
||||
import {
|
||||
AuthType,
|
||||
type ContentGenerator,
|
||||
type ContentGeneratorConfig,
|
||||
} from '../../core/contentGenerator.js';
|
||||
|
||||
function makeView(model: string): RuntimeContentGeneratorView {
|
||||
return {
|
||||
contentGenerator: { tag: model } as unknown as ContentGenerator,
|
||||
contentGeneratorConfig: {
|
||||
model,
|
||||
authType: AuthType.USE_OPENAI,
|
||||
} as ContentGeneratorConfig,
|
||||
};
|
||||
}
|
||||
|
||||
describe('agent-context (agentId)', () => {
|
||||
it('returns null outside any frame', () => {
|
||||
expect(getCurrentAgentId()).toBeNull();
|
||||
});
|
||||
|
||||
it('exposes the agentId inside a frame', async () => {
|
||||
await runWithAgentContext('explore-abc', async () => {
|
||||
expect(getCurrentAgentId()).toBe('explore-abc');
|
||||
});
|
||||
expect(getCurrentAgentId()).toBeNull();
|
||||
});
|
||||
|
||||
it('propagates across awaits', async () => {
|
||||
await runWithAgentContext('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('outer-1', async () => {
|
||||
expect(getCurrentAgentId()).toBe('outer-1');
|
||||
await runWithAgentContext('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('a', async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
results.push(getCurrentAgentId() ?? 'null');
|
||||
}),
|
||||
runWithAgentContext('b', async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
results.push(getCurrentAgentId() ?? 'null');
|
||||
}),
|
||||
]);
|
||||
expect(results.sort()).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent-context (runtimeView)', () => {
|
||||
it('returns undefined outside any frame', () => {
|
||||
expect(getRuntimeContentGenerator()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('exposes the view to code running inside the frame', async () => {
|
||||
const view = makeView('qwen3.6-plus');
|
||||
const inner = await runWithRuntimeContentGenerator(view, async () =>
|
||||
getRuntimeContentGenerator(),
|
||||
);
|
||||
expect(inner).toBe(view);
|
||||
expect(getRuntimeContentGenerator()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('isolates sibling runs', async () => {
|
||||
const v1 = makeView('model-a');
|
||||
const v2 = makeView('model-b');
|
||||
const [seen1, seen2] = await Promise.all([
|
||||
runWithRuntimeContentGenerator(v1, async () =>
|
||||
getRuntimeContentGenerator(),
|
||||
),
|
||||
runWithRuntimeContentGenerator(v2, async () =>
|
||||
getRuntimeContentGenerator(),
|
||||
),
|
||||
]);
|
||||
expect(seen1).toBe(v1);
|
||||
expect(seen2).toBe(v2);
|
||||
});
|
||||
|
||||
it('propagates through await chains', async () => {
|
||||
const view = makeView('chained');
|
||||
const seen = await runWithRuntimeContentGenerator(view, async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
return getRuntimeContentGenerator();
|
||||
});
|
||||
expect(seen).toBe(view);
|
||||
});
|
||||
|
||||
it('lets a nested run shadow the outer view', async () => {
|
||||
const outer = makeView('outer');
|
||||
const inner = makeView('inner');
|
||||
const [seenOuter, seenInner] = await runWithRuntimeContentGenerator(
|
||||
outer,
|
||||
async () => {
|
||||
const before = getRuntimeContentGenerator();
|
||||
const after = await runWithRuntimeContentGenerator(inner, async () =>
|
||||
getRuntimeContentGenerator(),
|
||||
);
|
||||
return [before, after];
|
||||
},
|
||||
);
|
||||
expect(seenOuter).toBe(outer);
|
||||
expect(seenInner).toBe(inner);
|
||||
const outerAgain = await runWithRuntimeContentGenerator(outer, async () => {
|
||||
await runWithRuntimeContentGenerator(inner, async () => undefined);
|
||||
return getRuntimeContentGenerator();
|
||||
});
|
||||
expect(outerAgain).toBe(outer);
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent-context (merging)', () => {
|
||||
it('runtimeView wrap preserves agentId from outer frame', async () => {
|
||||
const view = makeView('inner-model');
|
||||
await runWithAgentContext('outer-agent', async () => {
|
||||
await runWithRuntimeContentGenerator(view, async () => {
|
||||
expect(getCurrentAgentId()).toBe('outer-agent');
|
||||
expect(getRuntimeContentGenerator()).toBe(view);
|
||||
});
|
||||
// After the inner run resolves, runtimeView is gone but agentId stays.
|
||||
expect(getCurrentAgentId()).toBe('outer-agent');
|
||||
expect(getRuntimeContentGenerator()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('agentId wrap preserves runtimeView from outer frame', async () => {
|
||||
const view = makeView('outer-model');
|
||||
await runWithRuntimeContentGenerator(view, async () => {
|
||||
await runWithAgentContext('inner-agent', async () => {
|
||||
expect(getRuntimeContentGenerator()).toBe(view);
|
||||
expect(getCurrentAgentId()).toBe('inner-agent');
|
||||
});
|
||||
expect(getRuntimeContentGenerator()).toBe(view);
|
||||
expect(getCurrentAgentId()).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
63
packages/core/src/agents/runtime/agent-context.ts
Normal file
63
packages/core/src/agents/runtime/agent-context.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Per-run AsyncLocalStorage frame for agent execution.
|
||||
*
|
||||
* Tools capture `this.config` at construction time, so a sub-agent running
|
||||
* with a different model cannot rely on the constructor-bound Config to
|
||||
* report the right ContentGenerator or modalities. This frame lets
|
||||
* `Config.getContentGenerator{,Config}()` resolve to the active sub-agent
|
||||
* view, and lets nested `agent` tool launches discover their parent's id —
|
||||
* both without threading extra parameters through every call site.
|
||||
*
|
||||
* Helpers patch one field at a time and merge with whatever is already on
|
||||
* the stack, so wrapping at different layers preserves every set field.
|
||||
*/
|
||||
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import type {
|
||||
ContentGenerator,
|
||||
ContentGeneratorConfig,
|
||||
} from '../../core/contentGenerator.js';
|
||||
|
||||
export interface RuntimeContentGeneratorView {
|
||||
readonly contentGenerator: ContentGenerator;
|
||||
readonly contentGeneratorConfig: ContentGeneratorConfig;
|
||||
}
|
||||
|
||||
interface AgentContext {
|
||||
readonly agentId?: string;
|
||||
readonly runtimeView?: RuntimeContentGeneratorView;
|
||||
}
|
||||
|
||||
const storage = new AsyncLocalStorage<AgentContext>();
|
||||
|
||||
export function runWithAgentContext<T>(
|
||||
agentId: string,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const current = storage.getStore() ?? {};
|
||||
return storage.run({ ...current, agentId }, fn);
|
||||
}
|
||||
|
||||
export function runWithRuntimeContentGenerator<T>(
|
||||
view: RuntimeContentGeneratorView,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const current = storage.getStore() ?? {};
|
||||
return storage.run({ ...current, runtimeView: view }, fn);
|
||||
}
|
||||
|
||||
export function getCurrentAgentId(): string | null {
|
||||
return storage.getStore()?.agentId ?? null;
|
||||
}
|
||||
|
||||
export function getRuntimeContentGenerator():
|
||||
| RuntimeContentGeneratorView
|
||||
| undefined {
|
||||
return storage.getStore()?.runtimeView;
|
||||
}
|
||||
211
packages/core/src/agents/runtime/agent-core.test.ts
Normal file
211
packages/core/src/agents/runtime/agent-core.test.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AgentCore } from './agent-core.js';
|
||||
import {
|
||||
getRuntimeContentGenerator,
|
||||
runWithRuntimeContentGenerator,
|
||||
type RuntimeContentGeneratorView,
|
||||
} from './agent-context.js';
|
||||
import { subagentNameContext } from '../../utils/subagentNameContext.js';
|
||||
import type { Config } from '../../config/config.js';
|
||||
import type { ModelConfig, PromptConfig, RunConfig } from './agent-types.js';
|
||||
import type {
|
||||
ContentGenerator,
|
||||
ContentGeneratorConfig,
|
||||
} from '../../core/contentGenerator.js';
|
||||
|
||||
describe('AgentCore.runInAgentFrames', () => {
|
||||
// The deferred-approval `respond` callback that AgentCore hands to the
|
||||
// UI must restore both ALS frames the agent normally runs under, so any
|
||||
// tool body resumed via approval — including ones that trigger LLM
|
||||
// calls — sees the agent's ContentGenerator (modalities, auth) and is
|
||||
// attributed to the agent in token stats.
|
||||
//
|
||||
// The reasoning loop uses the same wrap, so anything that breaks here
|
||||
// also breaks the synchronous path. These tests pin the contract.
|
||||
|
||||
function makeCore(name: string, runtimeView?: RuntimeContentGeneratorView) {
|
||||
const promptConfig: PromptConfig = { systemPrompt: '' };
|
||||
const modelConfig: ModelConfig = { model: 'test-model' };
|
||||
const runConfig: RunConfig = { max_turns: 1 };
|
||||
return new AgentCore(
|
||||
name,
|
||||
{} as unknown as Config,
|
||||
promptConfig,
|
||||
modelConfig,
|
||||
runConfig,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
runtimeView,
|
||||
);
|
||||
}
|
||||
|
||||
it('publishes both the runtime view and the agent name when invoked from outside any frame', async () => {
|
||||
const view: RuntimeContentGeneratorView = {
|
||||
contentGenerator: {
|
||||
generateContentStream: () => Promise.resolve(),
|
||||
} as unknown as ContentGenerator,
|
||||
contentGeneratorConfig: {
|
||||
model: 'agent-model',
|
||||
authType: 'anthropic',
|
||||
} as ContentGeneratorConfig,
|
||||
};
|
||||
const core = makeCore('image-agent', view);
|
||||
|
||||
let observedView: RuntimeContentGeneratorView | undefined;
|
||||
let observedName: string | undefined;
|
||||
await core.runInAgentFrames(async () => {
|
||||
observedView = getRuntimeContentGenerator();
|
||||
observedName = subagentNameContext.getStore();
|
||||
});
|
||||
|
||||
expect(observedView).toBe(view);
|
||||
expect(observedName).toBe('image-agent');
|
||||
});
|
||||
|
||||
it('restores frames even when called from a fresh async chain (deferred-approval path)', async () => {
|
||||
// Simulates the UI's async-input handler invoking the captured
|
||||
// `respond` callback after the reasoning-loop frame has unwound.
|
||||
// Without `runInAgentFrames` re-entering, the body would see the
|
||||
// top-level (parent) view.
|
||||
const view: RuntimeContentGeneratorView = {
|
||||
contentGenerator: {
|
||||
generateContentStream: () => Promise.resolve(),
|
||||
} as unknown as ContentGenerator,
|
||||
contentGeneratorConfig: {
|
||||
model: 'agent-model',
|
||||
authType: 'anthropic',
|
||||
} as ContentGeneratorConfig,
|
||||
};
|
||||
const core = makeCore('approval-agent', view);
|
||||
|
||||
// Capture a thunk equivalent to the `respond` closure that AgentCore
|
||||
// emits with TOOL_WAITING_APPROVAL — the wrap is identical.
|
||||
let capturedRespond: (() => Promise<void>) | undefined;
|
||||
const onConfirmInvocations: Array<{
|
||||
view: RuntimeContentGeneratorView | undefined;
|
||||
name: string | undefined;
|
||||
}> = [];
|
||||
const onConfirm = async () => {
|
||||
onConfirmInvocations.push({
|
||||
view: getRuntimeContentGenerator(),
|
||||
name: subagentNameContext.getStore(),
|
||||
});
|
||||
};
|
||||
|
||||
await core.runInAgentFrames(async () => {
|
||||
// Inside the reasoning-loop frame the agent would build the
|
||||
// closure that the UI later invokes — same shape as line 938 of
|
||||
// agent-core.ts.
|
||||
capturedRespond = () => core.runInAgentFrames(onConfirm);
|
||||
});
|
||||
|
||||
// After the loop frame has unwound, neither frame is active.
|
||||
expect(getRuntimeContentGenerator()).toBeUndefined();
|
||||
expect(subagentNameContext.getStore()).toBeUndefined();
|
||||
|
||||
// Hop to a brand-new microtask chain to be sure no parent ALS frame
|
||||
// is in scope, then invoke the captured callback.
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await capturedRespond!();
|
||||
|
||||
expect(onConfirmInvocations).toHaveLength(1);
|
||||
expect(onConfirmInvocations[0]!.view).toBe(view);
|
||||
expect(onConfirmInvocations[0]!.name).toBe('approval-agent');
|
||||
});
|
||||
|
||||
it('still publishes the agent name when no runtime view is set (inheriting agent)', async () => {
|
||||
const core = makeCore('inherit-agent');
|
||||
|
||||
let observedView: RuntimeContentGeneratorView | undefined;
|
||||
let observedName: string | undefined;
|
||||
await core.runInAgentFrames(async () => {
|
||||
observedView = getRuntimeContentGenerator();
|
||||
observedName = subagentNameContext.getStore();
|
||||
});
|
||||
|
||||
expect(observedView).toBeUndefined();
|
||||
expect(observedName).toBe('inherit-agent');
|
||||
});
|
||||
|
||||
it('uses inheritedView for deferred-approval continuation when the agent owns no view', async () => {
|
||||
// A nested `model: inherit` child under a runtime-view-bearing parent
|
||||
// owns no view of its own, but its tool bodies (e.g. `read_file`
|
||||
// checking modalities) need the parent's view. The reasoning loop
|
||||
// sees it via ALS, but the deferred-approval `respond` callback runs
|
||||
// from a fresh async chain where that frame is gone — so the agent
|
||||
// must capture it at emit time and pass it back through.
|
||||
const parentView: RuntimeContentGeneratorView = {
|
||||
contentGenerator: {
|
||||
generateContentStream: () => Promise.resolve(),
|
||||
} as unknown as ContentGenerator,
|
||||
contentGeneratorConfig: {
|
||||
model: 'parent-model',
|
||||
authType: 'anthropic',
|
||||
} as ContentGeneratorConfig,
|
||||
};
|
||||
const inheritingCore = makeCore('inherit-agent');
|
||||
|
||||
let respondClosure: (() => Promise<void>) | undefined;
|
||||
let observedView: RuntimeContentGeneratorView | undefined;
|
||||
let observedName: string | undefined;
|
||||
const onConfirm = async () => {
|
||||
observedView = getRuntimeContentGenerator();
|
||||
observedName = subagentNameContext.getStore();
|
||||
};
|
||||
|
||||
// Simulate the parent's loop frame being live at emit time.
|
||||
await runWithRuntimeContentGenerator(parentView, async () => {
|
||||
const inheritedView = getRuntimeContentGenerator();
|
||||
respondClosure = () =>
|
||||
inheritingCore.runInAgentFrames(onConfirm, inheritedView);
|
||||
});
|
||||
|
||||
// Parent frame is gone; jump to a fresh microtask chain to be sure.
|
||||
expect(getRuntimeContentGenerator()).toBeUndefined();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
await respondClosure!();
|
||||
|
||||
expect(observedView).toBe(parentView);
|
||||
expect(observedName).toBe('inherit-agent');
|
||||
});
|
||||
|
||||
it("prefers the agent's own view over inheritedView when both are present", async () => {
|
||||
// Defensive: if a future caller wires both, the agent's explicit view
|
||||
// wins — we never want a captured snapshot to override the agent's
|
||||
// declared view.
|
||||
const ownView: RuntimeContentGeneratorView = {
|
||||
contentGenerator: {
|
||||
generateContentStream: () => Promise.resolve(),
|
||||
} as unknown as ContentGenerator,
|
||||
contentGeneratorConfig: {
|
||||
model: 'own-model',
|
||||
authType: 'anthropic',
|
||||
} as ContentGeneratorConfig,
|
||||
};
|
||||
const otherView: RuntimeContentGeneratorView = {
|
||||
contentGenerator: {
|
||||
generateContentStream: () => Promise.resolve(),
|
||||
} as unknown as ContentGenerator,
|
||||
contentGeneratorConfig: {
|
||||
model: 'other-model',
|
||||
authType: 'openai',
|
||||
} as ContentGeneratorConfig,
|
||||
};
|
||||
const core = makeCore('own-view-agent', ownView);
|
||||
|
||||
let observed: RuntimeContentGeneratorView | undefined;
|
||||
await core.runInAgentFrames(async () => {
|
||||
observed = getRuntimeContentGenerator();
|
||||
}, otherView);
|
||||
|
||||
expect(observed).toBe(ownView);
|
||||
});
|
||||
});
|
||||
|
|
@ -19,6 +19,11 @@
|
|||
import { reportError } from '../../utils/errorReporting.js';
|
||||
import { subagentNameContext } from '../../utils/subagentNameContext.js';
|
||||
import type { Config } from '../../config/config.js';
|
||||
import {
|
||||
getRuntimeContentGenerator,
|
||||
runWithRuntimeContentGenerator,
|
||||
type RuntimeContentGeneratorView,
|
||||
} from './agent-context.js';
|
||||
import { type ToolCallRequestInfo } from '../../core/turn.js';
|
||||
import {
|
||||
CoreToolScheduler,
|
||||
|
|
@ -183,6 +188,14 @@ export class AgentCore {
|
|||
readonly eventEmitter: AgentEventEmitter;
|
||||
readonly hooks?: AgentHooks;
|
||||
readonly stats = new AgentStatistics();
|
||||
/**
|
||||
* When the agent runs with a model different from the parent session,
|
||||
* this view is published via AsyncLocalStorage during execution so any
|
||||
* `Config.getContentGenerator{,Config}()` call inside the run resolves
|
||||
* to the agent's values — even from tools that captured the parent
|
||||
* Config at construction.
|
||||
*/
|
||||
readonly runtimeView?: RuntimeContentGeneratorView;
|
||||
|
||||
// Observable state lives on Core (not a wrapper) so headless and
|
||||
// background agents can be observed with the same accessors as
|
||||
|
|
@ -236,6 +249,7 @@ export class AgentCore {
|
|||
toolConfig?: ToolConfig,
|
||||
eventEmitter?: AgentEventEmitter,
|
||||
hooks?: AgentHooks,
|
||||
runtimeView?: RuntimeContentGeneratorView,
|
||||
) {
|
||||
const randomPart = Math.random().toString(36).slice(2, 8);
|
||||
this.subagentId = `${name}-${randomPart}`;
|
||||
|
|
@ -247,6 +261,7 @@ export class AgentCore {
|
|||
this.toolConfig = toolConfig;
|
||||
this.eventEmitter = eventEmitter ?? new AgentEventEmitter();
|
||||
this.hooks = hooks;
|
||||
this.runtimeView = runtimeView;
|
||||
this.setupStateListeners();
|
||||
}
|
||||
|
||||
|
|
@ -433,21 +448,66 @@ export class AgentCore {
|
|||
abortController: AbortController,
|
||||
options?: ReasoningLoopOptions,
|
||||
): Promise<ReasoningLoopResult> {
|
||||
// Tag every API call emitted from this loop with the owning subagent's
|
||||
// name so the `/stats` panel can attribute tokens/requests to the
|
||||
// originating subagent. The store is read inside
|
||||
// `LoggingContentGenerator` via `subagentNameContext.getStore()`.
|
||||
return subagentNameContext.run(this.name, () =>
|
||||
const inner = () =>
|
||||
this._runReasoningLoopInner(
|
||||
chat,
|
||||
initialMessages,
|
||||
toolsList,
|
||||
abortController,
|
||||
options,
|
||||
),
|
||||
);
|
||||
return this.runInAgentFrames(inner);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn` inside both ALS frames this agent owns:
|
||||
* 1. {@link subagentNameContext} so token-attribution code resolves to
|
||||
* this agent's name.
|
||||
* 2. The per-agent runtime ContentGenerator view (when set) so
|
||||
* `Config.getContentGenerator{,Config}()` calls inside resolve to
|
||||
* the agent rather than to the parent Config tools captured at
|
||||
* construction time.
|
||||
*
|
||||
* Used both around the reasoning loop and around the deferred-approval
|
||||
* `onConfirm` continuation — the latter runs from the parent UI's input
|
||||
* handler, on a different async chain than the loop, so without this
|
||||
* re-entry the resumed tool body would fall back to the parent's view
|
||||
* and mis-attribute its tokens.
|
||||
*
|
||||
* `inheritedView` lets a caller pass an ambient view captured earlier
|
||||
* (e.g. at approval-emit time, when the parent's ALS frame is still
|
||||
* live) for inheriting agents that own no view themselves. Without it,
|
||||
* a nested `model: inherit` agent under a runtime-view-bearing parent
|
||||
* would lose that view across the deferred-approval boundary, since
|
||||
* the UI invokes `respond` from a fresh async chain where the parent's
|
||||
* ALS frame is gone.
|
||||
*
|
||||
* Exposed (rather than inlined twice) so the contract stays testable in
|
||||
* isolation; see `agent-core.test.ts`.
|
||||
*/
|
||||
runInAgentFrames<T>(
|
||||
fn: () => Promise<T>,
|
||||
inheritedView?: RuntimeContentGeneratorView,
|
||||
): Promise<T> {
|
||||
return subagentNameContext.run(this.name, () =>
|
||||
this.withRuntimeView(fn, inheritedView),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps `fn` in the effective runtime view: this agent's own view if
|
||||
* set, else `inheritedView` if the caller captured one. Internal —
|
||||
* public callers should use {@link runInAgentFrames}, which also
|
||||
* restores the subagent-name frame.
|
||||
*/
|
||||
private withRuntimeView<T>(
|
||||
fn: () => Promise<T>,
|
||||
inheritedView?: RuntimeContentGeneratorView,
|
||||
): Promise<T> {
|
||||
const view = this.runtimeView ?? inheritedView;
|
||||
return view ? runWithRuntimeContentGenerator(view, fn) : fn();
|
||||
}
|
||||
|
||||
private async _runReasoningLoopInner(
|
||||
chat: GeminiChat,
|
||||
initialMessages: Content[],
|
||||
|
|
@ -901,6 +961,12 @@ export class AgentCore {
|
|||
try {
|
||||
const { confirmationDetails } = waiting;
|
||||
const { onConfirm: _onConfirm, ...rest } = confirmationDetails;
|
||||
// Snapshot the ambient runtime view here, while the loop frame
|
||||
// is still live. For inheriting agents (no own runtimeView)
|
||||
// this captures the parent's view so the deferred-approval
|
||||
// continuation — invoked later from the UI's async chain — can
|
||||
// restore it. See `runInAgentFrames` for the wiring.
|
||||
const inheritedView = getRuntimeContentGenerator();
|
||||
this.eventEmitter?.emit(AgentEventType.TOOL_WAITING_APPROVAL, {
|
||||
subagentId: this.subagentId,
|
||||
round: currentRound,
|
||||
|
|
@ -919,7 +985,14 @@ export class AgentCore {
|
|||
) => {
|
||||
if (responded.has(waiting.request.callId)) return;
|
||||
responded.add(waiting.request.callId);
|
||||
await waiting.confirmationDetails.onConfirm(outcome, payload);
|
||||
// UI invokes this from its own async chain (outside the
|
||||
// reasoning-loop ALS frames), so re-enter both the agent's
|
||||
// runtime view AND its name context before the resumed
|
||||
// tool body runs. See `runInAgentFrames` for rationale.
|
||||
await this.runInAgentFrames(
|
||||
() => waiting.confirmationDetails.onConfirm(outcome, payload),
|
||||
inheritedView,
|
||||
);
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
import type { Content } from '@google/genai';
|
||||
import type { Config } from '../../config/config.js';
|
||||
import type { RuntimeContentGeneratorView } from './agent-context.js';
|
||||
import { createDebugLogger } from '../../utils/debugLogger.js';
|
||||
import type {
|
||||
AgentEventEmitter,
|
||||
|
|
@ -164,6 +165,7 @@ export class AgentHeadless {
|
|||
toolConfig?: ToolConfig,
|
||||
eventEmitter?: AgentEventEmitter,
|
||||
hooks?: AgentHooks,
|
||||
runtimeView?: RuntimeContentGeneratorView,
|
||||
): Promise<AgentHeadless> {
|
||||
const core = new AgentCore(
|
||||
name,
|
||||
|
|
@ -174,6 +176,7 @@ export class AgentHeadless {
|
|||
toolConfig,
|
||||
eventEmitter,
|
||||
hooks,
|
||||
runtimeView,
|
||||
);
|
||||
return new AgentHeadless(core);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2197,4 +2197,102 @@ describe('Model Switching and Config Updates', () => {
|
|||
expect(mockHasHooksForEvent).toHaveBeenCalledWith('Stop');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runtime ContentGenerator view (AsyncLocalStorage)', () => {
|
||||
// The Config getters consult the per-run ALS view published by the
|
||||
// agent runtime when a sub-agent runs on a different model than the
|
||||
// parent. These tests pin that integration: tools that captured the
|
||||
// parent Config at construction must still resolve to the agent's
|
||||
// values when called inside the agent's runtime frame.
|
||||
function setInstanceFields(
|
||||
config: Config,
|
||||
contentGenerator: ContentGenerator,
|
||||
generatorConfig: ContentGeneratorConfig,
|
||||
): void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(config as any).contentGenerator = contentGenerator;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(config as any).contentGeneratorConfig = generatorConfig;
|
||||
}
|
||||
|
||||
it('resolves getters to the runtime view inside the frame, instance fields outside', async () => {
|
||||
const { runWithRuntimeContentGenerator } = await import(
|
||||
'../agents/runtime/agent-context.js'
|
||||
);
|
||||
const config = new Config(baseParams);
|
||||
const parentGenerator = {
|
||||
generateContentStream: vi.fn(),
|
||||
} as unknown as ContentGenerator;
|
||||
const parentGeneratorConfig: ContentGeneratorConfig = {
|
||||
model: 'parent-model',
|
||||
authType: AuthType.QWEN_OAUTH,
|
||||
apiKey: 'parent-key',
|
||||
};
|
||||
setInstanceFields(config, parentGenerator, parentGeneratorConfig);
|
||||
|
||||
const agentGenerator = {
|
||||
generateContentStream: vi.fn(),
|
||||
} as unknown as ContentGenerator;
|
||||
const agentGeneratorConfig: ContentGeneratorConfig = {
|
||||
model: 'agent-model',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
apiKey: 'agent-key',
|
||||
};
|
||||
|
||||
// Outside the frame, getters resolve to the parent's instance fields.
|
||||
expect(config.getContentGenerator()).toBe(parentGenerator);
|
||||
expect(config.getContentGeneratorConfig()).toBe(parentGeneratorConfig);
|
||||
expect(config.getModel()).toBe('parent-model');
|
||||
expect(config.getAuthType()).toBe(AuthType.QWEN_OAUTH);
|
||||
|
||||
// Inside the frame, every getter resolves to the agent's view.
|
||||
await runWithRuntimeContentGenerator(
|
||||
{
|
||||
contentGenerator: agentGenerator,
|
||||
contentGeneratorConfig: agentGeneratorConfig,
|
||||
},
|
||||
async () => {
|
||||
expect(config.getContentGenerator()).toBe(agentGenerator);
|
||||
expect(config.getContentGeneratorConfig()).toBe(agentGeneratorConfig);
|
||||
expect(config.getModel()).toBe('agent-model');
|
||||
expect(config.getAuthType()).toBe(AuthType.USE_OPENAI);
|
||||
},
|
||||
);
|
||||
|
||||
// Frame exit restores resolution to the parent's instance fields.
|
||||
expect(config.getContentGenerator()).toBe(parentGenerator);
|
||||
expect(config.getModel()).toBe('parent-model');
|
||||
});
|
||||
|
||||
it('falls back to the parent model id when the runtime view config has no model', async () => {
|
||||
const { runWithRuntimeContentGenerator } = await import(
|
||||
'../agents/runtime/agent-context.js'
|
||||
);
|
||||
const config = new Config(baseParams);
|
||||
setInstanceFields(
|
||||
config,
|
||||
{ generateContentStream: vi.fn() } as unknown as ContentGenerator,
|
||||
{
|
||||
model: 'parent-model',
|
||||
authType: AuthType.QWEN_OAUTH,
|
||||
} as ContentGeneratorConfig,
|
||||
);
|
||||
|
||||
await runWithRuntimeContentGenerator(
|
||||
{
|
||||
contentGenerator: {
|
||||
generateContentStream: vi.fn(),
|
||||
} as unknown as ContentGenerator,
|
||||
contentGeneratorConfig: {
|
||||
model: '',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
} as ContentGeneratorConfig,
|
||||
},
|
||||
async () => {
|
||||
// Empty model on the runtime view falls through to modelsConfig.
|
||||
expect(config.getModel()).toBe(baseParams.model);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import {
|
|||
createContentGenerator,
|
||||
resolveContentGeneratorConfigWithSources,
|
||||
} from '../core/contentGenerator.js';
|
||||
import { getRuntimeContentGenerator } from '../agents/runtime/agent-context.js';
|
||||
|
||||
// Services
|
||||
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
|
||||
|
|
@ -1285,7 +1286,9 @@ export class Config {
|
|||
}
|
||||
|
||||
getContentGenerator(): ContentGenerator {
|
||||
return this.contentGenerator;
|
||||
return (
|
||||
getRuntimeContentGenerator()?.contentGenerator ?? this.contentGenerator
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1471,7 +1474,10 @@ export class Config {
|
|||
}
|
||||
|
||||
getContentGeneratorConfig(): ContentGeneratorConfig {
|
||||
return this.contentGeneratorConfig;
|
||||
return (
|
||||
getRuntimeContentGenerator()?.contentGeneratorConfig ??
|
||||
this.contentGeneratorConfig
|
||||
);
|
||||
}
|
||||
|
||||
getContentGeneratorConfigSources(): ContentGeneratorConfigSources {
|
||||
|
|
@ -1487,7 +1493,9 @@ export class Config {
|
|||
}
|
||||
|
||||
getModel(): string {
|
||||
return this.contentGeneratorConfig?.model || this.modelsConfig.getModel();
|
||||
return (
|
||||
this.getContentGeneratorConfig()?.model || this.modelsConfig.getModel()
|
||||
);
|
||||
}
|
||||
|
||||
onModelChange(listener: (model: string) => void): () => void {
|
||||
|
|
@ -2455,7 +2463,7 @@ export class Config {
|
|||
}
|
||||
|
||||
getAuthType(): AuthType | undefined {
|
||||
return this.contentGeneratorConfig?.authType;
|
||||
return this.getContentGeneratorConfig()?.authType;
|
||||
}
|
||||
|
||||
getCliVersion(): string | undefined {
|
||||
|
|
|
|||
|
|
@ -7,12 +7,23 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
buildAgentContentGeneratorConfig,
|
||||
createRuntimeContentGeneratorView,
|
||||
resolveCredentialField,
|
||||
} from './content-generator-config.js';
|
||||
import { createContentGenerator } from '../core/contentGenerator.js';
|
||||
import type { ContentGeneratorConfig } from '../core/contentGenerator.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { ResolvedModelConfig } from './types.js';
|
||||
|
||||
vi.mock('../core/contentGenerator.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../core/contentGenerator.js')>();
|
||||
return {
|
||||
...actual,
|
||||
createContentGenerator: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
function createMockConfig(
|
||||
parentConfig: ContentGeneratorConfig,
|
||||
resolvedModel?: ResolvedModelConfig,
|
||||
|
|
@ -211,6 +222,44 @@ describe('buildAgentContentGeneratorConfig', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('createRuntimeContentGeneratorView', () => {
|
||||
const parentConfig: ContentGeneratorConfig = {
|
||||
model: 'parent-model',
|
||||
authType: 'openai' as ContentGeneratorConfig['authType'],
|
||||
apiKey: 'parent-key',
|
||||
baseUrl: 'https://parent.example.com',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(createContentGenerator).mockReset();
|
||||
});
|
||||
|
||||
it('should bind the new ContentGenerator to contentGeneratorOwner, not base', async () => {
|
||||
const baseConfig = createMockConfig(parentConfig);
|
||||
// Distinct instance — represents the per-agent override Config.
|
||||
const ownerConfig = createMockConfig(parentConfig);
|
||||
const fakeGenerator = { generateContentStream: vi.fn() };
|
||||
vi.mocked(createContentGenerator).mockResolvedValueOnce(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
fakeGenerator as any,
|
||||
);
|
||||
|
||||
const view = await createRuntimeContentGeneratorView(
|
||||
baseConfig,
|
||||
ownerConfig,
|
||||
'custom-model',
|
||||
{ authType: 'openai' },
|
||||
);
|
||||
|
||||
expect(createContentGenerator).toHaveBeenCalledTimes(1);
|
||||
const [, ownerArg] = vi.mocked(createContentGenerator).mock.calls[0];
|
||||
expect(ownerArg).toBe(ownerConfig);
|
||||
expect(ownerArg).not.toBe(baseConfig);
|
||||
expect(view.contentGenerator).toBe(fakeGenerator);
|
||||
expect(view.contentGeneratorConfig.model).toBe('custom-model');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCredentialField', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
|
|
|
|||
|
|
@ -14,9 +14,11 @@
|
|||
|
||||
import type { Config } from '../config/config.js';
|
||||
import {
|
||||
createContentGenerator,
|
||||
type AuthType,
|
||||
type ContentGeneratorConfig,
|
||||
} from '../core/contentGenerator.js';
|
||||
import type { RuntimeContentGeneratorView } from '../agents/runtime/agent-context.js';
|
||||
import {
|
||||
AUTH_ENV_MAPPINGS,
|
||||
MODEL_GENERATION_CONFIG_FIELDS,
|
||||
|
|
@ -97,6 +99,34 @@ export function buildAgentContentGeneratorConfig(
|
|||
return nextConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose `buildAgentContentGeneratorConfig` + `createContentGenerator` into
|
||||
* a single {@link RuntimeContentGeneratorView}. Both InProcessBackend and
|
||||
* SubagentManager need the same three-step recipe; this helper centralizes
|
||||
* it so the two paths can't drift.
|
||||
*
|
||||
* `contentGeneratorOwner` is the Config instance the new ContentGenerator
|
||||
* should bind to for cwd / workspace / telemetry purposes — typically the
|
||||
* per-agent override Config when one exists, or the parent Config otherwise.
|
||||
*/
|
||||
export async function createRuntimeContentGeneratorView(
|
||||
base: Config,
|
||||
contentGeneratorOwner: Config,
|
||||
modelId: string | undefined,
|
||||
authOverrides: AuthOverrides,
|
||||
): Promise<RuntimeContentGeneratorView> {
|
||||
const contentGeneratorConfig = buildAgentContentGeneratorConfig(
|
||||
base,
|
||||
modelId,
|
||||
authOverrides,
|
||||
);
|
||||
const contentGenerator = await createContentGenerator(
|
||||
contentGeneratorConfig,
|
||||
contentGeneratorOwner,
|
||||
);
|
||||
return { contentGenerator, contentGeneratorConfig };
|
||||
}
|
||||
|
||||
function applyResolvedModelConfig(
|
||||
targetConfig: ContentGeneratorConfig,
|
||||
resolvedModel: ResolvedModelConfig,
|
||||
|
|
|
|||
|
|
@ -162,6 +162,61 @@ describe('ModelRegistry', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('modalities auto-fill', () => {
|
||||
// Sub-agents that read straight from the registry (e.g. via
|
||||
// getResolvedModel) need the registry to populate modalities for them;
|
||||
// otherwise they inherit the parent session's modalities and fail the
|
||||
// image/pdf/video gates set on tools like ReadFile.
|
||||
it('populates modalities from the model name when not provided', () => {
|
||||
const registry = new ModelRegistry({
|
||||
openai: [
|
||||
{
|
||||
id: 'gpt-4-turbo',
|
||||
name: 'GPT-4 Turbo',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
generationConfig: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const model = registry.getModel(AuthType.USE_OPENAI, 'gpt-4-turbo');
|
||||
expect(model?.generationConfig.modalities).toEqual({ image: true });
|
||||
});
|
||||
|
||||
it('preserves caller-provided modalities verbatim', () => {
|
||||
const explicitModalities = { image: true, pdf: true };
|
||||
const registry = new ModelRegistry({
|
||||
openai: [
|
||||
{
|
||||
id: 'gpt-4-turbo',
|
||||
name: 'GPT-4 Turbo',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
generationConfig: { modalities: explicitModalities },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const model = registry.getModel(AuthType.USE_OPENAI, 'gpt-4-turbo');
|
||||
expect(model?.generationConfig.modalities).toEqual(explicitModalities);
|
||||
});
|
||||
|
||||
it('returns text-only ({}) for models with no multimodal default', () => {
|
||||
const registry = new ModelRegistry({
|
||||
openai: [
|
||||
{
|
||||
id: 'qwen3-coder-plus',
|
||||
name: 'Qwen3 Coder Plus',
|
||||
baseUrl: 'https://example.invalid',
|
||||
generationConfig: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const model = registry.getModel(AuthType.USE_OPENAI, 'qwen3-coder-plus');
|
||||
expect(model?.generationConfig.modalities).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasModel', () => {
|
||||
let registry: ModelRegistry;
|
||||
|
||||
|
|
|
|||
|
|
@ -125,8 +125,9 @@ export class ModelRegistry {
|
|||
isVision: model.capabilities?.vision ?? false,
|
||||
contextWindowSize:
|
||||
model.generationConfig.contextWindowSize ?? tokenLimit(model.id),
|
||||
modalities:
|
||||
model.generationConfig.modalities ?? defaultModalities(model.id),
|
||||
// `modalities` is auto-filled in `resolveModelConfig`, so it is
|
||||
// always defined on `ResolvedModelConfig` — no fallback needed here.
|
||||
modalities: model.generationConfig.modalities,
|
||||
baseUrl: model.baseUrl,
|
||||
envKey: model.envKey,
|
||||
}));
|
||||
|
|
@ -176,12 +177,21 @@ export class ModelRegistry {
|
|||
): ResolvedModelConfig {
|
||||
this.validateModelConfig(config, authType);
|
||||
|
||||
const generationConfig = { ...(config.generationConfig ?? {}) };
|
||||
// Auto-fill modalities from the model name when the provider didn't set
|
||||
// them explicitly. Without this, downstream consumers that read straight
|
||||
// from the registry (e.g. sub-agents via getResolvedModel) would inherit
|
||||
// the parent session's modalities instead of the agent's own.
|
||||
if (generationConfig.modalities === undefined) {
|
||||
generationConfig.modalities = defaultModalities(config.id);
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
authType,
|
||||
name: config.name || config.id,
|
||||
baseUrl: config.baseUrl || this.getDefaultBaseUrl(authType),
|
||||
generationConfig: config.generationConfig ?? {},
|
||||
generationConfig,
|
||||
capabilities: config.capabilities || {},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,58 +4,27 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Config, ApprovalMode } from '../config/config.js';
|
||||
import { SubagentManager } from './subagent-manager.js';
|
||||
import type { SubagentConfig } from './types.js';
|
||||
import { ToolNames } from '../tools/tool-names.js';
|
||||
import { EditTool } from '../tools/edit.js';
|
||||
import { ReadFileTool } from '../tools/read-file.js';
|
||||
import { createApprovalModeOverride } from '../tools/agent/agent.js';
|
||||
|
||||
// The non-inherit (explicit-model) branch in maybeOverrideContentGenerator
|
||||
// builds a fresh ContentGenerator. We don't want the test to actually
|
||||
// reach the OpenAI / Anthropic SDK — replacing the factory with a stub
|
||||
// is enough to exercise the code path.
|
||||
vi.mock('../core/contentGenerator.js', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('../core/contentGenerator.js')
|
||||
>('../core/contentGenerator.js');
|
||||
return {
|
||||
...actual,
|
||||
createContentGenerator: vi.fn().mockResolvedValue({
|
||||
generateContent: vi.fn(),
|
||||
generateContentStream: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../models/content-generator-config.js', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('../models/content-generator-config.js')
|
||||
>('../models/content-generator-config.js');
|
||||
return {
|
||||
...actual,
|
||||
buildAgentContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
model: 'override-model',
|
||||
authType: 'openai',
|
||||
apiKey: 'override-key',
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Companion to `tools/agent/agent-override.test.ts`. Same regression:
|
||||
* Object.create(parent) by itself is not enough to isolate a subagent's
|
||||
* core tools from the parent's bound `EditTool` / `WriteFileTool` /
|
||||
* `ReadFileTool`. The subagent path that flows through
|
||||
* `SubagentManager.maybeOverrideContentGenerator` must rebuild the
|
||||
* tool registry on the override Config so bound tools resolve
|
||||
* `this.config` to the subagent rather than the parent — otherwise
|
||||
* mutations executed via the bound tool reach the parent's
|
||||
* FileReadCache and silently weaken prior-read enforcement.
|
||||
* `ReadFileTool`. The subagent path (which flows through
|
||||
* `SubagentManager.createAgentHeadless` →
|
||||
* `buildSubagentContextOverride`) must rebuild the tool registry on
|
||||
* the override Config so bound tools resolve `this.config` to the
|
||||
* subagent rather than the parent — otherwise mutations executed via
|
||||
* the bound tool reach the parent's FileReadCache and silently weaken
|
||||
* prior-read enforcement.
|
||||
*/
|
||||
describe('SubagentManager.maybeOverrideContentGenerator bound-tool isolation', () => {
|
||||
describe('SubagentManager.buildSubagentContextOverride bound-tool isolation', () => {
|
||||
// Bare mode keeps the registry small (ReadFile / Edit / Shell only) and
|
||||
// avoids needing extra setup for optional tools.
|
||||
const baseParams = {
|
||||
|
|
@ -70,23 +39,19 @@ describe('SubagentManager.maybeOverrideContentGenerator bound-tool isolation', (
|
|||
// The method is `private`. Cast via `unknown` to invoke it directly —
|
||||
// testing through the public `createAgentHeadless` pathway would also
|
||||
// work but pulls in a much larger graph (file IO, hooks, etc.).
|
||||
function callMaybeOverride(
|
||||
function callBuildOverride(
|
||||
manager: SubagentManager,
|
||||
config: SubagentConfig,
|
||||
base: Config,
|
||||
): Promise<Config> {
|
||||
const fn = (
|
||||
manager as unknown as {
|
||||
maybeOverrideContentGenerator: (
|
||||
c: SubagentConfig,
|
||||
b: Config,
|
||||
) => Promise<Config>;
|
||||
buildSubagentContextOverride: (b: Config) => Promise<Config>;
|
||||
}
|
||||
).maybeOverrideContentGenerator.bind(manager);
|
||||
return fn(config, base);
|
||||
).buildSubagentContextOverride.bind(manager);
|
||||
return fn(base);
|
||||
}
|
||||
|
||||
it('inherits branch: returns a Config whose registry is distinct from the parent and binds Edit/Read to the override', async () => {
|
||||
it('returns a Config whose registry is distinct from the parent and binds Edit/Read to the override', async () => {
|
||||
const parent = new Config(baseParams);
|
||||
const parentRegistry = await parent.createToolRegistry(undefined, {
|
||||
skipDiscovery: true,
|
||||
|
|
@ -96,16 +61,7 @@ describe('SubagentManager.maybeOverrideContentGenerator bound-tool isolation', (
|
|||
|
||||
const manager = new SubagentManager(parent);
|
||||
|
||||
const subagentConfig: SubagentConfig = {
|
||||
name: 'inheriting-agent',
|
||||
description: 'Inherits parent model',
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
level: 'project',
|
||||
filePath: '/test/project/.qwen/agents/inheriting-agent.md',
|
||||
// model omitted -> inherits=true branch
|
||||
};
|
||||
|
||||
const child = await callMaybeOverride(manager, subagentConfig, parent);
|
||||
const child = await callBuildOverride(manager, parent);
|
||||
|
||||
expect(child).not.toBe(parent);
|
||||
expect(child.getToolRegistry()).not.toBe(parentRegistry);
|
||||
|
|
@ -130,7 +86,7 @@ describe('SubagentManager.maybeOverrideContentGenerator bound-tool isolation', (
|
|||
expect(boundConfig.getFileReadCache()).not.toBe(parent.getFileReadCache());
|
||||
});
|
||||
|
||||
it('inherits branch: parent and child caches are independent', async () => {
|
||||
it('parent and child caches are independent', async () => {
|
||||
const parent = new Config(baseParams);
|
||||
const parentRegistry = await parent.createToolRegistry(undefined, {
|
||||
skipDiscovery: true,
|
||||
|
|
@ -139,15 +95,8 @@ describe('SubagentManager.maybeOverrideContentGenerator bound-tool isolation', (
|
|||
(parent as any).toolRegistry = parentRegistry;
|
||||
|
||||
const manager = new SubagentManager(parent);
|
||||
const subagentConfig: SubagentConfig = {
|
||||
name: 'inheriting-agent',
|
||||
description: 'Inherits parent model',
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
level: 'project',
|
||||
filePath: '/test/project/.qwen/agents/inheriting-agent.md',
|
||||
};
|
||||
|
||||
const child = await callMaybeOverride(manager, subagentConfig, parent);
|
||||
const child = await callBuildOverride(manager, parent);
|
||||
|
||||
// Record a read on parent. Child must not see it.
|
||||
const fakeStats = {
|
||||
|
|
@ -166,12 +115,12 @@ describe('SubagentManager.maybeOverrideContentGenerator bound-tool isolation', (
|
|||
expect(child.getFileReadCache().size()).toBe(0);
|
||||
});
|
||||
|
||||
it('inherits branch: skips rebuild and inherits registry via prototype when the base already has its own registry (real-world chained-override case)', async () => {
|
||||
it('skips rebuild and inherits registry via prototype when an upstream wrapper has already rebuilt the registry (real-world chained-override case)', async () => {
|
||||
// This mirrors the real-world flow: agent.ts wraps the parent in
|
||||
// `createApprovalModeOverride` (which builds R1 on the wrapper),
|
||||
// then passes that wrapper — sometimes wrapped one more level in
|
||||
// `bgConfig = Object.create(agentConfig)` for the background path —
|
||||
// through `createAgentHeadless` → `maybeOverrideContentGenerator`.
|
||||
// through `createAgentHeadless` → `buildSubagentContextOverride`.
|
||||
// We do NOT want the second layer to build a redundant R2 — that
|
||||
// would (a) waste work, (b) leak listeners on every later
|
||||
// AgentTool/SkillTool factory invocation, and (c) split the cache
|
||||
|
|
@ -205,19 +154,8 @@ describe('SubagentManager.maybeOverrideContentGenerator bound-tool isolation', (
|
|||
bgWrapper.getShouldAvoidPermissionPrompts = () => true;
|
||||
|
||||
const manager = new SubagentManager(parent);
|
||||
const subagentConfig: SubagentConfig = {
|
||||
name: 'inheriting-agent',
|
||||
description: 'Inherits parent model',
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
level: 'project',
|
||||
filePath: '/test/project/.qwen/agents/inheriting-agent.md',
|
||||
};
|
||||
|
||||
const child = await callMaybeOverride(
|
||||
manager,
|
||||
subagentConfig,
|
||||
bgWrapper as Config,
|
||||
);
|
||||
const child = await callBuildOverride(manager, bgWrapper as Config);
|
||||
|
||||
// child is still a distinct instance (Object.create) so the
|
||||
// FileReadCache lazy-init still works, but its registry must
|
||||
|
|
@ -238,113 +176,7 @@ describe('SubagentManager.maybeOverrideContentGenerator bound-tool isolation', (
|
|||
expect((childEdit as any).config).toBe(upstreamWrapper);
|
||||
});
|
||||
|
||||
it('non-inherit branch (explicit-model selector): rebuilds registry and binds Edit/Read to the override Config', async () => {
|
||||
// The non-inherit branch swaps the ContentGenerator (so the
|
||||
// subagent talks to the model the selector requests). It must
|
||||
// ALSO rebuild the tool registry — without that step explicit-model
|
||||
// subagents would still resolve their core tools' `this.config` to
|
||||
// the parent and read the parent's FileReadCache.
|
||||
const parent = new Config(baseParams);
|
||||
// Even though bare mode skips most tools, the non-inherit branch
|
||||
// requires getContentGeneratorConfig() to return something for the
|
||||
// authType fallback. Stub it minimally.
|
||||
vi.spyOn(parent, 'getContentGeneratorConfig').mockReturnValue({
|
||||
model: 'parent-model',
|
||||
authType: 'openai',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
const parentRegistry = await parent.createToolRegistry(undefined, {
|
||||
skipDiscovery: true,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(parent as any).toolRegistry = parentRegistry;
|
||||
|
||||
const manager = new SubagentManager(parent);
|
||||
const subagentConfig: SubagentConfig = {
|
||||
name: 'explicit-model-agent',
|
||||
description: 'Uses an explicit model selector',
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
level: 'project',
|
||||
filePath: '/test/project/.qwen/agents/explicit-model-agent.md',
|
||||
// Bare model ID -> non-inherits branch (parses to {modelId,
|
||||
// inherits:false}).
|
||||
model: 'override-model',
|
||||
};
|
||||
|
||||
const child = await callMaybeOverride(manager, subagentConfig, parent);
|
||||
|
||||
expect(child).not.toBe(parent);
|
||||
expect(child.getToolRegistry()).not.toBe(parentRegistry);
|
||||
|
||||
const childEdit = await child.getToolRegistry().ensureTool(ToolNames.EDIT);
|
||||
const childRead = await child
|
||||
.getToolRegistry()
|
||||
.ensureTool(ToolNames.READ_FILE);
|
||||
|
||||
expect(childEdit).toBeInstanceOf(EditTool);
|
||||
expect(childRead).toBeInstanceOf(ReadFileTool);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((childEdit as any).config).toBe(child);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((childRead as any).config).toBe(child);
|
||||
|
||||
// The bound EditTool's FileReadCache must be the override's, not
|
||||
// the parent's.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const boundConfig = (childEdit as any).config as Config;
|
||||
expect(boundConfig.getFileReadCache()).toBe(child.getFileReadCache());
|
||||
expect(boundConfig.getFileReadCache()).not.toBe(parent.getFileReadCache());
|
||||
});
|
||||
|
||||
it('non-inherit branch: skips rebuild when an upstream wrapper has already rebuilt the registry', async () => {
|
||||
const parent = new Config(baseParams);
|
||||
vi.spyOn(parent, 'getContentGeneratorConfig').mockReturnValue({
|
||||
model: 'parent-model',
|
||||
authType: 'openai',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
const parentRegistry = await parent.createToolRegistry(undefined, {
|
||||
skipDiscovery: true,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(parent as any).toolRegistry = parentRegistry;
|
||||
|
||||
const upstreamWrapper = await createApprovalModeOverride(
|
||||
parent,
|
||||
ApprovalMode.AUTO_EDIT,
|
||||
);
|
||||
const upstreamRegistry = upstreamWrapper.getToolRegistry();
|
||||
|
||||
const manager = new SubagentManager(parent);
|
||||
const subagentConfig: SubagentConfig = {
|
||||
name: 'explicit-model-agent',
|
||||
description: 'Uses an explicit model selector',
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
level: 'project',
|
||||
filePath: '/test/project/.qwen/agents/explicit-model-agent.md',
|
||||
model: 'override-model',
|
||||
};
|
||||
|
||||
const child = await callMaybeOverride(
|
||||
manager,
|
||||
subagentConfig,
|
||||
upstreamWrapper,
|
||||
);
|
||||
|
||||
// Upstream rebuild was detected via the symbol marker, so the
|
||||
// override has no own registry — it inherits via the prototype.
|
||||
expect(child.getToolRegistry()).toBe(upstreamRegistry);
|
||||
|
||||
// Bound tools resolve to upstreamWrapper, not the second-layer
|
||||
// child — same as the inherits branch's chained-override case.
|
||||
const childEdit = await child.getToolRegistry().ensureTool(ToolNames.EDIT);
|
||||
expect(childEdit).toBeInstanceOf(EditTool);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((childEdit as any).config).toBe(upstreamWrapper);
|
||||
});
|
||||
|
||||
it('inherits branch: the override approval mode (inherited via prototype) still resolves via the override Config', async () => {
|
||||
it('the override approval mode (inherited via prototype) still resolves via the override Config', async () => {
|
||||
const parent = new Config(baseParams);
|
||||
const parentRegistry = await parent.createToolRegistry(undefined, {
|
||||
skipDiscovery: true,
|
||||
|
|
@ -353,15 +185,8 @@ describe('SubagentManager.maybeOverrideContentGenerator bound-tool isolation', (
|
|||
(parent as any).toolRegistry = parentRegistry;
|
||||
|
||||
const manager = new SubagentManager(parent);
|
||||
const subagentConfig: SubagentConfig = {
|
||||
name: 'inheriting-agent',
|
||||
description: 'Inherits parent model',
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
level: 'project',
|
||||
filePath: '/test/project/.qwen/agents/inheriting-agent.md',
|
||||
};
|
||||
|
||||
const child = await callMaybeOverride(manager, subagentConfig, parent);
|
||||
const child = await callBuildOverride(manager, parent);
|
||||
|
||||
// Child has no own getApprovalMode; falls through prototype to parent.
|
||||
// Verify mutating parent's mode via setter is observed by child.
|
||||
|
|
|
|||
|
|
@ -48,6 +48,28 @@ vi.mock('../agents/runtime/agent-headless.js', () => ({
|
|||
ContextState: class {},
|
||||
}));
|
||||
|
||||
// Mirrors the positional AgentHeadless.create parameters so tests can
|
||||
// destructure by name instead of indexing — adding new parameters can't
|
||||
// silently shift assertions onto the wrong slot.
|
||||
function destructureAgentHeadlessCall(call: unknown[]) {
|
||||
return {
|
||||
name: call[0] as string,
|
||||
runtimeContext: call[1],
|
||||
promptConfig: call[2],
|
||||
modelConfig: call[3],
|
||||
runConfig: call[4],
|
||||
toolConfig: call[5],
|
||||
eventEmitter: call[6],
|
||||
hooks: call[7],
|
||||
runtimeView: call[8] as
|
||||
| {
|
||||
contentGenerator: unknown;
|
||||
contentGeneratorConfig: { authType?: string; model?: string };
|
||||
}
|
||||
| undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Mock createContentGenerator for model override tests
|
||||
const mockCreateContentGenerator = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../core/contentGenerator.js', async (importOriginal) => {
|
||||
|
|
@ -72,7 +94,7 @@ describe('SubagentManager', () => {
|
|||
{ name: 'write_file', displayName: 'Write File' },
|
||||
{ name: 'grep', displayName: 'Search Files' },
|
||||
]),
|
||||
// `maybeOverrideContentGenerator` now rebuilds the tool registry on
|
||||
// `buildSubagentContextOverride` now rebuilds the tool registry on
|
||||
// its override and copies discovered tools from this parent
|
||||
// registry. The real implementation iterates `source.tools.values()`,
|
||||
// so the stub needs a `tools` Map to avoid a TypeError.
|
||||
|
|
@ -1446,9 +1468,12 @@ System prompt 3`);
|
|||
|
||||
await manager.createAgentHeadless(config, mockConfig);
|
||||
|
||||
// Owner is the runtimeContext passed to createAgentHeadless — assert
|
||||
// the exact instance so a regression that swaps in a different Config
|
||||
// (e.g. the override) gets caught.
|
||||
expect(mockCreateContentGenerator).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ model: 'custom-model' }),
|
||||
expect.anything(),
|
||||
mockConfig,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -1462,7 +1487,7 @@ System prompt 3`);
|
|||
model: 'claude-sonnet',
|
||||
authType: 'anthropic',
|
||||
}),
|
||||
expect.anything(),
|
||||
mockConfig,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -1480,16 +1505,23 @@ System prompt 3`);
|
|||
expect(mockCreateContentGenerator).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass the overridden Config to AgentHeadless.create', async () => {
|
||||
it('should pass the agent runtimeView to AgentHeadless.create', async () => {
|
||||
const config = { ...agentConfig, model: 'custom-model' };
|
||||
const fakeGenerator = { generateContentStream: vi.fn() };
|
||||
mockCreateContentGenerator.mockResolvedValue(fakeGenerator);
|
||||
|
||||
await manager.createAgentHeadless(config, mockConfig);
|
||||
|
||||
const passedConfig = mockAgentHeadlessCreate.mock.calls[0][1];
|
||||
expect(passedConfig.getContentGenerator()).toBe(fakeGenerator);
|
||||
expect(passedConfig.getModel()).toBe('custom-model');
|
||||
const { runtimeContext, runtimeView } = destructureAgentHeadlessCall(
|
||||
mockAgentHeadlessCreate.mock.calls[0],
|
||||
);
|
||||
// Subagents always get an `Object.create(parent)` wrapper for
|
||||
// FileReadCache isolation — distinct instance, prototype === parent.
|
||||
expect(runtimeContext).not.toBe(mockConfig);
|
||||
expect(Object.getPrototypeOf(runtimeContext)).toBe(mockConfig);
|
||||
expect(runtimeView).toBeDefined();
|
||||
expect(runtimeView!.contentGenerator).toBe(fakeGenerator);
|
||||
expect(runtimeView!.contentGeneratorConfig.model).toBe('custom-model');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -35,13 +35,8 @@ import type {
|
|||
} from '../agents/runtime/agent-events.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { APPROVAL_MODES } from '../config/config.js';
|
||||
import {
|
||||
type AuthType,
|
||||
type ContentGenerator,
|
||||
type ContentGeneratorConfig,
|
||||
createContentGenerator,
|
||||
} from '../core/contentGenerator.js';
|
||||
import { buildAgentContentGeneratorConfig } from '../models/content-generator-config.js';
|
||||
import type { RuntimeContentGeneratorView } from '../agents/runtime/agent-context.js';
|
||||
import { createRuntimeContentGeneratorView } from '../models/content-generator-config.js';
|
||||
import { createDebugLogger } from '../utils/debugLogger.js';
|
||||
import { normalizeContent } from '../utils/textUtils.js';
|
||||
import { parseSubagentModelSelection } from './model-selection.js';
|
||||
|
|
@ -660,22 +655,27 @@ export class SubagentManager {
|
|||
options?.toolConfigOverride ?? runtimeConfig.toolConfig;
|
||||
|
||||
// When the model selector specifies a different provider, build a
|
||||
// per-agent Config with a dedicated ContentGenerator so the subagent
|
||||
// talks to the right API without affecting the parent process.
|
||||
const agentContext = await this.maybeOverrideContentGenerator(
|
||||
// dedicated ContentGenerator + view so the subagent talks to the
|
||||
// right API without affecting the parent process. The view is
|
||||
// applied via AsyncLocalStorage when the agent runs.
|
||||
const runtimeView = await this.buildRuntimeContentGeneratorView(
|
||||
config,
|
||||
runtimeContext,
|
||||
);
|
||||
|
||||
const subagentContext =
|
||||
await this.buildSubagentContextOverride(runtimeContext);
|
||||
|
||||
return await AgentHeadless.create(
|
||||
config.name,
|
||||
agentContext,
|
||||
subagentContext,
|
||||
promptConfig,
|
||||
modelConfig,
|
||||
runConfig,
|
||||
toolConfig,
|
||||
options?.eventEmitter,
|
||||
options?.hooks,
|
||||
runtimeView,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
|
|
@ -690,53 +690,55 @@ export class SubagentManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* When a subagent's model selector specifies a model (bare ID or
|
||||
* authType-prefixed), build a Config override with a dedicated
|
||||
* ContentGenerator so the model actually reaches the API.
|
||||
* For inherit selectors we still build a thin Object.create
|
||||
* override so the subagent gets an isolated FileReadCache via
|
||||
* the per-Config own-property machinery — returning `base`
|
||||
* directly would let the subagent share the parent's read entries
|
||||
* and silently weaken prior-read enforcement on its mutation
|
||||
* paths.
|
||||
* Build the per-subagent Config override used as the AgentHeadless
|
||||
* runtime context. The override is a thin prototype-delegation wrapper
|
||||
* (`Object.create(runtimeContext)`): no method changes, but a distinct
|
||||
* instance triggers the lazy own-property init in
|
||||
* `Config.getFileReadCache()` so the subagent gets its own cache
|
||||
* rather than inheriting the parent's recorded reads — which would
|
||||
* silently weaken prior-read enforcement on its mutation paths.
|
||||
*
|
||||
* The tool registry is also rebuilt on the override so `EditTool` /
|
||||
* `WriteFileTool` / `ReadFileTool` resolve `this.config` to the
|
||||
* subagent — without that step, the parent's cached tool instances
|
||||
* still reach the parent's FileReadCache. The rebuild is skipped when
|
||||
* a wrapper above `runtimeContext` already rebuilt one (typically
|
||||
* `agent.ts:createApprovalModeOverride`, which marks itself via a
|
||||
* Symbol-keyed flag — Symbol lookup walks the prototype chain, so
|
||||
* this also catches wrapper-on-wrapper layering like
|
||||
* `bgConfig = Object.create(agentConfig)` from the background path).
|
||||
* Rebuilding twice would waste work, leak listeners on shared
|
||||
* managers, and split caches across registry layers.
|
||||
*/
|
||||
private async maybeOverrideContentGenerator(
|
||||
private async buildSubagentContextOverride(
|
||||
runtimeContext: Config,
|
||||
): Promise<Config> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const subagentContext = Object.create(runtimeContext) as any as Config;
|
||||
if (!hasRebuiltToolRegistry(runtimeContext)) {
|
||||
await rebuildToolRegistryOnOverride(subagentContext, runtimeContext);
|
||||
}
|
||||
return subagentContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* When a subagent's model selector specifies a model (bare ID or
|
||||
* authType-prefixed), build a dedicated ContentGenerator and the view
|
||||
* the agent runtime should publish via AsyncLocalStorage during the
|
||||
* run. Returns `undefined` for inherit selectors (no override needed).
|
||||
*
|
||||
* FileReadCache isolation and tool-registry rebuilding are handled
|
||||
* separately in {@link buildSubagentContextOverride} — every subagent
|
||||
* (inherit or explicit) gets that, regardless of whether a runtime
|
||||
* view is built here.
|
||||
*/
|
||||
private async buildRuntimeContentGeneratorView(
|
||||
config: SubagentConfig,
|
||||
base: Config,
|
||||
): Promise<Config> {
|
||||
): Promise<RuntimeContentGeneratorView | undefined> {
|
||||
const selection = parseSubagentModelSelection(config.model);
|
||||
// Skip the registry rebuild if any wrapper above `base` already
|
||||
// rebuilt one (typically `agent.ts:createApprovalModeOverride`,
|
||||
// which marks itself via Symbol-keyed flag — Symbol property lookup
|
||||
// walks the prototype chain, so this also catches
|
||||
// wrapper-on-wrapper layering like
|
||||
// `bgConfig = Object.create(agentConfig)` passed in from the
|
||||
// background path). Rebuilding a second time would waste work,
|
||||
// leak listeners on shared managers (any AgentTool / SkillTool the
|
||||
// second registry later instantiates registers a change-listener
|
||||
// and the short-lived registry has no explicit stop() site), and
|
||||
// split the cache so client-level cache clears target an empty
|
||||
// second-layer cache while bound tools (still in the upstream
|
||||
// layer's registry) keep using the upstream cache.
|
||||
const upstreamRebuilt = hasRebuiltToolRegistry(base);
|
||||
|
||||
if (selection.inherits) {
|
||||
// Thin prototype-delegation override: no method changes, but a
|
||||
// distinct instance triggers the lazy-init in
|
||||
// `Config.getFileReadCache()` so the subagent gets its own
|
||||
// cache rather than inheriting the parent's.
|
||||
//
|
||||
// When no upstream rebuild has happened, also rebuild the tool
|
||||
// registry so `EditTool` / `WriteFileTool` / `ReadFileTool` are
|
||||
// bound to the override and resolve `this.config` to the subagent
|
||||
// — without that step, the parent's cached tool instances still
|
||||
// reach the parent's FileReadCache and silently weaken prior-read
|
||||
// enforcement on the subagent's mutation paths.
|
||||
const isolated = Object.create(base) as Config;
|
||||
if (!upstreamRebuilt) {
|
||||
await rebuildToolRegistryOnOverride(isolated, base);
|
||||
}
|
||||
return isolated;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const authType =
|
||||
|
|
@ -745,39 +747,18 @@ export class SubagentManager {
|
|||
authType: authType as string,
|
||||
};
|
||||
|
||||
const agentGeneratorConfig = buildAgentContentGeneratorConfig(
|
||||
const view = await createRuntimeContentGeneratorView(
|
||||
base,
|
||||
base,
|
||||
selection.modelId,
|
||||
authOverrides,
|
||||
);
|
||||
|
||||
const agentGenerator = await createContentGenerator(
|
||||
agentGeneratorConfig,
|
||||
base,
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const override = Object.create(base) as any;
|
||||
override.getContentGenerator = (): ContentGenerator => agentGenerator;
|
||||
override.getContentGeneratorConfig = (): ContentGeneratorConfig =>
|
||||
agentGeneratorConfig;
|
||||
override.getAuthType = (): AuthType | undefined =>
|
||||
agentGeneratorConfig.authType;
|
||||
override.getModel = (): string => agentGeneratorConfig.model;
|
||||
|
||||
// Rebuild the tool registry on the override so core tools resolve
|
||||
// `this.config` to the subagent — but only if the upstream caller
|
||||
// did not already build one. See the comment at the top of this
|
||||
// function for the reasoning.
|
||||
if (!upstreamRebuilt) {
|
||||
await rebuildToolRegistryOnOverride(override as Config, base);
|
||||
}
|
||||
|
||||
debugLogger.info(
|
||||
`Created per-agent ContentGenerator for subagent "${config.name}": authType=${authType}, model=${agentGeneratorConfig.model}`,
|
||||
`Created per-agent ContentGenerator for subagent "${config.name}": authType=${authType}, model=${view.contentGeneratorConfig.model}`,
|
||||
);
|
||||
|
||||
return override as Config;
|
||||
return view;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
/**
|
||||
* @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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
/**
|
||||
* @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;
|
||||
}
|
||||
|
|
@ -32,7 +32,7 @@ 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 { runWithAgentContext } from '../../agents/runtime/agent-context.js';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
|
@ -1845,12 +1845,9 @@ describe('AgentTool', () => {
|
|||
invocation as unknown as { setCallId: (id: string) => void }
|
||||
).setCallId('nested-1');
|
||||
|
||||
await runWithAgentContext(
|
||||
{ agentId: 'explore-parent-42' },
|
||||
async () => {
|
||||
await invocation.execute();
|
||||
},
|
||||
);
|
||||
await runWithAgentContext('explore-parent-42', async () => {
|
||||
await invocation.execute();
|
||||
});
|
||||
|
||||
const meta = readSidecar('monitor-nested-1');
|
||||
expect(meta.parentAgentId).toBe('explore-parent-42');
|
||||
|
|
|
|||
|
|
@ -39,7 +39,10 @@ import {
|
|||
isInForkExecution,
|
||||
runInForkContext,
|
||||
} from './fork-subagent.js';
|
||||
import { getCurrentAgentId, runWithAgentContext } from './agent-context.js';
|
||||
import {
|
||||
getCurrentAgentId,
|
||||
runWithAgentContext,
|
||||
} from '../../agents/runtime/agent-context.js';
|
||||
import {
|
||||
AgentEventEmitter,
|
||||
AgentEventType,
|
||||
|
|
@ -187,7 +190,7 @@ export const TOOL_REGISTRY_REBUILT: unique symbol = Symbol.for(
|
|||
* rebuilt its tool registry via {@link rebuildToolRegistryOnOverride}.
|
||||
*
|
||||
* Used by spawn sites that may be called with a wrapper-on-wrapper
|
||||
* argument (e.g. `subagent-manager.ts:maybeOverrideContentGenerator`
|
||||
* argument (e.g. `subagent-manager.ts:buildSubagentContextOverride`
|
||||
* receiving `bgConfig = Object.create(agentConfig)` from the
|
||||
* background-agent path) to skip a redundant rebuild.
|
||||
*/
|
||||
|
|
@ -200,7 +203,7 @@ export function hasRebuiltToolRegistry(config: Config): boolean {
|
|||
* Rebuilds the tool registry on `override` so core tools resolve
|
||||
* `this.config` to `override` instead of `base`. Used by both
|
||||
* {@link createApprovalModeOverride} and
|
||||
* `subagent-manager.ts:maybeOverrideContentGenerator` to avoid
|
||||
* `subagent-manager.ts:buildSubagentContextOverride` to avoid
|
||||
* duplicated rebuild logic.
|
||||
*
|
||||
* - `override.createToolRegistry(...)` runs on the override (so the
|
||||
|
|
@ -1409,7 +1412,7 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
|
|||
// from this subagent's model record this agent's id as their
|
||||
// `parentAgentId` in the sidecar meta.
|
||||
const framedBgBody = () =>
|
||||
runWithAgentContext({ agentId: hookOpts.agentId }, bgBody);
|
||||
runWithAgentContext(hookOpts.agentId, bgBody);
|
||||
void (isFork ? runInForkContext(framedBgBody) : framedBgBody());
|
||||
|
||||
this.updateDisplay({ status: 'background' as const }, updateOutput);
|
||||
|
|
@ -1441,13 +1444,9 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
|
|||
// SkillTool the fork's model instantiates from this registry leaks
|
||||
// its change-listener on shared SubagentManager / SkillManager.
|
||||
const runFramedFork = () =>
|
||||
runWithAgentContext({ agentId: hookOpts.agentId }, async () => {
|
||||
runWithAgentContext(hookOpts.agentId, async () => {
|
||||
try {
|
||||
await this.runSubagentWithHooks(
|
||||
subagent,
|
||||
contextState,
|
||||
hookOpts,
|
||||
);
|
||||
await this.runSubagentWithHooks(subagent, contextState, hookOpts);
|
||||
} finally {
|
||||
void agentConfig
|
||||
.getToolRegistry()
|
||||
|
|
@ -1477,7 +1476,7 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
|
|||
|
||||
const fgHookOpts = { ...hookOpts, signal: fgAbortController.signal };
|
||||
const runFramed = () =>
|
||||
runWithAgentContext({ agentId: hookOpts.agentId }, () =>
|
||||
runWithAgentContext(hookOpts.agentId, () =>
|
||||
this.runSubagentWithHooks(subagent, contextState, fgHookOpts),
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue