feat(core): subagent isolation and cleanup hardening (#23903)

This commit is contained in:
Abhi
2026-03-26 23:43:39 -04:00
committed by GitHub
parent aca8e1af05
commit 104587bae8
13 changed files with 520 additions and 133 deletions
+102 -10
View File
@@ -69,6 +69,10 @@ import {
type FunctionDeclaration,
} from '@google/genai';
import type { Config } from '../config/config.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import type { GeminiClient } from '../core/client.js';
import type { SandboxManager } from '../services/sandboxManager.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { MockTool } from '../test-utils/mock-tool.js';
import { getDirectoryContextString } from '../utils/environmentContext.js';
import { z } from 'zod';
@@ -377,10 +381,8 @@ describe('LocalAgentExecutor', () => {
describe('create (Initialization and Validation)', () => {
it('should explicitly map execution context properties to prevent unintended propagation', async () => {
const definition = createTestDefinition([LS_TOOL_NAME]);
const mockGeminiClient =
{} as unknown as import('../core/client.js').GeminiClient;
const mockSandboxManager =
{} as unknown as import('../services/sandboxManager.js').SandboxManager;
const mockGeminiClient = {} as unknown as GeminiClient;
const mockSandboxManager = {} as unknown as SandboxManager;
const extendedContext = {
config: mockConfig,
promptId: mockConfig.promptId,
@@ -391,7 +393,7 @@ describe('LocalAgentExecutor', () => {
geminiClient: mockGeminiClient,
sandboxManager: mockSandboxManager,
unintendedProperty: 'should not be here',
} as unknown as import('../config/agent-loop-context.js').AgentLoopContext;
} as unknown as AgentLoopContext;
const executor = await LocalAgentExecutor.create(
definition,
@@ -414,7 +416,7 @@ describe('LocalAgentExecutor', () => {
expect(executionContext).toBeDefined();
expect(executionContext.config).toBe(extendedContext.config);
expect(executionContext.promptId).toBe(extendedContext.promptId);
expect(executionContext.promptId).toBeDefined();
expect(executionContext.geminiClient).toBe(extendedContext.geminiClient);
expect(executionContext.sandboxManager).toBe(
extendedContext.sandboxManager,
@@ -445,7 +447,99 @@ describe('LocalAgentExecutor', () => {
expect(executionContext.messageBus).not.toBe(extendedContext.messageBus);
});
it('should create successfully with allowed tools', async () => {
it('should propagate parentSessionId from context when creating executionContext', async () => {
const parentSessionId = 'top-level-session-id';
const currentPromptId = 'subagent-a-id';
const mockGeminiClient = {} as unknown as GeminiClient;
const mockSandboxManager = {} as unknown as SandboxManager;
const mockMessageBus = {
derive: () => ({}),
} as unknown as MessageBus;
const mockToolRegistry = {
getMessageBus: () => mockMessageBus,
getAllToolNames: () => [],
sortTools: () => {},
} as unknown as ToolRegistry;
const context = {
config: mockConfig,
promptId: currentPromptId,
parentSessionId,
toolRegistry: mockToolRegistry,
promptRegistry: {} as unknown as PromptRegistry,
resourceRegistry: {} as unknown as ResourceRegistry,
geminiClient: mockGeminiClient,
sandboxManager: mockSandboxManager,
messageBus: mockMessageBus,
} as unknown as AgentLoopContext;
const definition = createTestDefinition([]);
const executor = await LocalAgentExecutor.create(definition, context);
mockModelResponse([
{
name: TASK_COMPLETE_TOOL_NAME,
args: { finalResult: 'done' },
id: 'call1',
},
]);
await executor.run({ goal: 'test' }, signal);
const chatConstructorArgs =
MockedGeminiChat.mock.calls[MockedGeminiChat.mock.calls.length - 1];
const executionContext = chatConstructorArgs[0];
expect(executionContext.parentSessionId).toBe(parentSessionId);
expect(executionContext.promptId).toBe(executor['agentId']);
});
it('should fall back to promptId if parentSessionId is missing (top-level subagent)', async () => {
const rootSessionId = 'root-session-id';
const mockGeminiClient = {} as unknown as GeminiClient;
const mockSandboxManager = {} as unknown as SandboxManager;
const mockMessageBus = {
derive: () => ({}),
} as unknown as MessageBus;
const mockToolRegistry = {
getMessageBus: () => mockMessageBus,
getAllToolNames: () => [],
sortTools: () => {},
} as unknown as ToolRegistry;
const context = {
config: mockConfig,
promptId: rootSessionId,
// parentSessionId is undefined
toolRegistry: mockToolRegistry,
promptRegistry: {} as unknown as PromptRegistry,
resourceRegistry: {} as unknown as ResourceRegistry,
geminiClient: mockGeminiClient,
sandboxManager: mockSandboxManager,
messageBus: mockMessageBus,
} as unknown as AgentLoopContext;
const definition = createTestDefinition([]);
const executor = await LocalAgentExecutor.create(definition, context);
mockModelResponse([
{
name: TASK_COMPLETE_TOOL_NAME,
args: { finalResult: 'done' },
id: 'call1',
},
]);
await executor.run({ goal: 'test' }, signal);
const chatConstructorArgs =
MockedGeminiChat.mock.calls[MockedGeminiChat.mock.calls.length - 1];
const executionContext = chatConstructorArgs[0];
expect(executionContext.parentSessionId).toBe(rootSessionId);
expect(executionContext.promptId).toBe(executor['agentId']);
});
it('should successfully with allowed tools', async () => {
const definition = createTestDefinition([LS_TOOL_NAME]);
const executor = await LocalAgentExecutor.create(
definition,
@@ -500,9 +594,7 @@ describe('LocalAgentExecutor', () => {
onActivity,
);
expect(executor['agentId']).toMatch(
new RegExp(`^${parentId}-${definition.name}-`),
);
expect(executor['agentId']).toBeDefined();
});
it('should correctly apply templates to initialMessages', async () => {
+3 -11
View File
@@ -121,7 +121,8 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
private get executionContext(): AgentLoopContext {
return {
config: this.context.config,
promptId: this.context.promptId,
promptId: this.agentId,
parentSessionId: this.context.parentSessionId || this.context.promptId, // Always preserve the main agent session ID
geminiClient: this.context.geminiClient,
sandboxManager: this.context.sandboxManager,
toolRegistry: this.toolRegistry,
@@ -255,9 +256,6 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
agentToolRegistry.sortTools();
// Get the parent prompt ID from context
const parentPromptId = context.promptId;
// Get the parent tool call ID from context
const toolContext = getToolCallContext();
const parentCallId = toolContext?.callId;
@@ -265,7 +263,6 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
return new LocalAgentExecutor(
definition,
context,
parentPromptId,
agentToolRegistry,
agentPromptRegistry,
agentResourceRegistry,
@@ -283,7 +280,6 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
private constructor(
definition: LocalAgentDefinition<TOutput>,
context: AgentLoopContext,
parentPromptId: string | undefined,
toolRegistry: ToolRegistry,
promptRegistry: PromptRegistry,
resourceRegistry: ResourceRegistry,
@@ -299,11 +295,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
this.compressionService = new ChatCompressionService();
this.parentCallId = parentCallId;
const randomIdPart = Math.random().toString(36).slice(2, 8);
// parentPromptId will be undefined if this agent is invoked directly
// (top-level), rather than as a sub-agent.
const parentPrefix = parentPromptId ? `${parentPromptId}-` : '';
this.agentId = `${parentPrefix}${this.definition.name}-${randomIdPart}`;
this.agentId = Math.random().toString(36).slice(2, 8);
}
/**