Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions integration-tests/parallel-tools.responses
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"read_file","args":{"file_path":"file1.txt"}}},{"functionCall":{"name":"read_file","args":{"file_path":"file2.txt"}}},{"functionCall":{"name":"write_file","args":{"file_path":"output.txt","content":"wave2"}}},{"functionCall":{"name":"read_file","args":{"file_path":"file3.txt"}}},{"functionCall":{"name":"read_file","args":{"file_path":"file4.txt"}}}, {"text":"All waves completed successfully."}]},"finishReason":"STOP","index":0}]}]}
77 changes: 77 additions & 0 deletions integration-tests/parallel-tools.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig } from './test-helper.js';
import { join } from 'node:path';
import fs from 'node:fs';

describe('Parallel Tool Execution Integration', () => {
let rig: TestRig;

beforeEach(() => {
rig = new TestRig();
});

afterEach(async () => {
await rig.cleanup();
});

it('should execute [read, read, write, read, read] in correct waves with user approval', async () => {
rig.setup('parallel-wave-execution', {
fakeResponsesPath: join(import.meta.dirname, 'parallel-tools.responses'),
settings: {
tools: {
core: ['read_file', 'write_file'],
approval: 'ASK', // Disable YOLO mode to show permission prompts
confirmationRequired: ['write_file'],
},
},
});

rig.createFile('file1.txt', 'c1');
rig.createFile('file2.txt', 'c2');
rig.createFile('file3.txt', 'c3');
rig.createFile('file4.txt', 'c4');
rig.sync();

const run = await rig.runInteractive({ approvalMode: 'default' });

// 1. Trigger the wave
await run.type('ok');
await run.type('\r');

// 3. Wait for the write_file prompt.
await run.expectText('Allow', 5000);

// 4. Press Enter to approve the write_file.
await run.type('y');
await run.type('\r');

// 5. Wait for the final model response
await run.expectText('All waves completed successfully.', 5000);

// Verify all tool calls were made and succeeded in the logs
await rig.expectToolCallSuccess(['write_file']);
const toolLogs = rig.readToolLogs();

const readFiles = toolLogs.filter(
(l) => l.toolRequest.name === 'read_file',
);
const writeFiles = toolLogs.filter(
(l) => l.toolRequest.name === 'write_file',
);

expect(readFiles.length).toBe(4);
expect(writeFiles.length).toBe(1);
expect(toolLogs.every((l) => l.toolRequest.success)).toBe(true);

// Check that output.txt was actually written
expect(fs.readFileSync(join(rig.testDir!, 'output.txt'), 'utf8')).toBe(
'wave2',
);
});
});
84 changes: 84 additions & 0 deletions packages/core/src/agents/subagent-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
import type { Config } from '../config/config.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import type {
DeclarativeTool,
ToolCallConfirmationDetails,
ToolInvocation,
ToolResult,
} from '../tools/tools.js';
import type { ToolRegistry } from 'src/tools/tool-registry.js';

vi.mock('./subagent-tool-wrapper.js');

Expand Down Expand Up @@ -274,3 +276,85 @@ describe('SubAgentInvocation', () => {
});
});
});

describe('SubagentTool Read-Only logic', () => {
let mockConfig: Config;
let mockMessageBus: MessageBus;

beforeEach(() => {
vi.clearAllMocks();
mockConfig = makeFakeConfig();
mockMessageBus = createMockMessageBus();
});

it('should be false for remote agents', () => {
const tool = new SubagentTool(
testRemoteDefinition,
mockConfig,
mockMessageBus,
);
expect(tool.isReadOnly).toBe(false);
});

it('should be true for local agent with only read-only tools', () => {
const readOnlyTool = {
name: 'read',
isReadOnly: true,
} as unknown as DeclarativeTool<object, ToolResult>;
const registry = {
getTool: (name: string) => (name === 'read' ? readOnlyTool : undefined),
};
vi.spyOn(mockConfig, 'getToolRegistry').mockReturnValue(
registry as unknown as ToolRegistry,
);

const defWithTools: LocalAgentDefinition = {
...testDefinition,
toolConfig: { tools: ['read'] },
};
const tool = new SubagentTool(defWithTools, mockConfig, mockMessageBus);
expect(tool.isReadOnly).toBe(true);
});

it('should be false for local agent with at least one non-read-only tool', () => {
const readOnlyTool = {
name: 'read',
isReadOnly: true,
} as unknown as DeclarativeTool<object, ToolResult>;
const mutatorTool = {
name: 'write',
isReadOnly: false,
} as unknown as DeclarativeTool<object, ToolResult>;
const registry = {
getTool: (name: string) => {
if (name === 'read') return readOnlyTool;
if (name === 'write') return mutatorTool;
return undefined;
},
};
vi.spyOn(mockConfig, 'getToolRegistry').mockReturnValue(
registry as unknown as ToolRegistry,
);

const defWithTools: LocalAgentDefinition = {
...testDefinition,
toolConfig: { tools: ['read', 'write'] },
};
const tool = new SubagentTool(defWithTools, mockConfig, mockMessageBus);
expect(tool.isReadOnly).toBe(false);
});

it('should be true for local agent with no tools', () => {
const registry = { getTool: () => undefined };
vi.spyOn(mockConfig, 'getToolRegistry').mockReturnValue(
registry as unknown as ToolRegistry,
);

const defNoTools: LocalAgentDefinition = {
...testDefinition,
toolConfig: { tools: [] },
};
const tool = new SubagentTool(defNoTools, mockConfig, mockMessageBus);
expect(tool.isReadOnly).toBe(true);
});
});
48 changes: 48 additions & 0 deletions packages/core/src/agents/subagent-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type ToolResult,
BaseToolInvocation,
type ToolCallConfirmationDetails,
isTool,
} from '../tools/tools.js';
import type { AnsiOutput } from '../utils/terminalSerializer.js';
import type { Config } from '../config/config.js';
Expand Down Expand Up @@ -48,6 +49,53 @@ export class SubagentTool extends BaseDeclarativeTool<AgentInputs, ToolResult> {
);
}

private _memoizedIsReadOnly: boolean | undefined;

override get isReadOnly(): boolean {
if (this._memoizedIsReadOnly !== undefined) {
return this._memoizedIsReadOnly;
}
// No try-catch here. If getToolRegistry() throws, we let it throw.
// This is an invariant: you can't check read-only status if the system isn't initialized.
this._memoizedIsReadOnly = SubagentTool.checkIsReadOnly(
this.definition,
this.config,
);
return this._memoizedIsReadOnly;
}

private static checkIsReadOnly(
definition: AgentDefinition,
config: Config,
): boolean {
if (definition.kind === 'remote') {
return false;
}
const tools = definition.toolConfig?.tools ?? [];
const registry = config.getToolRegistry();

if (!registry) {
return false;
}

for (const tool of tools) {
if (typeof tool === 'string') {
const resolvedTool = registry.getTool(tool);
if (!resolvedTool || !resolvedTool.isReadOnly) {
return false;
}
} else if (isTool(tool)) {
if (!tool.isReadOnly) {
return false;
}
} else {
// FunctionDeclaration - we don't know, so assume NOT read-only
return false;
}
}
return true;
}

protected createInvocation(
params: AgentInputs,
messageBus: MessageBus,
Expand Down
Loading
Loading