mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-20 18:14:29 -07:00
feat(core): scope subagent workspace directories via AsyncLocalStorage (#24445)
This commit is contained in:
@@ -198,6 +198,27 @@ vi.mock('../utils/promptIdContext.js', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../config/scoped-config.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../config/scoped-config.js')>();
|
||||
return {
|
||||
...actual,
|
||||
runWithScopedWorkspaceContext: vi.fn(actual.runWithScopedWorkspaceContext),
|
||||
createScopedWorkspaceContext: vi.fn(actual.createScopedWorkspaceContext),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
runWithScopedWorkspaceContext,
|
||||
createScopedWorkspaceContext,
|
||||
} from '../config/scoped-config.js';
|
||||
const mockedRunWithScopedWorkspaceContext = vi.mocked(
|
||||
runWithScopedWorkspaceContext,
|
||||
);
|
||||
const mockedCreateScopedWorkspaceContext = vi.mocked(
|
||||
createScopedWorkspaceContext,
|
||||
);
|
||||
|
||||
const MockedGeminiChat = vi.mocked(GeminiChat);
|
||||
const mockedGetDirectoryContextString = vi.mocked(getDirectoryContextString);
|
||||
const mockedPromptIdContext = vi.mocked(promptIdContext);
|
||||
@@ -396,6 +417,8 @@ describe('LocalAgentExecutor', () => {
|
||||
);
|
||||
mockedLogAgentStart.mockReset();
|
||||
mockedLogAgentFinish.mockReset();
|
||||
mockedRunWithScopedWorkspaceContext.mockClear();
|
||||
mockedCreateScopedWorkspaceContext.mockClear();
|
||||
mockedPromptIdContext.getStore.mockReset();
|
||||
mockedPromptIdContext.run.mockImplementation((_id, fn) => fn());
|
||||
|
||||
@@ -885,6 +908,55 @@ describe('LocalAgentExecutor', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('run (Workspace Scoping)', () => {
|
||||
it('should use runWithScopedWorkspaceContext when workspaceDirectories is set', async () => {
|
||||
const definition = createTestDefinition();
|
||||
definition.workspaceDirectories = ['/tmp/extra-dir'];
|
||||
const executor = await LocalAgentExecutor.create(
|
||||
definition,
|
||||
mockConfig,
|
||||
onActivity,
|
||||
);
|
||||
|
||||
// Mock a simple complete_task response so run() terminates
|
||||
mockModelResponse([
|
||||
{
|
||||
name: COMPLETE_TASK_TOOL_NAME,
|
||||
args: { finalResult: 'done' },
|
||||
id: 'c1',
|
||||
},
|
||||
]);
|
||||
|
||||
await executor.run({ goal: 'test' }, signal);
|
||||
|
||||
expect(mockedCreateScopedWorkspaceContext).toHaveBeenCalledOnce();
|
||||
expect(mockedRunWithScopedWorkspaceContext).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should not use runWithScopedWorkspaceContext when workspaceDirectories is not set', async () => {
|
||||
const definition = createTestDefinition();
|
||||
const executor = await LocalAgentExecutor.create(
|
||||
definition,
|
||||
mockConfig,
|
||||
onActivity,
|
||||
);
|
||||
|
||||
// Mock a simple complete_task response so run() terminates
|
||||
mockModelResponse([
|
||||
{
|
||||
name: COMPLETE_TASK_TOOL_NAME,
|
||||
args: { finalResult: 'done' },
|
||||
id: 'c1',
|
||||
},
|
||||
]);
|
||||
|
||||
await executor.run({ goal: 'test' }, signal);
|
||||
|
||||
expect(mockedCreateScopedWorkspaceContext).not.toHaveBeenCalled();
|
||||
expect(mockedRunWithScopedWorkspaceContext).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('run (Execution Loop and Logic)', () => {
|
||||
it('should log AgentFinish with error if run throws', async () => {
|
||||
const definition = createTestDefinition();
|
||||
|
||||
@@ -73,6 +73,10 @@ import {
|
||||
formatBackgroundCompletionForModel,
|
||||
} from '../utils/fastAckHelper.js';
|
||||
import type { InjectionSource } from '../config/injectionService.js';
|
||||
import {
|
||||
createScopedWorkspaceContext,
|
||||
runWithScopedWorkspaceContext,
|
||||
} from '../config/scoped-config.js';
|
||||
import { CompleteTaskTool } from '../tools/complete-task.js';
|
||||
import { COMPLETE_TASK_TOOL_NAME } from '../tools/definitions/base-declarations.js';
|
||||
|
||||
@@ -521,6 +525,27 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
* @returns A promise that resolves to the agent's final output.
|
||||
*/
|
||||
async run(inputs: AgentInputs, signal: AbortSignal): Promise<OutputObject> {
|
||||
// If the agent definition declares additional workspace directories,
|
||||
// wrap execution in a scoped workspace context. All calls to
|
||||
// Config.getWorkspaceContext() within this scope will see the extended
|
||||
// directories, without mutating the shared Config.
|
||||
const dirs = this.definition.workspaceDirectories;
|
||||
if (dirs && dirs.length > 0) {
|
||||
const scopedCtx = createScopedWorkspaceContext(
|
||||
this.context.config.getWorkspaceContext(),
|
||||
dirs,
|
||||
);
|
||||
return runWithScopedWorkspaceContext(scopedCtx, () =>
|
||||
this.runInternal(inputs, signal),
|
||||
);
|
||||
}
|
||||
return this.runInternal(inputs, signal);
|
||||
}
|
||||
|
||||
private async runInternal(
|
||||
inputs: AgentInputs,
|
||||
signal: AbortSignal,
|
||||
): Promise<OutputObject> {
|
||||
const startTime = Date.now();
|
||||
let turnCounter = 0;
|
||||
let terminateReason: AgentTerminateMode = AgentTerminateMode.ERROR;
|
||||
|
||||
@@ -150,4 +150,11 @@ describe('MemoryManagerAgent', () => {
|
||||
const agent = MemoryManagerAgent(createMockConfig());
|
||||
expect(agent.modelConfig.model).toBe('flash');
|
||||
});
|
||||
|
||||
it('should declare workspaceDirectories containing the global .gemini directory', () => {
|
||||
const agent = MemoryManagerAgent(createMockConfig());
|
||||
const globalGeminiDir = Storage.getGlobalGeminiDir();
|
||||
expect(agent.workspaceDirectories).toBeDefined();
|
||||
expect(agent.workspaceDirectories).toContain(globalGeminiDir);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -131,6 +131,7 @@ reply with what you need, and exit. Do not search the codebase for the missing c
|
||||
modelConfig: {
|
||||
model: GEMINI_MODEL_ALIAS_FLASH,
|
||||
},
|
||||
workspaceDirectories: [globalGeminiDir],
|
||||
toolConfig: {
|
||||
tools: [
|
||||
READ_FILE_TOOL_NAME,
|
||||
|
||||
@@ -281,21 +281,11 @@ export class AgentRegistry {
|
||||
}
|
||||
|
||||
// Register the memory manager agent as a replacement for the save_memory tool.
|
||||
// The agent declares its own workspaceDirectories (e.g. ~/.gemini) which are
|
||||
// scoped to its execution via runWithScopedWorkspaceContext in LocalAgentExecutor,
|
||||
// keeping the main agent's workspace context clean.
|
||||
if (this.config.isMemoryManagerEnabled()) {
|
||||
this.registerLocalAgent(MemoryManagerAgent(this.config));
|
||||
|
||||
// Ensure the global .gemini directory is accessible to tools.
|
||||
// This allows the save_memory agent to read and write to it.
|
||||
// Access control is enforced by the Policy Engine (memory-manager.toml).
|
||||
try {
|
||||
const globalDir = Storage.getGlobalGeminiDir();
|
||||
this.config.getWorkspaceContext().addDirectory(globalDir);
|
||||
} catch (e) {
|
||||
debugLogger.warn(
|
||||
`[AgentRegistry] Could not add global .gemini directory to workspace:`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -215,6 +215,18 @@ export interface LocalAgentDefinition<
|
||||
// Optional configs
|
||||
toolConfig?: ToolConfig;
|
||||
|
||||
/**
|
||||
* Optional additional workspace directories scoped to this agent.
|
||||
* When provided, the agent receives a workspace context that extends
|
||||
* the parent's with these directories. Other agents and the main
|
||||
* session are unaffected. If omitted, the parent workspace context
|
||||
* is inherited unchanged.
|
||||
*
|
||||
* Note: Filesystem root paths (e.g. `/` or `C:\`) are rejected at
|
||||
* runtime to prevent accidentally granting access to the entire filesystem.
|
||||
*/
|
||||
workspaceDirectories?: string[];
|
||||
|
||||
/**
|
||||
* Optional inline MCP servers for this agent.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user