mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-29 11:10:59 -07:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 061a986f09 | |||
| 805a83de3f | |||
| b826256f3e | |||
| 05bc0399f3 | |||
| 3409de774c | |||
| 175ffc452b | |||
| 544df749af | |||
| 56c8d7e985 |
@@ -22,7 +22,7 @@ get_issue_labels() {
|
||||
# Check cache
|
||||
case "${ISSUE_LABELS_CACHE_FLAT}" in
|
||||
*"|${ISSUE_NUM}:"*)
|
||||
local suffix="${ISSUE_LABELS_CACHE_FLAT#*|${ISSUE_NUM}:}"
|
||||
local suffix="${ISSUE_LABELS_CACHE_FLAT#*|"${ISSUE_NUM}":}"
|
||||
echo "${suffix%%|*}"
|
||||
return
|
||||
;;
|
||||
|
||||
@@ -224,8 +224,6 @@ jobs:
|
||||
if: |
|
||||
always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
|
||||
runs-on: 'gemini-cli-windows-16-core'
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
|
||||
@@ -315,6 +313,7 @@ jobs:
|
||||
needs:
|
||||
- 'e2e_linux'
|
||||
- 'e2e_mac'
|
||||
- 'e2e_windows'
|
||||
- 'evals'
|
||||
- 'merge_queue_skipper'
|
||||
runs-on: 'gemini-cli-ubuntu-16-core'
|
||||
@@ -323,6 +322,7 @@ jobs:
|
||||
run: |
|
||||
if [[ ${{ needs.e2e_linux.result }} != 'success' || \
|
||||
${{ needs.e2e_mac.result }} != 'success' || \
|
||||
${{ needs.e2e_windows.result }} != 'success' || \
|
||||
${{ needs.evals.result }} != 'success' ]]; then
|
||||
echo "One or more E2E jobs failed."
|
||||
exit 1
|
||||
|
||||
@@ -360,7 +360,6 @@ jobs:
|
||||
runs-on: 'gemini-cli-windows-16-core'
|
||||
needs: 'merge_queue_skipper'
|
||||
if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}"
|
||||
continue-on-error: true
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -458,6 +457,7 @@ jobs:
|
||||
- 'link_checker'
|
||||
- 'test_linux'
|
||||
- 'test_mac'
|
||||
- 'test_windows'
|
||||
- 'codeql'
|
||||
- 'bundle_size'
|
||||
runs-on: 'gemini-cli-ubuntu-16-core'
|
||||
@@ -468,6 +468,7 @@ jobs:
|
||||
(${{ needs.link_checker.result }} != 'success' && ${{ needs.link_checker.result }} != 'skipped') || \
|
||||
(${{ needs.test_linux.result }} != 'success' && ${{ needs.test_linux.result }} != 'skipped') || \
|
||||
(${{ needs.test_mac.result }} != 'success' && ${{ needs.test_mac.result }} != 'skipped') || \
|
||||
(${{ needs.test_windows.result }} != 'success' && ${{ needs.test_windows.result }} != 'skipped') || \
|
||||
(${{ needs.codeql.result }} != 'success' && ${{ needs.codeql.result }} != 'skipped') || \
|
||||
(${{ needs.bundle_size.result }} != 'success' && ${{ needs.bundle_size.result }} != 'skipped') ]]; then
|
||||
echo "One or more CI jobs failed."
|
||||
|
||||
@@ -27,6 +27,7 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
model:
|
||||
- 'gemini-3.1-pro-preview-customtools'
|
||||
- 'gemini-3-pro-preview'
|
||||
- 'gemini-3-flash-preview'
|
||||
- 'gemini-2.5-pro'
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
|
||||
|
||||
name: 'PR rate limiter'
|
||||
|
||||
permissions: {}
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- 'opened'
|
||||
- 'reopened'
|
||||
|
||||
jobs:
|
||||
limit:
|
||||
runs-on: 'gemini-cli-ubuntu-16-core'
|
||||
permissions:
|
||||
contents: 'read'
|
||||
pull-requests: 'write'
|
||||
steps:
|
||||
- name: 'Limit open pull requests per user'
|
||||
uses: 'Homebrew/actions/limit-pull-requests@9ceb7934560eb61d131dde205a6c2d77b2e1529d' # master
|
||||
with:
|
||||
except-author-associations: |
|
||||
MEMBER
|
||||
OWNER
|
||||
comment-limit: 8
|
||||
comment: >
|
||||
You already have 7 pull requests open. Please work on getting
|
||||
existing PRs merged before opening more.
|
||||
close-limit: 8
|
||||
close: true
|
||||
+13
-12
@@ -78,22 +78,23 @@ describe('Frugal reads eval', () => {
|
||||
).toBe(true);
|
||||
|
||||
let totalLinesRead = 0;
|
||||
const readRanges: { offset: number; limit: number }[] = [];
|
||||
const readRanges: { start_line: number; end_line: number }[] = [];
|
||||
|
||||
for (const call of targetFileReads) {
|
||||
const args = JSON.parse(call.toolRequest.args);
|
||||
|
||||
expect(
|
||||
args.limit,
|
||||
'Agent read the entire file (missing limit) instead of using ranged read',
|
||||
args.end_line,
|
||||
'Agent read the entire file (missing end_line) instead of using ranged read',
|
||||
).toBeDefined();
|
||||
|
||||
const limit = args.limit;
|
||||
const offset = args.offset ?? 0;
|
||||
totalLinesRead += limit;
|
||||
readRanges.push({ offset, limit });
|
||||
const end_line = args.end_line;
|
||||
const start_line = args.start_line ?? 1;
|
||||
const linesRead = end_line - start_line + 1;
|
||||
totalLinesRead += linesRead;
|
||||
readRanges.push({ start_line, end_line });
|
||||
|
||||
expect(args.limit, 'Agent read too many lines at once').toBeLessThan(
|
||||
expect(linesRead, 'Agent read too many lines at once').toBeLessThan(
|
||||
1001,
|
||||
);
|
||||
}
|
||||
@@ -108,7 +109,7 @@ describe('Frugal reads eval', () => {
|
||||
const errorLines = [500, 510, 520];
|
||||
for (const line of errorLines) {
|
||||
const covered = readRanges.some(
|
||||
(range) => line >= range.offset && line < range.offset + range.limit,
|
||||
(range) => line >= range.start_line && line <= range.end_line,
|
||||
);
|
||||
expect(covered, `Agent should have read around line ${line}`).toBe(
|
||||
true,
|
||||
@@ -191,8 +192,8 @@ describe('Frugal reads eval', () => {
|
||||
for (const call of targetFileReads) {
|
||||
const args = JSON.parse(call.toolRequest.args);
|
||||
expect(
|
||||
args.limit,
|
||||
'Agent should have used ranged read (limit) to save tokens',
|
||||
args.end_line,
|
||||
'Agent should have used ranged read (end_line) to save tokens',
|
||||
).toBeDefined();
|
||||
}
|
||||
},
|
||||
@@ -253,7 +254,7 @@ describe('Frugal reads eval', () => {
|
||||
// and just read the whole file to be efficient with tool calls.
|
||||
const readEntireFile = targetFileReads.some((call) => {
|
||||
const args = JSON.parse(call.toolRequest.args);
|
||||
return args.limit === undefined;
|
||||
return args.end_line === undefined;
|
||||
});
|
||||
|
||||
expect(
|
||||
|
||||
@@ -68,7 +68,7 @@ describe('Frugal Search', () => {
|
||||
const args = getParams(call);
|
||||
return (
|
||||
args.file_path === 'src/legacy_processor.ts' &&
|
||||
(args.limit === undefined || args.limit === null)
|
||||
(args.end_line === undefined || args.end_line === null)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -87,7 +87,7 @@ describe('Frugal Search', () => {
|
||||
if (
|
||||
call.toolRequest.name === 'read_file' &&
|
||||
args.file_path === 'src/legacy_processor.ts' &&
|
||||
args.limit !== undefined
|
||||
args.end_line !== undefined
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ describe('interactive_commands', () => {
|
||||
const scaffoldCall = logs.find(
|
||||
(l) =>
|
||||
l.toolRequest.name === 'run_shell_command' &&
|
||||
/npm (init|create)|npx create-|yarn create|pnpm create/.test(
|
||||
/npm (init|create)|npx (.*)?create-|yarn create|pnpm create/.test(
|
||||
l.toolRequest.args,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -520,4 +520,77 @@ describe('ToolConfirmationMessage', () => {
|
||||
expect(output).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should show MCP tool details expand hint for MCP confirmations', async () => {
|
||||
const confirmationDetails: ToolCallConfirmationDetails = {
|
||||
type: 'mcp',
|
||||
title: 'Confirm MCP Tool',
|
||||
serverName: 'test-server',
|
||||
toolName: 'test-tool',
|
||||
toolDisplayName: 'Test Tool',
|
||||
toolArgs: {
|
||||
url: 'https://www.google.co.jp',
|
||||
},
|
||||
toolDescription: 'Navigates browser to a URL.',
|
||||
toolParameterSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: {
|
||||
type: 'string',
|
||||
description: 'Destination URL',
|
||||
},
|
||||
},
|
||||
required: ['url'],
|
||||
},
|
||||
onConfirm: vi.fn(),
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ToolConfirmationMessage
|
||||
callId="test-call-id"
|
||||
confirmationDetails={confirmationDetails}
|
||||
config={mockConfig}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('MCP Tool Details:');
|
||||
expect(output).toContain('(press Ctrl+O to expand MCP tool details)');
|
||||
expect(output).not.toContain('https://www.google.co.jp');
|
||||
expect(output).not.toContain('Navigates browser to a URL.');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should omit empty MCP invocation arguments from details', async () => {
|
||||
const confirmationDetails: ToolCallConfirmationDetails = {
|
||||
type: 'mcp',
|
||||
title: 'Confirm MCP Tool',
|
||||
serverName: 'test-server',
|
||||
toolName: 'test-tool',
|
||||
toolDisplayName: 'Test Tool',
|
||||
toolArgs: {},
|
||||
toolDescription: 'No arguments required.',
|
||||
onConfirm: vi.fn(),
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ToolConfirmationMessage
|
||||
callId="test-call-id"
|
||||
confirmationDetails={confirmationDetails}
|
||||
config={mockConfig}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('MCP Tool Details:');
|
||||
expect(output).toContain('(press Ctrl+O to expand MCP tool details)');
|
||||
expect(output).not.toContain('Invocation Arguments:');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { useMemo, useCallback, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { DiffRenderer } from './DiffRenderer.js';
|
||||
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
|
||||
@@ -29,6 +29,7 @@ import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||
import { keyMatchers, Command } from '../../keyMatchers.js';
|
||||
import { formatCommand } from '../../utils/keybindingUtils.js';
|
||||
import {
|
||||
REDIRECTION_WARNING_NOTE_LABEL,
|
||||
REDIRECTION_WARNING_NOTE_TEXT,
|
||||
@@ -64,6 +65,17 @@ export const ToolConfirmationMessage: React.FC<
|
||||
terminalWidth,
|
||||
}) => {
|
||||
const { confirm, isDiffingEnabled } = useToolActions();
|
||||
const [mcpDetailsExpansionState, setMcpDetailsExpansionState] = useState<{
|
||||
callId: string;
|
||||
expanded: boolean;
|
||||
}>({
|
||||
callId,
|
||||
expanded: false,
|
||||
});
|
||||
const isMcpToolDetailsExpanded =
|
||||
mcpDetailsExpansionState.callId === callId
|
||||
? mcpDetailsExpansionState.expanded
|
||||
: false;
|
||||
|
||||
const settings = useSettings();
|
||||
const allowPermanentApproval =
|
||||
@@ -86,9 +98,81 @@ export const ToolConfirmationMessage: React.FC<
|
||||
[confirm, callId],
|
||||
);
|
||||
|
||||
const mcpToolDetailsText = useMemo(() => {
|
||||
if (confirmationDetails.type !== 'mcp') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const detailsLines: string[] = [];
|
||||
const hasNonEmptyToolArgs =
|
||||
confirmationDetails.toolArgs !== undefined &&
|
||||
!(
|
||||
typeof confirmationDetails.toolArgs === 'object' &&
|
||||
confirmationDetails.toolArgs !== null &&
|
||||
Object.keys(confirmationDetails.toolArgs).length === 0
|
||||
);
|
||||
if (hasNonEmptyToolArgs) {
|
||||
let argsText: string;
|
||||
try {
|
||||
argsText = stripUnsafeCharacters(
|
||||
JSON.stringify(confirmationDetails.toolArgs, null, 2),
|
||||
);
|
||||
} catch {
|
||||
argsText = '[unserializable arguments]';
|
||||
}
|
||||
detailsLines.push('Invocation Arguments:');
|
||||
detailsLines.push(argsText);
|
||||
}
|
||||
|
||||
const description = confirmationDetails.toolDescription?.trim();
|
||||
if (description) {
|
||||
if (detailsLines.length > 0) {
|
||||
detailsLines.push('');
|
||||
}
|
||||
detailsLines.push('Description:');
|
||||
detailsLines.push(stripUnsafeCharacters(description));
|
||||
}
|
||||
|
||||
if (confirmationDetails.toolParameterSchema !== undefined) {
|
||||
let schemaText: string;
|
||||
try {
|
||||
schemaText = stripUnsafeCharacters(
|
||||
JSON.stringify(confirmationDetails.toolParameterSchema, null, 2),
|
||||
);
|
||||
} catch {
|
||||
schemaText = '[unserializable schema]';
|
||||
}
|
||||
if (detailsLines.length > 0) {
|
||||
detailsLines.push('');
|
||||
}
|
||||
detailsLines.push('Input Schema:');
|
||||
detailsLines.push(schemaText);
|
||||
}
|
||||
|
||||
if (detailsLines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return detailsLines.join('\n');
|
||||
}, [confirmationDetails]);
|
||||
|
||||
const hasMcpToolDetails = !!mcpToolDetailsText;
|
||||
const expandDetailsHintKey = formatCommand(Command.SHOW_MORE_LINES);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (!isFocused) return false;
|
||||
if (
|
||||
confirmationDetails.type === 'mcp' &&
|
||||
hasMcpToolDetails &&
|
||||
keyMatchers[Command.SHOW_MORE_LINES](key)
|
||||
) {
|
||||
setMcpDetailsExpansionState({
|
||||
callId,
|
||||
expanded: !isMcpToolDetailsExpanded,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
handleConfirm(ToolConfirmationOutcome.Cancel);
|
||||
return true;
|
||||
@@ -100,7 +184,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: isFocused },
|
||||
{ isActive: isFocused, priority: true },
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
@@ -504,12 +588,31 @@ export const ToolConfirmationMessage: React.FC<
|
||||
|
||||
bodyContent = (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.text.link}>
|
||||
MCP Server: {sanitizeForDisplay(mcpProps.serverName)}
|
||||
</Text>
|
||||
<Text color={theme.text.link}>
|
||||
Tool: {sanitizeForDisplay(mcpProps.toolName)}
|
||||
</Text>
|
||||
<>
|
||||
<Text color={theme.text.link}>
|
||||
MCP Server: {sanitizeForDisplay(mcpProps.serverName)}
|
||||
</Text>
|
||||
<Text color={theme.text.link}>
|
||||
Tool: {sanitizeForDisplay(mcpProps.toolName)}
|
||||
</Text>
|
||||
</>
|
||||
{hasMcpToolDetails && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color={theme.text.primary}>MCP Tool Details:</Text>
|
||||
{isMcpToolDetailsExpanded ? (
|
||||
<>
|
||||
<Text color={theme.text.secondary}>
|
||||
(press {expandDetailsHintKey} to collapse MCP tool details)
|
||||
</Text>
|
||||
<Text color={theme.text.link}>{mcpToolDetailsText}</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text color={theme.text.secondary}>
|
||||
(press {expandDetailsHintKey} to expand MCP tool details)
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -522,8 +625,17 @@ export const ToolConfirmationMessage: React.FC<
|
||||
terminalWidth,
|
||||
handleConfirm,
|
||||
deceptiveUrlWarningText,
|
||||
isMcpToolDetailsExpanded,
|
||||
hasMcpToolDetails,
|
||||
mcpToolDetailsText,
|
||||
expandDetailsHintKey,
|
||||
]);
|
||||
|
||||
const bodyOverflowDirection: 'top' | 'bottom' =
|
||||
confirmationDetails.type === 'mcp' && isMcpToolDetailsExpanded
|
||||
? 'bottom'
|
||||
: 'top';
|
||||
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
if (confirmationDetails.isModifying) {
|
||||
return (
|
||||
@@ -559,7 +671,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
<MaxSizedBox
|
||||
maxHeight={availableBodyContentHeight()}
|
||||
maxWidth={terminalWidth}
|
||||
overflowDirection="top"
|
||||
overflowDirection={bodyOverflowDirection}
|
||||
>
|
||||
{bodyContent}
|
||||
</MaxSizedBox>
|
||||
|
||||
@@ -352,7 +352,6 @@ describe('keyMatchers', () => {
|
||||
createKey('l', { ctrl: true }),
|
||||
],
|
||||
},
|
||||
|
||||
// Shell commands
|
||||
{
|
||||
command: Command.REVERSE_SEARCH,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { Agent } from './agent.js';
|
||||
import { AgentSession } from './session.js';
|
||||
import { makeFakeConfig } from '../test-utils/config.js';
|
||||
import { type AgentConfig } from './types.js';
|
||||
|
||||
vi.mock('./session.js', () => ({
|
||||
AgentSession: vi.fn().mockImplementation(() => ({
|
||||
prompt: vi.fn().mockImplementation(async function* () {
|
||||
yield { type: 'agent_start', value: { sessionId: 'test-session' } };
|
||||
yield {
|
||||
type: 'agent_finish',
|
||||
value: { sessionId: 'test-session', totalTurns: 1 },
|
||||
};
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('Agent', () => {
|
||||
let mockConfig: ReturnType<typeof makeFakeConfig>;
|
||||
const agentConfig: AgentConfig = {
|
||||
name: 'TestAgent',
|
||||
systemInstruction: 'You are a test agent.',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockConfig = makeFakeConfig();
|
||||
vi.spyOn(mockConfig, 'getSessionId').mockReturnValue('global-session-id');
|
||||
});
|
||||
|
||||
it('should create an AgentSession', () => {
|
||||
const agent = new Agent(agentConfig, mockConfig);
|
||||
const session = agent.createSession('custom-session-id');
|
||||
|
||||
expect(session).toBeDefined();
|
||||
expect(AgentSession).toHaveBeenCalledWith(
|
||||
'custom-session-id',
|
||||
agentConfig,
|
||||
mockConfig,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use global session ID if none provided to createSession', () => {
|
||||
const agent = new Agent(agentConfig, mockConfig);
|
||||
agent.createSession();
|
||||
|
||||
expect(AgentSession).toHaveBeenCalledWith(
|
||||
'global-session-id',
|
||||
agentConfig,
|
||||
mockConfig,
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass config and runtime to the session', () => {
|
||||
const agent = new Agent(agentConfig, mockConfig);
|
||||
agent.createSession('test-id');
|
||||
|
||||
expect(AgentSession).toHaveBeenCalledWith(
|
||||
'test-id',
|
||||
agentConfig,
|
||||
mockConfig,
|
||||
);
|
||||
});
|
||||
|
||||
it('should prompt through a new session', async () => {
|
||||
const agent = new Agent(agentConfig, mockConfig);
|
||||
const events = [];
|
||||
for await (const event of agent.prompt('Hello')) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0].type).toBe('agent_start');
|
||||
expect(events[1].type).toBe('agent_finish');
|
||||
expect(AgentSession).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type Part } from '@google/genai';
|
||||
import { type Config } from '../config/config.js';
|
||||
import { type AgentEvent, type AgentConfig } from './types.js';
|
||||
import { AgentSession } from './session.js';
|
||||
|
||||
/**
|
||||
* The Agent class is a factory for creating stateful AgentSessions.
|
||||
* This represents a configured agent template.
|
||||
*/
|
||||
export class Agent {
|
||||
constructor(
|
||||
private readonly config: AgentConfig,
|
||||
private readonly runtime: Config,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Creates a new stateful session for interacting with the agent.
|
||||
*/
|
||||
createSession(sessionId?: string): AgentSession {
|
||||
const id = sessionId ?? this.runtime.getSessionId();
|
||||
return new AgentSession(id, this.config, this.runtime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to quickly run a single prompt and get the results.
|
||||
*/
|
||||
prompt(
|
||||
input: string | Part[],
|
||||
sessionId?: string,
|
||||
signal?: AbortSignal,
|
||||
): AsyncIterable<AgentEvent> {
|
||||
const session = this.createSession(sessionId);
|
||||
return session.prompt(input, signal);
|
||||
}
|
||||
}
|
||||
@@ -546,11 +546,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
// === UNIFIED RECOVERY BLOCK ===
|
||||
// Only attempt recovery if it's a known recoverable reason.
|
||||
// We don't recover from GOAL (already done) or ABORTED (user cancelled).
|
||||
if (
|
||||
terminateReason !== AgentTerminateMode.ERROR &&
|
||||
terminateReason !== AgentTerminateMode.ABORTED &&
|
||||
terminateReason !== AgentTerminateMode.GOAL
|
||||
) {
|
||||
if (this.isRecoverableReason(terminateReason)) {
|
||||
const recoveryResult = await this.executeFinalWarningTurn(
|
||||
chat,
|
||||
turnCounter, // Use current turnCounter for the recovery attempt
|
||||
@@ -1256,6 +1252,23 @@ Important Rules:
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the agent should attempt a recovery turn for the given reason.
|
||||
*/
|
||||
private isRecoverableReason(
|
||||
reason: AgentTerminateMode,
|
||||
): reason is
|
||||
| AgentTerminateMode.TIMEOUT
|
||||
| AgentTerminateMode.MAX_TURNS
|
||||
| AgentTerminateMode.ERROR_NO_COMPLETE_TASK_CALL {
|
||||
return (
|
||||
reason !== AgentTerminateMode.ERROR &&
|
||||
reason !== AgentTerminateMode.ABORTED &&
|
||||
reason !== AgentTerminateMode.GOAL &&
|
||||
reason !== AgentTerminateMode.LOOP
|
||||
);
|
||||
}
|
||||
|
||||
/** Emits an activity event to the configured callback. */
|
||||
private emitActivity(
|
||||
type: SubagentActivityEvent['type'],
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { AgentSession } from './session.js';
|
||||
import { makeFakeConfig } from '../test-utils/config.js';
|
||||
import {
|
||||
type AgentConfig,
|
||||
AgentTerminateMode,
|
||||
type AgentEvent,
|
||||
type AgentFinishEvent,
|
||||
type ToolSuiteStartEvent,
|
||||
type ToolCallFinishEvent,
|
||||
} from './types.js';
|
||||
import { Scheduler } from '../scheduler/scheduler.js';
|
||||
import { GeminiEventType, CompressionStatus } from '../core/turn.js';
|
||||
import { ChatCompressionService } from '../services/chatCompressionService.js';
|
||||
import {
|
||||
MessageBusType,
|
||||
type ToolCallsUpdateMessage,
|
||||
} from '../confirmation-bus/types.js';
|
||||
import {
|
||||
CoreToolCallStatus,
|
||||
type ToolCallRequestInfo,
|
||||
} from '../scheduler/types.js';
|
||||
import { type ResumedSessionData } from '../services/chatRecordingService.js';
|
||||
import { type GeminiClient } from '../core/client.js';
|
||||
|
||||
vi.mock('../core/client.js');
|
||||
vi.mock('../scheduler/scheduler.js');
|
||||
vi.mock('../services/chatCompressionService.js');
|
||||
|
||||
describe('AgentSession', () => {
|
||||
let mockConfig: ReturnType<typeof makeFakeConfig>;
|
||||
let mockClient: {
|
||||
sendMessageStream: ReturnType<typeof vi.fn>;
|
||||
isInitialized: ReturnType<typeof vi.fn>;
|
||||
initialize: ReturnType<typeof vi.fn>;
|
||||
getChat: ReturnType<typeof vi.fn>;
|
||||
getCurrentSequenceModel: ReturnType<typeof vi.fn>;
|
||||
getHistory: ReturnType<typeof vi.fn>;
|
||||
resumeChat: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockScheduler: {
|
||||
schedule: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockCompressionService: {
|
||||
compress: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let session: AgentSession;
|
||||
const agentConfig: AgentConfig = {
|
||||
name: 'TestAgent',
|
||||
systemInstruction: 'You are a test agent.',
|
||||
capabilities: { compression: true },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockConfig = makeFakeConfig();
|
||||
|
||||
mockClient = {
|
||||
sendMessageStream: vi.fn(),
|
||||
isInitialized: vi.fn().mockReturnValue(false),
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
getChat: vi.fn().mockReturnValue({
|
||||
recordCompletedToolCalls: vi.fn(),
|
||||
setHistory: vi.fn(),
|
||||
getHistory: vi.fn().mockReturnValue([]),
|
||||
setSystemInstruction: vi.fn(),
|
||||
}),
|
||||
getCurrentSequenceModel: vi.fn().mockReturnValue('test-model'),
|
||||
getHistory: vi.fn().mockReturnValue([]),
|
||||
resumeChat: vi.fn(),
|
||||
};
|
||||
|
||||
mockScheduler = {
|
||||
schedule: vi.fn(),
|
||||
};
|
||||
|
||||
mockCompressionService = {
|
||||
compress: vi.fn().mockResolvedValue({
|
||||
newHistory: null,
|
||||
info: { compressionStatus: CompressionStatus.NOOP },
|
||||
}),
|
||||
};
|
||||
|
||||
vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue(
|
||||
mockClient as unknown as GeminiClient,
|
||||
);
|
||||
vi.mocked(Scheduler).mockImplementation(
|
||||
(options) =>
|
||||
({
|
||||
...mockScheduler,
|
||||
schedulerId: (options as { schedulerId: string }).schedulerId,
|
||||
}) as unknown as Scheduler,
|
||||
);
|
||||
vi.mocked(ChatCompressionService).mockImplementation(
|
||||
() => mockCompressionService as unknown as ChatCompressionService,
|
||||
);
|
||||
|
||||
session = new AgentSession('test-session', agentConfig, mockConfig);
|
||||
});
|
||||
|
||||
it('should emit agent_start and agent_finish', async () => {
|
||||
mockClient.sendMessageStream.mockImplementation(async function* () {
|
||||
yield { type: GeminiEventType.Content, value: 'Hello' };
|
||||
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
|
||||
});
|
||||
|
||||
const events = [];
|
||||
for await (const event of session.prompt('Hi')) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
const finishEvent = events[events.length - 1] as AgentFinishEvent;
|
||||
expect(events[0].type).toBe('agent_start');
|
||||
expect(finishEvent.type).toBe('agent_finish');
|
||||
expect(finishEvent.value.reason).toBe(AgentTerminateMode.GOAL);
|
||||
expect(mockClient.sendMessageStream).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle tool calls and execute them via MessageBus updates', async () => {
|
||||
mockClient.sendMessageStream.mockImplementationOnce(async function* () {
|
||||
yield {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: { callId: 'call1', name: 'test_tool', args: {} },
|
||||
};
|
||||
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
|
||||
});
|
||||
|
||||
mockClient.sendMessageStream.mockImplementationOnce(async function* () {
|
||||
yield { type: GeminiEventType.Content, value: 'Tool executed' };
|
||||
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
|
||||
});
|
||||
|
||||
const toolResponse = {
|
||||
response: {
|
||||
callId: 'call1',
|
||||
responseParts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'test_tool',
|
||||
response: { ok: true },
|
||||
id: 'call1',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
mockScheduler.schedule.mockImplementation(async () => {
|
||||
const bus = mockConfig.getMessageBus();
|
||||
const schedulerId = (session as unknown as { schedulerId: string })
|
||||
.schedulerId;
|
||||
|
||||
await bus.publish({
|
||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||
schedulerId,
|
||||
toolCalls: [
|
||||
{
|
||||
request: { callId: 'call1', name: 'test_tool', args: {} },
|
||||
status: CoreToolCallStatus.Executing,
|
||||
schedulerId,
|
||||
} as unknown as ToolCallsUpdateMessage['toolCalls'][number],
|
||||
],
|
||||
});
|
||||
|
||||
await bus.publish({
|
||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||
schedulerId,
|
||||
toolCalls: [
|
||||
{
|
||||
request: { callId: 'call1', name: 'test_tool', args: {} },
|
||||
status: CoreToolCallStatus.Success,
|
||||
response: toolResponse.response,
|
||||
schedulerId,
|
||||
} as unknown as ToolCallsUpdateMessage['toolCalls'][number],
|
||||
],
|
||||
});
|
||||
|
||||
return [toolResponse];
|
||||
});
|
||||
|
||||
const events = [];
|
||||
for await (const event of session.prompt('Run tool')) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(mockClient.sendMessageStream).toHaveBeenCalledTimes(2);
|
||||
expect(mockScheduler.schedule).toHaveBeenCalledTimes(1);
|
||||
|
||||
const callStart = events.find((e) => e.type === 'tool_call_start');
|
||||
const callFinish = events.find((e) => e.type === 'tool_call_finish');
|
||||
expect(callStart).toBeDefined();
|
||||
expect(callFinish).toBeDefined();
|
||||
expect((callFinish as ToolCallFinishEvent).value.callId).toBe('call1');
|
||||
});
|
||||
|
||||
it('should handle multiple consecutive ReAct turns', async () => {
|
||||
// Turn 1: tool1
|
||||
mockClient.sendMessageStream.mockImplementationOnce(async function* () {
|
||||
yield {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: { callId: 'c1', name: 'tool1', args: {} },
|
||||
};
|
||||
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
|
||||
});
|
||||
// Turn 2: tool2
|
||||
mockClient.sendMessageStream.mockImplementationOnce(async function* () {
|
||||
yield {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: { callId: 'c2', name: 'tool2', args: {} },
|
||||
};
|
||||
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
|
||||
});
|
||||
// Turn 3: final content
|
||||
mockClient.sendMessageStream.mockImplementationOnce(async function* () {
|
||||
yield { type: GeminiEventType.Content, value: 'All done' };
|
||||
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
|
||||
});
|
||||
|
||||
mockScheduler.schedule.mockImplementation(async (calls) =>
|
||||
(calls as ToolCallRequestInfo[]).map((c) => ({
|
||||
response: { callId: c.callId, responseParts: [] },
|
||||
})),
|
||||
);
|
||||
|
||||
const events = [];
|
||||
for await (const event of session.prompt('Start multistep')) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(mockClient.sendMessageStream).toHaveBeenCalledTimes(3);
|
||||
expect(mockScheduler.schedule).toHaveBeenCalledTimes(2);
|
||||
expect(events.filter((e) => e.type === 'tool_suite_start')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should handle parallel tool calls in a single turn', async () => {
|
||||
mockClient.sendMessageStream.mockImplementationOnce(async function* () {
|
||||
yield {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: { callId: 'p1', name: 'toolA', args: {} },
|
||||
};
|
||||
yield {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: { callId: 'p2', name: 'toolB', args: {} },
|
||||
};
|
||||
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
|
||||
});
|
||||
mockClient.sendMessageStream.mockImplementationOnce(async function* () {
|
||||
yield { type: GeminiEventType.Content, value: 'Parallel done' };
|
||||
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
|
||||
});
|
||||
|
||||
mockScheduler.schedule.mockImplementation(async (calls) =>
|
||||
(calls as ToolCallRequestInfo[]).map((c) => ({
|
||||
response: { callId: c.callId, responseParts: [] },
|
||||
})),
|
||||
);
|
||||
|
||||
const events = [];
|
||||
for await (const event of session.prompt('Parallel')) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
const suiteStart = events.find(
|
||||
(e) => e.type === 'tool_suite_start',
|
||||
) as ToolSuiteStartEvent;
|
||||
expect(suiteStart.value.count).toBe(2);
|
||||
expect(mockScheduler.schedule).toHaveBeenCalledTimes(1);
|
||||
expect(mockScheduler.schedule).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ callId: 'p1' }),
|
||||
expect.objectContaining({ callId: 'p2' }),
|
||||
]),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should resume from session data', async () => {
|
||||
const resumeData = {
|
||||
conversation: {
|
||||
messages: [{ type: 'user', content: 'Hello' }],
|
||||
},
|
||||
} as unknown as ResumedSessionData;
|
||||
|
||||
await session.resume(resumeData);
|
||||
|
||||
expect(mockClient.resumeChat).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ role: 'user', parts: [{ text: 'Hello' }] }),
|
||||
]),
|
||||
resumeData,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle model stream errors gracefully', async () => {
|
||||
mockClient.sendMessageStream.mockImplementationOnce(async function* () {
|
||||
yield* []; // satisfy require-yield
|
||||
throw new Error('Model connection lost');
|
||||
});
|
||||
|
||||
const events: AgentEvent[] = [];
|
||||
try {
|
||||
for await (const event of session.prompt('Error test')) {
|
||||
events.push(event);
|
||||
}
|
||||
} catch (_e) {
|
||||
// Expected error
|
||||
}
|
||||
|
||||
const finishEvent = events.find(
|
||||
(e) => e.type === 'agent_finish',
|
||||
) as AgentFinishEvent;
|
||||
expect(finishEvent).toBeDefined();
|
||||
expect(finishEvent.value.reason).toBe(AgentTerminateMode.ERROR);
|
||||
expect(finishEvent.value.message).toBe('Model connection lost');
|
||||
});
|
||||
|
||||
it('should ignore MessageBus updates from other schedulers', async () => {
|
||||
mockClient.sendMessageStream.mockImplementationOnce(async function* () {
|
||||
yield {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: { callId: 'call1', name: 'test_tool', args: {} },
|
||||
};
|
||||
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
|
||||
});
|
||||
mockClient.sendMessageStream.mockImplementationOnce(async function* () {
|
||||
yield { type: GeminiEventType.Content, value: 'Done' };
|
||||
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
|
||||
});
|
||||
|
||||
mockScheduler.schedule.mockImplementation(async () => {
|
||||
const bus = mockConfig.getMessageBus();
|
||||
|
||||
// Update from ANOTHER scheduler
|
||||
await bus.publish({
|
||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||
schedulerId: 'different-scheduler',
|
||||
toolCalls: [
|
||||
{
|
||||
request: { callId: 'call1', name: 'test_tool', args: {} },
|
||||
status: CoreToolCallStatus.Executing,
|
||||
schedulerId: 'different-scheduler',
|
||||
} as unknown as ToolCallsUpdateMessage['toolCalls'][number],
|
||||
],
|
||||
});
|
||||
|
||||
return [{ response: { callId: 'call1', responseParts: [] } }];
|
||||
});
|
||||
|
||||
const events = [];
|
||||
for await (const event of session.prompt('Isolation test')) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
// Should NOT see tool_call_start because it was from a different schedulerId
|
||||
expect(events.find((e) => e.type === 'tool_call_start')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should terminate with LOOP when loop is detected by model', async () => {
|
||||
mockClient.sendMessageStream.mockImplementationOnce(async function* () {
|
||||
yield { type: GeminiEventType.LoopDetected };
|
||||
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
|
||||
});
|
||||
|
||||
const events = [];
|
||||
for await (const event of session.prompt('Loop')) {
|
||||
events.push(event);
|
||||
}
|
||||
const finishEvent = events.find(
|
||||
(e) => e.type === 'agent_finish',
|
||||
) as AgentFinishEvent;
|
||||
expect(finishEvent.value.reason).toBe(AgentTerminateMode.LOOP);
|
||||
expect(finishEvent.value.message).toContain('Loop detected');
|
||||
});
|
||||
|
||||
it('should handle Part[] input correctly', async () => {
|
||||
mockClient.sendMessageStream.mockImplementationOnce(async function* () {
|
||||
yield { type: GeminiEventType.Content, value: 'I see parts' };
|
||||
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
|
||||
});
|
||||
|
||||
const input = [{ text: 'Hello' }, { text: 'World' }];
|
||||
for await (const _ of session.prompt(input)) {
|
||||
// consume
|
||||
}
|
||||
|
||||
expect(mockClient.sendMessageStream).toHaveBeenCalledWith(
|
||||
input,
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
false,
|
||||
input,
|
||||
);
|
||||
});
|
||||
|
||||
it('should trigger compression if enabled', async () => {
|
||||
mockClient.sendMessageStream.mockImplementation(async function* () {
|
||||
yield { type: GeminiEventType.Content, value: 'Done' };
|
||||
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
|
||||
});
|
||||
|
||||
for await (const _ of session.prompt('Compress me')) {
|
||||
// consume stream to trigger compression
|
||||
}
|
||||
|
||||
expect(mockCompressionService.compress).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should respect abort signal', async () => {
|
||||
const controller = new AbortController();
|
||||
mockClient.sendMessageStream.mockImplementation(async function* () {
|
||||
yield { type: GeminiEventType.Content, value: 'Thinking...' };
|
||||
controller.abort();
|
||||
yield { type: GeminiEventType.Content, value: 'Still thinking...' };
|
||||
});
|
||||
|
||||
const events = [];
|
||||
for await (const event of session.prompt('Long task', controller.signal)) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
const finishEvent = events[events.length - 1] as AgentFinishEvent;
|
||||
expect(finishEvent.type).toBe('agent_finish');
|
||||
expect(finishEvent.value.reason).toBe(AgentTerminateMode.ABORTED);
|
||||
});
|
||||
|
||||
it('should apply systemInstruction from AgentConfig', async () => {
|
||||
const customConfig: AgentConfig = {
|
||||
...agentConfig,
|
||||
systemInstruction: 'You are a helpful assistant.',
|
||||
};
|
||||
|
||||
// Mock isInitialized to true so constructor can set it
|
||||
mockClient.isInitialized.mockReturnValue(true);
|
||||
const mockChat = { setSystemInstruction: vi.fn() };
|
||||
mockClient.getChat.mockReturnValue(mockChat);
|
||||
|
||||
// Re-create to trigger constructor logic
|
||||
new AgentSession('test-session-3', customConfig, mockConfig);
|
||||
expect(mockChat.setSystemInstruction).toHaveBeenCalledWith(
|
||||
'You are a helpful assistant.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should abort internal operations if caller stops iterating', async () => {
|
||||
let internalSignal: AbortSignal | undefined;
|
||||
|
||||
mockClient.sendMessageStream.mockImplementation(async function* (
|
||||
_parts: unknown,
|
||||
signal: AbortSignal,
|
||||
) {
|
||||
internalSignal = signal;
|
||||
yield { type: GeminiEventType.Content, value: 'Part 1' };
|
||||
yield { type: GeminiEventType.Content, value: 'Part 2' };
|
||||
});
|
||||
|
||||
const promptStream = session.prompt('Test cancellation');
|
||||
const iterator = promptStream[Symbol.asyncIterator]();
|
||||
|
||||
const firstEvent = await iterator.next();
|
||||
expect(firstEvent.value?.type).toBe('agent_start');
|
||||
|
||||
const secondEvent = await iterator.next(); // content Part 1
|
||||
expect(secondEvent.value?.type).toBe(GeminiEventType.Content);
|
||||
|
||||
// Caller stops here and closes the generator
|
||||
if (iterator.return) {
|
||||
await iterator.return();
|
||||
}
|
||||
|
||||
expect(internalSignal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('should respect maxTurns from config', async () => {
|
||||
const customSession = new AgentSession(
|
||||
'test-session-2',
|
||||
{ ...agentConfig, maxTurns: 2 },
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
mockClient.sendMessageStream.mockImplementation(async function* () {
|
||||
yield {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: { callId: 'call', name: 'test_tool', args: {} },
|
||||
};
|
||||
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
|
||||
});
|
||||
|
||||
mockScheduler.schedule.mockResolvedValue([
|
||||
{
|
||||
response: {
|
||||
callId: 'call',
|
||||
responseParts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'test_tool',
|
||||
response: { ok: true },
|
||||
id: 'call',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const events = [];
|
||||
for await (const event of customSession.prompt('Start loop')) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(mockScheduler.schedule).toHaveBeenCalledTimes(2);
|
||||
|
||||
const finishEvent = events[events.length - 1] as AgentFinishEvent;
|
||||
expect(finishEvent.type).toBe('agent_finish');
|
||||
expect(finishEvent.value.totalTurns).toBe(2);
|
||||
expect(finishEvent.value.reason).toBe(AgentTerminateMode.MAX_TURNS);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,469 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type Part } from '@google/genai';
|
||||
import { type Config } from '../config/config.js';
|
||||
import { type GeminiClient } from '../core/client.js';
|
||||
import { type AgentEvent, type AgentConfig } from './types.js';
|
||||
import { Scheduler } from '../scheduler/scheduler.js';
|
||||
import {
|
||||
type ToolCallRequestInfo,
|
||||
type ToolCallResponseInfo,
|
||||
CoreToolCallStatus,
|
||||
type CompletedToolCall,
|
||||
} from '../scheduler/types.js';
|
||||
import { GeminiEventType, CompressionStatus } from '../core/turn.js';
|
||||
import { recordToolCallInteractions } from '../code_assist/telemetry.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { ToolErrorType } from '../tools/tool-error.js';
|
||||
import { ChatCompressionService } from '../services/chatCompressionService.js';
|
||||
import { AgentTerminateMode } from './types.js';
|
||||
import type { ResumedSessionData } from '../services/chatRecordingService.js';
|
||||
import { convertSessionToClientHistory } from '../utils/sessionUtils.js';
|
||||
import {
|
||||
MessageBusType,
|
||||
type ToolCallsUpdateMessage,
|
||||
} from '../confirmation-bus/types.js';
|
||||
|
||||
/** Result of a single model turn in the ReAct loop. */
|
||||
export interface ModelTurnResult {
|
||||
/** The specific tool calls requested by the model. */
|
||||
toolCalls: ToolCallRequestInfo[];
|
||||
/** The unified event stream from this model turn. */
|
||||
events: AsyncIterable<AgentEvent>;
|
||||
/** Whether an infinite tool loop was detected. */
|
||||
loopDetected: boolean;
|
||||
}
|
||||
|
||||
/** Result of executing a batch of tool calls. */
|
||||
export interface ToolExecutionResult {
|
||||
/** The response parts from the tool execution to be sent back to the model. */
|
||||
nextParts: Part[];
|
||||
/** Whether execution should stop immediately (e.g. on fatal tool error). */
|
||||
stopExecution: boolean;
|
||||
/** Optional details if execution was stopped. */
|
||||
stopExecutionInfo: ToolCallResponseInfo | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* AgentSession manages the state of a conversation and orchestrates the agent
|
||||
* loop.
|
||||
*/
|
||||
export class AgentSession {
|
||||
readonly sessionId: string;
|
||||
private readonly client: GeminiClient;
|
||||
private readonly scheduler: Scheduler;
|
||||
private readonly schedulerId: string;
|
||||
private readonly compressionService: ChatCompressionService;
|
||||
private totalTurns = 0;
|
||||
private hasFailedCompressionAttempt = false;
|
||||
|
||||
constructor(
|
||||
sessionId: string,
|
||||
private readonly config: AgentConfig,
|
||||
private readonly runtime: Config,
|
||||
) {
|
||||
this.sessionId = sessionId;
|
||||
this.client = this.runtime.getGeminiClient();
|
||||
this.schedulerId = `agent-scheduler-${this.sessionId}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
this.scheduler = new Scheduler({
|
||||
config: this.runtime,
|
||||
messageBus: this.runtime.getMessageBus(),
|
||||
getPreferredEditor: () => undefined,
|
||||
schedulerId: this.schedulerId,
|
||||
});
|
||||
this.compressionService = new ChatCompressionService();
|
||||
|
||||
// Ensure system instruction is set from AgentConfig
|
||||
if (this.config.systemInstruction) {
|
||||
if (this.client.isInitialized()) {
|
||||
this.client
|
||||
.getChat()
|
||||
.setSystemInstruction(this.config.systemInstruction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumes the agent session from persistent storage data.
|
||||
* Hydrates the internal language model client with the previously saved trajectory.
|
||||
*
|
||||
* @param resumedSessionData The raw payload of a previously saved session.
|
||||
*/
|
||||
async resume(resumedSessionData: ResumedSessionData): Promise<void> {
|
||||
const clientHistory = convertSessionToClientHistory(
|
||||
resumedSessionData.conversation.messages,
|
||||
);
|
||||
await this.client.resumeChat(clientHistory, resumedSessionData);
|
||||
|
||||
// Re-apply system instruction after resume since resume re-creates the chat
|
||||
if (this.config.systemInstruction) {
|
||||
this.client.getChat().setSystemInstruction(this.config.systemInstruction);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the ReAct loop for a given user input.
|
||||
* Returns an AsyncIterable of events occurring during the session.
|
||||
*/
|
||||
async *prompt(
|
||||
input: string | Part[],
|
||||
signal?: AbortSignal,
|
||||
): AsyncIterable<AgentEvent> {
|
||||
const internalController = new AbortController();
|
||||
const combinedSignal = signal
|
||||
? AbortSignal.any([signal, internalController.signal])
|
||||
: internalController.signal;
|
||||
|
||||
yield {
|
||||
type: 'agent_start',
|
||||
value: { sessionId: this.sessionId },
|
||||
};
|
||||
|
||||
let terminationReason = AgentTerminateMode.GOAL;
|
||||
let terminationMessage: string | undefined = undefined;
|
||||
let terminationError: unknown | undefined = undefined;
|
||||
|
||||
try {
|
||||
const loop = this._runLoop(input, combinedSignal);
|
||||
for await (const event of loop) {
|
||||
if (event.type === GeminiEventType.LoopDetected) {
|
||||
terminationReason = AgentTerminateMode.LOOP;
|
||||
terminationMessage = 'Loop detected, stopping execution';
|
||||
}
|
||||
yield event;
|
||||
}
|
||||
|
||||
if (combinedSignal.aborted) {
|
||||
terminationReason = AgentTerminateMode.ABORTED;
|
||||
} else if (
|
||||
terminationReason === AgentTerminateMode.GOAL &&
|
||||
this.config.maxTurns &&
|
||||
this.config.maxTurns !== -1 &&
|
||||
this.totalTurns >= this.config.maxTurns
|
||||
) {
|
||||
// Only set MAX_TURNS if we haven't already hit another reason (like LOOP)
|
||||
// and we are actually at or above the turn limit.
|
||||
terminationReason = AgentTerminateMode.MAX_TURNS;
|
||||
terminationMessage = 'Maximum session turns exceeded.';
|
||||
}
|
||||
} catch (e) {
|
||||
terminationReason = AgentTerminateMode.ERROR;
|
||||
terminationMessage = e instanceof Error ? e.message : String(e);
|
||||
terminationError = e;
|
||||
throw e;
|
||||
} finally {
|
||||
internalController.abort();
|
||||
yield {
|
||||
type: 'agent_finish',
|
||||
value: {
|
||||
sessionId: this.sessionId,
|
||||
totalTurns: this.totalTurns,
|
||||
reason: terminationReason,
|
||||
message: terminationMessage,
|
||||
error: terminationError,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal generator managing the turn-by-turn ReAct loop.
|
||||
*/
|
||||
private async *_runLoop(
|
||||
input: string | Part[],
|
||||
signal: AbortSignal,
|
||||
): AsyncIterable<AgentEvent> {
|
||||
let currentInput = input;
|
||||
let isContinuation = false;
|
||||
const maxTurns = this.config.maxTurns ?? -1;
|
||||
|
||||
while (maxTurns === -1 || this.totalTurns < maxTurns) {
|
||||
if (signal.aborted) return;
|
||||
|
||||
this.totalTurns++;
|
||||
const promptId = `${this.sessionId}#${this.totalTurns}`;
|
||||
|
||||
if (this.config.capabilities?.compression) {
|
||||
await this.tryCompressChat(promptId);
|
||||
}
|
||||
|
||||
const results = await this.runModelTurn(
|
||||
currentInput,
|
||||
promptId,
|
||||
isContinuation ? undefined : input,
|
||||
signal,
|
||||
);
|
||||
|
||||
for await (const event of results.events) {
|
||||
yield event;
|
||||
}
|
||||
|
||||
if (results.loopDetected) return;
|
||||
if (signal.aborted) return;
|
||||
|
||||
if (results.toolCalls.length > 0) {
|
||||
const toolRun = this._handleToolCalls(results.toolCalls, signal);
|
||||
let toolResults: ToolExecutionResult;
|
||||
while (true) {
|
||||
const { value, done } = await toolRun.next();
|
||||
if (done) {
|
||||
toolResults = value;
|
||||
break;
|
||||
}
|
||||
yield value;
|
||||
}
|
||||
|
||||
if (toolResults.stopExecution || signal.aborted) {
|
||||
if (toolResults.stopExecution && toolResults.stopExecutionInfo) {
|
||||
throw (
|
||||
toolResults.stopExecutionInfo.error ??
|
||||
new Error('Tool execution stopped')
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (maxTurns !== -1 && this.totalTurns >= maxTurns) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentInput = toolResults.nextParts;
|
||||
isContinuation = true;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrates tool execution turn and yields events.
|
||||
*/
|
||||
private async *_handleToolCalls(
|
||||
toolCalls: ToolCallRequestInfo[],
|
||||
signal: AbortSignal,
|
||||
): AsyncGenerator<AgentEvent, ToolExecutionResult> {
|
||||
const toolRun = this.executeTools(toolCalls, signal);
|
||||
while (true) {
|
||||
const { value, done } = await toolRun.next();
|
||||
if (done) return value;
|
||||
yield value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the model and yields the event stream.
|
||||
* Collects tool call requests for the next phase.
|
||||
*/
|
||||
private async runModelTurn(
|
||||
input: string | Part[],
|
||||
promptId: string,
|
||||
displayContent?: string | Part[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<ModelTurnResult> {
|
||||
const parts = Array.isArray(input) ? input : [{ text: input }];
|
||||
const toolCalls: ToolCallRequestInfo[] = [];
|
||||
let loopDetected = false;
|
||||
|
||||
// Ensure client is initialized before sending message
|
||||
if (!this.client.isInitialized()) {
|
||||
await this.client.initialize();
|
||||
// Re-apply system instruction after initialization
|
||||
if (this.config.systemInstruction) {
|
||||
this.client
|
||||
.getChat()
|
||||
.setSystemInstruction(this.config.systemInstruction);
|
||||
}
|
||||
}
|
||||
|
||||
const stream = this.client.sendMessageStream(
|
||||
parts,
|
||||
signal ?? new AbortController().signal,
|
||||
promptId,
|
||||
undefined, // maxTurns (client handles its own)
|
||||
false, // isInvalidStreamRetry
|
||||
displayContent,
|
||||
);
|
||||
|
||||
const eventGenerator = async function* (): AsyncIterable<AgentEvent> {
|
||||
for await (const event of stream) {
|
||||
if (event.type === GeminiEventType.ToolCallRequest) {
|
||||
toolCalls.push(event.value);
|
||||
} else if (event.type === GeminiEventType.LoopDetected) {
|
||||
loopDetected = true;
|
||||
}
|
||||
yield event;
|
||||
}
|
||||
};
|
||||
|
||||
const events = eventGenerator();
|
||||
|
||||
return {
|
||||
toolCalls,
|
||||
events,
|
||||
get loopDetected() {
|
||||
return loopDetected;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a batch of tool calls via the Scheduler.
|
||||
*/
|
||||
private async *executeTools(
|
||||
toolCalls: ToolCallRequestInfo[],
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<AgentEvent, ToolExecutionResult> {
|
||||
yield {
|
||||
type: 'tool_suite_start',
|
||||
value: { count: toolCalls.length },
|
||||
};
|
||||
|
||||
const eventQueue: AgentEvent[] = [];
|
||||
let resolveNext: (() => void) | undefined;
|
||||
let isFinished = false;
|
||||
|
||||
const onToolUpdate = this._createToolUpdateHandler(eventQueue, () =>
|
||||
resolveNext?.(),
|
||||
);
|
||||
|
||||
const messageBus = this.runtime.getMessageBus();
|
||||
messageBus.subscribe(MessageBusType.TOOL_CALLS_UPDATE, onToolUpdate);
|
||||
|
||||
const schedulePromise = this.scheduler.schedule(
|
||||
toolCalls,
|
||||
signal ?? new AbortController().signal,
|
||||
);
|
||||
|
||||
try {
|
||||
while (!isFinished || eventQueue.length > 0) {
|
||||
if (eventQueue.length > 0) {
|
||||
const event = eventQueue.shift();
|
||||
if (event) yield event;
|
||||
} else {
|
||||
const waitNext = new Promise<void>((resolve) => {
|
||||
resolveNext = resolve;
|
||||
});
|
||||
await Promise.race([
|
||||
waitNext,
|
||||
schedulePromise.then(() => {
|
||||
isFinished = true;
|
||||
resolveNext?.();
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
const completedCalls = await schedulePromise;
|
||||
|
||||
yield {
|
||||
type: 'tool_suite_finish',
|
||||
value: { responses: completedCalls.map((c) => c.response) },
|
||||
};
|
||||
|
||||
await this._recordTelemetry(completedCalls);
|
||||
|
||||
const nextParts = completedCalls.flatMap((c) => c.response.responseParts);
|
||||
const stopExecutionInfo = completedCalls.find(
|
||||
(c) => c.response.errorType === ToolErrorType.STOP_EXECUTION,
|
||||
)?.response;
|
||||
|
||||
return {
|
||||
nextParts,
|
||||
stopExecution: !!stopExecutionInfo,
|
||||
stopExecutionInfo,
|
||||
};
|
||||
} finally {
|
||||
messageBus.unsubscribe(MessageBusType.TOOL_CALLS_UPDATE, onToolUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a handler for MessageBus tool update events.
|
||||
*/
|
||||
private _createToolUpdateHandler(
|
||||
eventQueue: AgentEvent[],
|
||||
onNewEvents: () => void,
|
||||
) {
|
||||
const seenStatuses = new Map<string, CoreToolCallStatus>();
|
||||
|
||||
return (message: ToolCallsUpdateMessage) => {
|
||||
if (message.schedulerId !== this.schedulerId) return;
|
||||
|
||||
for (const call of message.toolCalls) {
|
||||
const prevStatus = seenStatuses.get(call.request.callId);
|
||||
if (prevStatus === call.status) continue;
|
||||
|
||||
if (call.status === CoreToolCallStatus.Executing) {
|
||||
eventQueue.push({ type: 'tool_call_start', value: call.request });
|
||||
} else if (
|
||||
call.status === CoreToolCallStatus.Success ||
|
||||
call.status === CoreToolCallStatus.Error ||
|
||||
call.status === CoreToolCallStatus.Cancelled
|
||||
) {
|
||||
eventQueue.push({
|
||||
type: 'tool_call_finish',
|
||||
value: call.response,
|
||||
});
|
||||
}
|
||||
seenStatuses.set(call.request.callId, call.status);
|
||||
}
|
||||
onNewEvents();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Records tool interaction telemetry and persistence data.
|
||||
*/
|
||||
private async _recordTelemetry(completedCalls: CompletedToolCall[]) {
|
||||
try {
|
||||
const currentModel =
|
||||
this.client.getCurrentSequenceModel() ?? this.runtime.getModel();
|
||||
this.client
|
||||
.getChat()
|
||||
.recordCompletedToolCalls(currentModel, completedCalls);
|
||||
await recordToolCallInteractions(this.runtime, completedCalls);
|
||||
} catch (e) {
|
||||
debugLogger.warn(`Error recording tool call information: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to compress the chat history if thresholds are exceeded.
|
||||
*/
|
||||
private async tryCompressChat(promptId: string): Promise<void> {
|
||||
const chat = this.client.getChat();
|
||||
const model = this.config.model ?? this.runtime.getModel();
|
||||
|
||||
const { newHistory, info } = await this.compressionService.compress(
|
||||
chat,
|
||||
promptId,
|
||||
false,
|
||||
model,
|
||||
this.runtime,
|
||||
this.hasFailedCompressionAttempt,
|
||||
);
|
||||
|
||||
if (
|
||||
info.compressionStatus ===
|
||||
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT
|
||||
) {
|
||||
this.hasFailedCompressionAttempt = true;
|
||||
} else if (info.compressionStatus === CompressionStatus.COMPRESSED) {
|
||||
if (newHistory) {
|
||||
chat.setHistory(newHistory);
|
||||
this.hasFailedCompressionAttempt = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current message history for this session.
|
||||
*/
|
||||
getHistory() {
|
||||
return this.client.getHistory();
|
||||
}
|
||||
}
|
||||
@@ -4,16 +4,111 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Defines the core configuration interfaces and types for the agent architecture.
|
||||
*/
|
||||
|
||||
import type { Content, FunctionDeclaration } from '@google/genai';
|
||||
import type { AnyDeclarativeTool } from '../tools/tools.js';
|
||||
import { type z } from 'zod';
|
||||
import type { ModelConfig } from '../services/modelConfigService.js';
|
||||
import type { AnySchema } from 'ajv';
|
||||
import type { A2AAuthConfig } from './auth-provider/types.js';
|
||||
import { type ServerGeminiStreamEvent } from '../core/turn.js';
|
||||
import {
|
||||
type ToolCallResponseInfo,
|
||||
type ToolCallRequestInfo,
|
||||
} from '../scheduler/types.js';
|
||||
|
||||
/** Emitted when an agent session begins execution. */
|
||||
export interface AgentStartEvent {
|
||||
type: 'agent_start';
|
||||
value: { sessionId: string };
|
||||
}
|
||||
|
||||
/** Emitted when an agent session completes, providing termination details. */
|
||||
export interface AgentFinishEvent {
|
||||
type: 'agent_finish';
|
||||
value: {
|
||||
sessionId: string;
|
||||
totalTurns: number;
|
||||
reason: AgentTerminateMode;
|
||||
message?: string;
|
||||
error?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
/** Emitted when a group of tool calls is about to be executed. */
|
||||
export interface ToolSuiteStartEvent {
|
||||
type: 'tool_suite_start';
|
||||
value: { count: number };
|
||||
}
|
||||
|
||||
/** Emitted when a group of tool calls has finished executing. */
|
||||
export interface ToolSuiteFinishEvent {
|
||||
type: 'tool_suite_finish';
|
||||
value: { responses: ToolCallResponseInfo[] };
|
||||
}
|
||||
|
||||
/** Emitted when an individual tool call begins execution. */
|
||||
export interface ToolCallStartEvent {
|
||||
type: 'tool_call_start';
|
||||
value: ToolCallRequestInfo;
|
||||
}
|
||||
|
||||
/** Emitted when an individual tool call has finished execution. */
|
||||
export interface ToolCallFinishEvent {
|
||||
type: 'tool_call_finish';
|
||||
value: ToolCallResponseInfo;
|
||||
}
|
||||
|
||||
/** Emitted when the model generates internal reasoning or "thought" content. */
|
||||
export interface ThoughtEvent {
|
||||
type: 'thought';
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** Emitted when an infinite loop is detected in the model's tool calling patterns. */
|
||||
export interface LoopDetectedEvent {
|
||||
type: 'loop_detected';
|
||||
value: { sessionId: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified event type for the Agent loop.
|
||||
* This extends the base Gemini stream events with higher-level agent lifecycle events.
|
||||
*/
|
||||
export type AgentEvent =
|
||||
| ServerGeminiStreamEvent
|
||||
| AgentStartEvent
|
||||
| AgentFinishEvent
|
||||
| ToolSuiteStartEvent
|
||||
| ToolSuiteFinishEvent
|
||||
| ToolCallStartEvent
|
||||
| ToolCallFinishEvent
|
||||
| ThoughtEvent
|
||||
| LoopDetectedEvent;
|
||||
|
||||
/**
|
||||
* Configuration for an Agent.
|
||||
*/
|
||||
export interface AgentConfig {
|
||||
/** The name of the agent. */
|
||||
name: string;
|
||||
/** The system instruction (personality/rules) for the agent. */
|
||||
systemInstruction: string;
|
||||
/** Optional override for the model to use. */
|
||||
model?: string;
|
||||
/**
|
||||
* Optional maximum number of conversational turns.
|
||||
* Set to -1 for no limit, defaults to -1 if not specified.
|
||||
*/
|
||||
maxTurns?: number;
|
||||
/**
|
||||
* Optional capabilities to enable for this agent.
|
||||
*/
|
||||
capabilities?: {
|
||||
compression?: boolean;
|
||||
loopDetection?: boolean;
|
||||
ideContext?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes the possible termination modes for an agent.
|
||||
@@ -24,6 +119,7 @@ export enum AgentTerminateMode {
|
||||
GOAL = 'GOAL',
|
||||
MAX_TURNS = 'MAX_TURNS',
|
||||
ABORTED = 'ABORTED',
|
||||
LOOP = 'LOOP',
|
||||
ERROR_NO_COMPLETE_TASK_CALL = 'ERROR_NO_COMPLETE_TASK_CALL',
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,9 @@ export type SerializableConfirmationDetails =
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
toolDisplayName: string;
|
||||
toolArgs?: Record<string, unknown>;
|
||||
toolDescription?: string;
|
||||
toolParameterSchema?: unknown;
|
||||
}
|
||||
| {
|
||||
type: 'ask_user';
|
||||
|
||||
@@ -80,6 +80,8 @@ export class DiscoveredMCPToolInvocation extends BaseToolInvocation<
|
||||
readonly trust?: boolean,
|
||||
params: ToolParams = {},
|
||||
private readonly cliConfig?: Config,
|
||||
private readonly toolDescription?: string,
|
||||
private readonly toolParameterSchema?: unknown,
|
||||
) {
|
||||
// Use composite format for policy checks: serverName__toolName
|
||||
// This enables server wildcards (e.g., "google-workspace__*")
|
||||
@@ -123,6 +125,9 @@ export class DiscoveredMCPToolInvocation extends BaseToolInvocation<
|
||||
serverName: this.serverName,
|
||||
toolName: this.serverToolName, // Display original tool name in confirmation
|
||||
toolDisplayName: this.displayName, // Display global registry name exposed to model and user
|
||||
toolArgs: this.params,
|
||||
toolDescription: this.toolDescription,
|
||||
toolParameterSchema: this.toolParameterSchema,
|
||||
onConfirm: async (outcome: ToolConfirmationOutcome) => {
|
||||
if (outcome === ToolConfirmationOutcome.ProceedAlwaysServer) {
|
||||
DiscoveredMCPToolInvocation.allowlist.add(serverAllowListKey);
|
||||
@@ -317,6 +322,8 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool<
|
||||
this.trust,
|
||||
params,
|
||||
this.cliConfig,
|
||||
this.description,
|
||||
this.parameterSchema,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -757,6 +757,9 @@ export interface ToolMcpConfirmationDetails {
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
toolDisplayName: string;
|
||||
toolArgs?: Record<string, unknown>;
|
||||
toolDescription?: string;
|
||||
toolParameterSchema?: unknown;
|
||||
onConfirm: (outcome: ToolConfirmationOutcome) => Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user