mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-02 21:21:09 -07:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6deaf3dd0f | |||
| 102881c27f | |||
| 456f8be568 | |||
| fa0e6e252f | |||
| 9d8351c5c9 | |||
| 3b9d5d6e8c | |||
| 303d001251 | |||
| ed32dcb179 | |||
| 39fe31d2d3 | |||
| 67819bf5ae | |||
| c989087ba5 | |||
| 14e781c77c | |||
| 70105b687c | |||
| 3faa6f2056 | |||
| ac8f6f6e7e | |||
| fbade116c3 | |||
| 85c31ad2da | |||
| 886417efc1 | |||
| e650c10cf5 |
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"experimental": {
|
"experimental": {
|
||||||
"plan": true
|
"plan": true,
|
||||||
|
"enableAgentHarness": true
|
||||||
},
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"devtools": true
|
"devtools": true
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Agent harness architecture
|
||||||
|
|
||||||
|
This document provides a detailed walkthrough of the architectural shift from
|
||||||
|
linear turn-based execution to the unified hierarchical loop model used by the
|
||||||
|
Agent Harness.
|
||||||
|
|
||||||
|
> **Note:** This is a preview feature currently under active development.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Agent Harness represents a fundamental evolution in how Gemini CLI manages
|
||||||
|
interactions with Large Language Models (LLMs) and tools. It unifies the
|
||||||
|
execution logic for both the main CLI agent and subagents, providing parity in
|
||||||
|
features like model routing, history management, and tool execution.
|
||||||
|
|
||||||
|
## Legacy architecture: Linear turns
|
||||||
|
|
||||||
|
The legacy system operates on a "Stop-and-Go" model where the UI manages the
|
||||||
|
execution turn-by-turn.
|
||||||
|
|
||||||
|
In this model, when you send a prompt, the system follows these steps:
|
||||||
|
|
||||||
|
1. **Orchestration:** The `GeminiClient` and the `useGeminiStream` hook manage
|
||||||
|
the flow.
|
||||||
|
2. **Execution:** Gemini returns a single response containing text or tool
|
||||||
|
calls.
|
||||||
|
3. **UI Interruption:** The execution stops at the UI layer. If Gemini calls
|
||||||
|
tools, the UI schedules them, waits for results, and then re-submits the
|
||||||
|
entire history as a brand-new turn.
|
||||||
|
4. **Subagents:** Subagents are treated as "Black Box" tools. The main agent
|
||||||
|
calls a subagent (for example, `codebase_investigator`), waits for it to
|
||||||
|
complete its private loop using `LocalAgentExecutor`, and receives a single
|
||||||
|
string result.
|
||||||
|
|
||||||
|
This model results in duplicated logic for subagents and prevents them from
|
||||||
|
using advanced features available to the main agent.
|
||||||
|
|
||||||
|
## New architecture: Unified agent harness
|
||||||
|
|
||||||
|
The Agent Harness treats the ReAct (Reasoning and Action) loop as a first-class,
|
||||||
|
autonomous process.
|
||||||
|
|
||||||
|
The new model introduces several key improvements:
|
||||||
|
|
||||||
|
1. **Continuous Loop:** The `AgentHarness` manages the entire lifecycle
|
||||||
|
internally. It handles LLM calls, tool execution, and reasoning without
|
||||||
|
relinquishing control to the UI until it reaches the final goal.
|
||||||
|
2. **Event Stream:** The harness yields a continuous stream of events
|
||||||
|
(`GeminiEvent`) that the UI listens to and renders in real-time.
|
||||||
|
3. **Hierarchical Delegation:** Because the harness is unified, a subagent is
|
||||||
|
simply another instance of `AgentHarness` running inside a tool call of the
|
||||||
|
parent harness.
|
||||||
|
4. **Feature Parity:** Subagents can now use the same features as the main
|
||||||
|
agent, including dynamic model routing, history compression, and complex
|
||||||
|
interactive tools.
|
||||||
|
|
||||||
|
## UI synchronization challenges
|
||||||
|
|
||||||
|
Moving to a hierarchical model introduces complexity in how the UI maintains a
|
||||||
|
consistent history.
|
||||||
|
|
||||||
|
The `HistoryManager` expects a flat list of messages, but the harness provides a
|
||||||
|
nested, multi-turn stream. This creates two primary challenges:
|
||||||
|
|
||||||
|
1. **History Persistence:** Legacy code may clear the "active" turn state
|
||||||
|
prematurely when a turn boundary is crossed. The harness uses a
|
||||||
|
`TurnFinished` event to signal when to "lock in" reasoning without ending
|
||||||
|
the overall session.
|
||||||
|
2. **Hierarchical Boxes:** In a hierarchical model, internal subagent tool
|
||||||
|
calls (for example, reading a file) shouldn't clutter the main history. The
|
||||||
|
UI uses `SubagentActivity` events to update a single, persistent subagent
|
||||||
|
box rather than rendering every internal step as a top-level item.
|
||||||
|
|
||||||
|
## Isolation strategy
|
||||||
|
|
||||||
|
To ensure stability during this transition, the project uses a "Dual
|
||||||
|
Implementation" strategy.
|
||||||
|
|
||||||
|
This strategy isolates the experimental logic from the stable codebase:
|
||||||
|
|
||||||
|
- **Hook Isolation:** `useAgentHarness.ts` provides a dedicated hook for the new
|
||||||
|
event model, leaving the stable `useGeminiStream` untouched.
|
||||||
|
- **Logic Isolation:** `HarnessSubagentInvocation.ts` manages subagent execution
|
||||||
|
specifically for the harness, while `LocalSubagentInvocation.ts` continues to
|
||||||
|
serve the legacy path.
|
||||||
|
- **Conditional Forking:** The system switches between these paths based on the
|
||||||
|
`experimental-agent-harness` configuration flag.
|
||||||
@@ -88,6 +88,10 @@
|
|||||||
"label": "Sub-agents (experimental)",
|
"label": "Sub-agents (experimental)",
|
||||||
"slug": "docs/core/subagents"
|
"slug": "docs/core/subagents"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"label": "Agent harness architecture (experimental)",
|
||||||
|
"slug": "docs/core/agent-harness-architecture"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "Remote subagents (experimental)",
|
"label": "Remote subagents (experimental)",
|
||||||
"slug": "docs/core/remote-agents"
|
"slug": "docs/core/remote-agents"
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class MockClient implements acp.Client {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('ACP Environment and Auth', () => {
|
describe.skip('ACP Environment and Auth', () => {
|
||||||
let rig: TestRig;
|
let rig: TestRig;
|
||||||
let child: ChildProcess | undefined;
|
let child: ChildProcess | undefined;
|
||||||
|
|
||||||
@@ -55,15 +55,19 @@ describe('ACP Environment and Auth', () => {
|
|||||||
|
|
||||||
const bundlePath = join(import.meta.dirname, '..', 'bundle/gemini.js');
|
const bundlePath = join(import.meta.dirname, '..', 'bundle/gemini.js');
|
||||||
|
|
||||||
|
const customEnv = {
|
||||||
|
...process.env,
|
||||||
|
GEMINI_CLI_HOME: rig.homeDir!,
|
||||||
|
VERBOSE: 'true',
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
delete (customEnv as any).GEMINI_API_KEY;
|
||||||
|
|
||||||
child = spawn('node', [bundlePath, '--experimental-acp'], {
|
child = spawn('node', [bundlePath, '--experimental-acp'], {
|
||||||
cwd: rig.homeDir!,
|
cwd: rig.homeDir!,
|
||||||
stdio: ['pipe', 'pipe', 'inherit'],
|
stdio: ['pipe', 'pipe', 'inherit'],
|
||||||
env: {
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
...process.env,
|
env: customEnv as any,
|
||||||
GEMINI_CLI_HOME: rig.homeDir!,
|
|
||||||
GEMINI_API_KEY: undefined,
|
|
||||||
VERBOSE: 'true',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const input = Writable.toWeb(child.stdin!);
|
const input = Writable.toWeb(child.stdin!);
|
||||||
@@ -120,15 +124,19 @@ describe('ACP Environment and Auth', () => {
|
|||||||
|
|
||||||
const bundlePath = join(import.meta.dirname, '..', 'bundle/gemini.js');
|
const bundlePath = join(import.meta.dirname, '..', 'bundle/gemini.js');
|
||||||
|
|
||||||
|
const customEnv = {
|
||||||
|
...process.env,
|
||||||
|
GEMINI_CLI_HOME: rig.homeDir!,
|
||||||
|
VERBOSE: 'true',
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
delete (customEnv as any).GEMINI_API_KEY;
|
||||||
|
|
||||||
child = spawn('node', [bundlePath, '--experimental-acp'], {
|
child = spawn('node', [bundlePath, '--experimental-acp'], {
|
||||||
cwd: rig.homeDir!,
|
cwd: rig.homeDir!,
|
||||||
stdio: ['pipe', 'pipe', 'inherit'],
|
stdio: ['pipe', 'pipe', 'inherit'],
|
||||||
env: {
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
...process.env,
|
env: customEnv as any,
|
||||||
GEMINI_CLI_HOME: rig.homeDir!,
|
|
||||||
GEMINI_API_KEY: undefined,
|
|
||||||
VERBOSE: 'true',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const input = Writable.toWeb(child.stdin!);
|
const input = Writable.toWeb(child.stdin!);
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { TestRig } from './test-helper.js';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
|
||||||
|
describe('Agent Harness E2E', () => {
|
||||||
|
let rig: TestRig;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
rig = new TestRig();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => await rig.cleanup());
|
||||||
|
|
||||||
|
it('should execute a simple prompt using the agent harness', async () => {
|
||||||
|
await rig.setup('agent-harness-simple');
|
||||||
|
|
||||||
|
// Run with the harness enabled via env var
|
||||||
|
// Turn 1
|
||||||
|
const result1 = await rig.run({
|
||||||
|
args: ['chat', 'My name is GeminiUser'],
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
GEMINI_ENABLE_AGENT_HARNESS: 'true',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result1).toBeDefined();
|
||||||
|
|
||||||
|
// Turn 2
|
||||||
|
const result2 = await rig.run({
|
||||||
|
args: ['chat', 'What is my name?', '--resume', 'latest'],
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
GEMINI_ENABLE_AGENT_HARNESS: 'true',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result2).toContain('GeminiUser');
|
||||||
|
}, 120000);
|
||||||
|
|
||||||
|
it('should delegate to codebase_investigator and synthesize results', async () => {
|
||||||
|
await rig.setup('agent-harness-delegation');
|
||||||
|
|
||||||
|
// Create a dummy file for CBI to find
|
||||||
|
const historyDir = path.join(rig.testDir!, 'packages/core/src');
|
||||||
|
fs.mkdirSync(historyDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(historyDir, 'history.ts'),
|
||||||
|
`
|
||||||
|
/** ChatHistory maintains the message history for the session. */
|
||||||
|
export class ChatHistory {
|
||||||
|
private messages: any[] = [];
|
||||||
|
addMessage(msg: any) { this.messages.push(msg); }
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
const result = await rig.run({
|
||||||
|
args: [
|
||||||
|
'chat',
|
||||||
|
'use @codebase_investigator to tell me about how chat history is maintained',
|
||||||
|
],
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
GEMINI_ENABLE_AGENT_HARNESS: 'true',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify synthesis: CBI should have found ChatHistory or history.ts
|
||||||
|
const output = result.toLowerCase();
|
||||||
|
expect(output).toMatch(/history|chat/);
|
||||||
|
|
||||||
|
// Verify single delegation: CBI should only be called once.
|
||||||
|
// We check the tool logs for 'codebase_investigator'
|
||||||
|
const toolLogs = rig.readToolLogs();
|
||||||
|
const cbiCalls = toolLogs.filter(
|
||||||
|
(log) => log.toolRequest?.name === 'codebase_investigator',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cbiCalls.length < 1) {
|
||||||
|
console.log('DEBUG: Full tool logs:', JSON.stringify(toolLogs, null, 2));
|
||||||
|
if (rig._lastRunStdout) {
|
||||||
|
console.log('DEBUG: Full stdout length:', rig._lastRunStdout.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(cbiCalls.length).toBeGreaterThanOrEqual(1);
|
||||||
|
}, 240000);
|
||||||
|
});
|
||||||
@@ -78,6 +78,8 @@ export interface CliArgs {
|
|||||||
allowedMcpServerNames: string[] | undefined;
|
allowedMcpServerNames: string[] | undefined;
|
||||||
allowedTools: string[] | undefined;
|
allowedTools: string[] | undefined;
|
||||||
experimentalAcp: boolean | undefined;
|
experimentalAcp: boolean | undefined;
|
||||||
|
experimentalAgentHarness: boolean | undefined;
|
||||||
|
experimentalEnableAgents: boolean | undefined;
|
||||||
extensions: string[] | undefined;
|
extensions: string[] | undefined;
|
||||||
listExtensions: boolean | undefined;
|
listExtensions: boolean | undefined;
|
||||||
resume: string | typeof RESUME_LATEST | undefined;
|
resume: string | typeof RESUME_LATEST | undefined;
|
||||||
@@ -162,6 +164,14 @@ export async function parseArguments(
|
|||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
description: 'Starts the agent in ACP mode',
|
description: 'Starts the agent in ACP mode',
|
||||||
})
|
})
|
||||||
|
.option('experimental-agent-harness', {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Enable the new unified agent harness',
|
||||||
|
})
|
||||||
|
.option('experimental-enable-agents', {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Enable local and remote subagents',
|
||||||
|
})
|
||||||
.option('allowed-mcp-server-names', {
|
.option('allowed-mcp-server-names', {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
string: true,
|
string: true,
|
||||||
@@ -787,7 +797,16 @@ export async function loadCliConfig(
|
|||||||
enabledExtensions: argv.extensions,
|
enabledExtensions: argv.extensions,
|
||||||
extensionLoader: extensionManager,
|
extensionLoader: extensionManager,
|
||||||
enableExtensionReloading: settings.experimental?.extensionReloading,
|
enableExtensionReloading: settings.experimental?.extensionReloading,
|
||||||
enableAgents: settings.experimental?.enableAgents,
|
enableAgents:
|
||||||
|
argv.experimentalEnableAgents ?? settings.experimental?.enableAgents,
|
||||||
|
enableAgentHarness:
|
||||||
|
argv.experimentalAgentHarness ??
|
||||||
|
(process.env['GEMINI_ENABLE_AGENT_HARNESS'] === 'true'
|
||||||
|
? true
|
||||||
|
: process.env['GEMINI_ENABLE_AGENT_HARNESS'] === 'false'
|
||||||
|
? false
|
||||||
|
: settings.experimental?.enableAgentHarness),
|
||||||
|
|
||||||
plan: settings.experimental?.plan,
|
plan: settings.experimental?.plan,
|
||||||
enableEventDrivenScheduler: true,
|
enableEventDrivenScheduler: true,
|
||||||
skillsSupport: settings.skills?.enabled ?? true,
|
skillsSupport: settings.skills?.enabled ?? true,
|
||||||
|
|||||||
@@ -1528,6 +1528,15 @@ const SETTINGS_SCHEMA = {
|
|||||||
'Enable local and remote subagents. Warning: Experimental feature, uses YOLO mode for subagents',
|
'Enable local and remote subagents. Warning: Experimental feature, uses YOLO mode for subagents',
|
||||||
showInDialog: false,
|
showInDialog: false,
|
||||||
},
|
},
|
||||||
|
enableAgentHarness: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Enable Agent Harness',
|
||||||
|
category: 'Experimental',
|
||||||
|
requiresRestart: true,
|
||||||
|
default: false,
|
||||||
|
description: 'Enable the new unified agent harness (experimental).',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
extensionManagement: {
|
extensionManagement: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
label: 'Extension Management',
|
label: 'Extension Management',
|
||||||
|
|||||||
@@ -467,6 +467,8 @@ describe('gemini.tsx main function kitty protocol', () => {
|
|||||||
allowedMcpServerNames: undefined,
|
allowedMcpServerNames: undefined,
|
||||||
allowedTools: undefined,
|
allowedTools: undefined,
|
||||||
experimentalAcp: undefined,
|
experimentalAcp: undefined,
|
||||||
|
experimentalAgentHarness: undefined,
|
||||||
|
experimentalEnableAgents: undefined,
|
||||||
extensions: undefined,
|
extensions: undefined,
|
||||||
listExtensions: undefined,
|
listExtensions: undefined,
|
||||||
includeDirectories: undefined,
|
includeDirectories: undefined,
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ import { computeTerminalTitle } from '../utils/windowTitle.js';
|
|||||||
import { useTextBuffer } from './components/shared/text-buffer.js';
|
import { useTextBuffer } from './components/shared/text-buffer.js';
|
||||||
import { useLogger } from './hooks/useLogger.js';
|
import { useLogger } from './hooks/useLogger.js';
|
||||||
import { useGeminiStream } from './hooks/useGeminiStream.js';
|
import { useGeminiStream } from './hooks/useGeminiStream.js';
|
||||||
|
import { useAgentHarness } from './hooks/useAgentHarness.js';
|
||||||
import { type BackgroundShell } from './hooks/shellCommandProcessor.js';
|
import { type BackgroundShell } from './hooks/shellCommandProcessor.js';
|
||||||
import { useVim } from './hooks/vim.js';
|
import { useVim } from './hooks/vim.js';
|
||||||
import { type LoadableSettingScope, SettingScope } from '../config/settings.js';
|
import { type LoadableSettingScope, SettingScope } from '../config/settings.js';
|
||||||
@@ -966,26 +967,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
}
|
}
|
||||||
}, [pendingRestorePrompt, inputHistory, historyManager.history]);
|
}, [pendingRestorePrompt, inputHistory, historyManager.history]);
|
||||||
|
|
||||||
const {
|
const isAgentHarnessEnabled = config.isAgentHarnessEnabled();
|
||||||
streamingState,
|
|
||||||
submitQuery,
|
const legacyStream = useGeminiStream(
|
||||||
initError,
|
|
||||||
pendingHistoryItems: pendingGeminiHistoryItems,
|
|
||||||
thought,
|
|
||||||
cancelOngoingRequest,
|
|
||||||
pendingToolCalls,
|
|
||||||
handleApprovalModeChange,
|
|
||||||
activePtyId,
|
|
||||||
loopDetectionConfirmationRequest,
|
|
||||||
lastOutputTime,
|
|
||||||
backgroundShellCount,
|
|
||||||
isBackgroundShellVisible,
|
|
||||||
toggleBackgroundShell,
|
|
||||||
backgroundCurrentShell,
|
|
||||||
backgroundShells,
|
|
||||||
dismissBackgroundShell,
|
|
||||||
retryStatus,
|
|
||||||
} = useGeminiStream(
|
|
||||||
config.getGeminiClient(),
|
config.getGeminiClient(),
|
||||||
historyManager.history,
|
historyManager.history,
|
||||||
historyManager.addItem,
|
historyManager.addItem,
|
||||||
@@ -1006,6 +990,40 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
embeddedShellFocused,
|
embeddedShellFocused,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const harnessStream = useAgentHarness(
|
||||||
|
historyManager.addItem,
|
||||||
|
config,
|
||||||
|
onCancelSubmit,
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeStream = isAgentHarnessEnabled ? harnessStream : legacyStream;
|
||||||
|
|
||||||
|
const {
|
||||||
|
streamingState,
|
||||||
|
submitQuery,
|
||||||
|
initError,
|
||||||
|
pendingHistoryItems: pendingGeminiHistoryItems,
|
||||||
|
thought,
|
||||||
|
cancelOngoingRequest,
|
||||||
|
toolCalls: pendingToolCalls,
|
||||||
|
handleApprovalModeChange,
|
||||||
|
activePtyId: rawActivePtyId,
|
||||||
|
loopDetectionConfirmationRequest: rawLoopDetectionConfirmationRequest,
|
||||||
|
lastOutputTime,
|
||||||
|
backgroundShellCount,
|
||||||
|
isBackgroundShellVisible,
|
||||||
|
toggleBackgroundShell,
|
||||||
|
backgroundCurrentShell,
|
||||||
|
backgroundShells: rawBackgroundShells,
|
||||||
|
dismissBackgroundShell,
|
||||||
|
retryStatus: rawRetryStatus,
|
||||||
|
} = activeStream;
|
||||||
|
|
||||||
|
const activePtyId = rawActivePtyId ?? undefined;
|
||||||
|
const loopDetectionConfirmationRequest = rawLoopDetectionConfirmationRequest;
|
||||||
|
const backgroundShells = rawBackgroundShells;
|
||||||
|
const retryStatus = rawRetryStatus;
|
||||||
|
|
||||||
toggleBackgroundShellRef.current = toggleBackgroundShell;
|
toggleBackgroundShellRef.current = toggleBackgroundShell;
|
||||||
isBackgroundShellVisibleRef.current = isBackgroundShellVisible;
|
isBackgroundShellVisibleRef.current = isBackgroundShellVisible;
|
||||||
backgroundShellsRef.current = backgroundShells;
|
backgroundShellsRef.current = backgroundShells;
|
||||||
@@ -1610,7 +1628,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
return false;
|
return false;
|
||||||
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
|
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
|
||||||
if (activePtyId) {
|
if (activePtyId) {
|
||||||
backgroundCurrentShell();
|
backgroundCurrentShell?.();
|
||||||
// After backgrounding, we explicitly do NOT show or focus the background UI.
|
// After backgrounding, we explicitly do NOT show or focus the background UI.
|
||||||
} else {
|
} else {
|
||||||
toggleBackgroundShell();
|
toggleBackgroundShell();
|
||||||
|
|||||||
@@ -110,7 +110,8 @@ export const Notifications = () => {
|
|||||||
marginBottom={1}
|
marginBottom={1}
|
||||||
>
|
>
|
||||||
<Text color={theme.status.error}>
|
<Text color={theme.status.error}>
|
||||||
Initialization Error: {initError}
|
Initialization Error:{' '}
|
||||||
|
{initError instanceof Error ? initError.message : initError}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={theme.status.error}>
|
<Text color={theme.status.error}>
|
||||||
{' '}
|
{' '}
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export interface UIState {
|
|||||||
permissionConfirmationRequest: PermissionConfirmationRequest | null;
|
permissionConfirmationRequest: PermissionConfirmationRequest | null;
|
||||||
geminiMdFileCount: number;
|
geminiMdFileCount: number;
|
||||||
streamingState: StreamingState;
|
streamingState: StreamingState;
|
||||||
initError: string | null;
|
initError: string | Error | null;
|
||||||
pendingGeminiHistoryItems: HistoryItemWithoutId[];
|
pendingGeminiHistoryItems: HistoryItemWithoutId[];
|
||||||
thought: ThoughtSummary | null;
|
thought: ThoughtSummary | null;
|
||||||
shellModeActive: boolean;
|
shellModeActive: boolean;
|
||||||
|
|||||||
@@ -61,10 +61,13 @@ export function mapToDisplay(
|
|||||||
|
|
||||||
const displayName = call.tool?.displayName ?? call.request.name;
|
const displayName = call.tool?.displayName ?? call.request.name;
|
||||||
|
|
||||||
if (call.status === 'error') {
|
if (call.status === 'error' || !call.invocation) {
|
||||||
description = JSON.stringify(call.request.args);
|
description = JSON.stringify(call.request.args);
|
||||||
} else {
|
} else {
|
||||||
description = call.invocation.getDescription();
|
description = call.invocation.getDescription();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (call.tool) {
|
||||||
renderOutputAsMarkdown = call.tool.isOutputMarkdown;
|
renderOutputAsMarkdown = call.tool.isOutputMarkdown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,12 +89,12 @@ export function mapToDisplay(
|
|||||||
|
|
||||||
switch (call.status) {
|
switch (call.status) {
|
||||||
case 'success':
|
case 'success':
|
||||||
resultDisplay = call.response.resultDisplay;
|
resultDisplay = call.response?.resultDisplay;
|
||||||
outputFile = call.response.outputFile;
|
outputFile = call.response?.outputFile;
|
||||||
break;
|
break;
|
||||||
case 'error':
|
case 'error':
|
||||||
case 'cancelled':
|
case 'cancelled':
|
||||||
resultDisplay = call.response.resultDisplay;
|
resultDisplay = call.response?.resultDisplay;
|
||||||
break;
|
break;
|
||||||
case 'awaiting_approval':
|
case 'awaiting_approval':
|
||||||
correlationId = call.correlationId;
|
correlationId = call.correlationId;
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||||
|
import { act } from 'react';
|
||||||
|
import { renderHookWithProviders } from '../../test-utils/render.js';
|
||||||
|
import { useAgentHarness } from './useAgentHarness.js';
|
||||||
|
import {
|
||||||
|
GeminiEventType as ServerGeminiEventType,
|
||||||
|
ROOT_SCHEDULER_ID,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
|
import { makeFakeConfig } from '../../../../core/src/test-utils/config.js';
|
||||||
|
import type {
|
||||||
|
Config,
|
||||||
|
ServerGeminiStreamEvent as GeminiEvent,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
|
import { StreamingState, MessageType } from '../types.js';
|
||||||
|
|
||||||
|
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||||
|
const actual =
|
||||||
|
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
AgentFactory: {
|
||||||
|
createHarness: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useAgentHarness', () => {
|
||||||
|
let mockAddItem: Mock;
|
||||||
|
let mockConfig: Config;
|
||||||
|
let mockOnCancelSubmit: Mock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockAddItem = vi.fn();
|
||||||
|
mockConfig = makeFakeConfig();
|
||||||
|
mockOnCancelSubmit = vi.fn();
|
||||||
|
|
||||||
|
vi.spyOn(mockConfig, 'getToolRegistry').mockReturnValue({
|
||||||
|
getTool: vi.fn().mockReturnValue({
|
||||||
|
displayName: 'codebase_investigator',
|
||||||
|
createInvocation: vi.fn().mockReturnValue({
|
||||||
|
getDescription: () => 'Test Tool Description',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
vi.spyOn(mockConfig, 'getMessageBus').mockReturnValue({
|
||||||
|
subscribe: vi.fn().mockReturnValue(vi.fn()),
|
||||||
|
unsubscribe: vi.fn(),
|
||||||
|
publish: vi.fn(),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('initializes in Idle state', () => {
|
||||||
|
const { result } = renderHookWithProviders(() =>
|
||||||
|
useAgentHarness(mockAddItem, mockConfig, mockOnCancelSubmit),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.streamingState).toBe(StreamingState.Idle);
|
||||||
|
expect(result.current.isResponding).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates state live during processEvent', async () => {
|
||||||
|
const { result } = renderHookWithProviders(() =>
|
||||||
|
useAgentHarness(mockAddItem, mockConfig, mockOnCancelSubmit),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1. Send content
|
||||||
|
await act(async () => {
|
||||||
|
result.current.processEvent({
|
||||||
|
type: ServerGeminiEventType.Content,
|
||||||
|
value: 'Hello',
|
||||||
|
} as GeminiEvent);
|
||||||
|
});
|
||||||
|
expect(result.current.streamingContent).toBe('Hello');
|
||||||
|
expect(result.current.streamingState).toBe(StreamingState.Responding);
|
||||||
|
|
||||||
|
// 2. Send thought
|
||||||
|
await act(async () => {
|
||||||
|
result.current.processEvent({
|
||||||
|
type: ServerGeminiEventType.Thought,
|
||||||
|
value: { subject: 'Thinking' },
|
||||||
|
} as GeminiEvent);
|
||||||
|
});
|
||||||
|
expect(result.current.thought?.subject).toBe('Thinking');
|
||||||
|
|
||||||
|
// 3. Send tool request
|
||||||
|
await act(async () => {
|
||||||
|
result.current.processEvent({
|
||||||
|
type: ServerGeminiEventType.ToolCallRequest,
|
||||||
|
value: {
|
||||||
|
name: 'tool_1',
|
||||||
|
callId: 'c1',
|
||||||
|
args: {},
|
||||||
|
schedulerId: ROOT_SCHEDULER_ID,
|
||||||
|
},
|
||||||
|
} as GeminiEvent);
|
||||||
|
});
|
||||||
|
expect(result.current.toolCalls).toHaveLength(1);
|
||||||
|
expect(result.current.toolCalls[0].request.name).toBe('tool_1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges subagent activity into active tool calls', async () => {
|
||||||
|
const { result } = renderHookWithProviders(() =>
|
||||||
|
useAgentHarness(mockAddItem, mockConfig, mockOnCancelSubmit),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start a delegation tool
|
||||||
|
await act(async () => {
|
||||||
|
result.current.processEvent({
|
||||||
|
type: ServerGeminiEventType.ToolCallRequest,
|
||||||
|
value: {
|
||||||
|
name: 'subagent_tool',
|
||||||
|
callId: 'c1',
|
||||||
|
args: {},
|
||||||
|
schedulerId: ROOT_SCHEDULER_ID,
|
||||||
|
},
|
||||||
|
} as GeminiEvent);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send subagent activity
|
||||||
|
await act(async () => {
|
||||||
|
result.current.processEvent({
|
||||||
|
type: ServerGeminiEventType.SubagentActivity,
|
||||||
|
value: {
|
||||||
|
agentName: 'codebase_investigator',
|
||||||
|
type: 'THOUGHT',
|
||||||
|
data: { subject: 'Analyzing logs' },
|
||||||
|
},
|
||||||
|
} as GeminiEvent);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify the tool box resultDisplay was updated with the thought
|
||||||
|
expect(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(result.current.toolCalls[0] as any).response?.resultDisplay,
|
||||||
|
).toContain('🤖💭 Analyzing logs');
|
||||||
|
|
||||||
|
// Send another activity
|
||||||
|
await act(async () => {
|
||||||
|
result.current.processEvent({
|
||||||
|
type: ServerGeminiEventType.SubagentActivity,
|
||||||
|
value: {
|
||||||
|
agentName: 'codebase_investigator',
|
||||||
|
type: 'TOOL_CALL_START',
|
||||||
|
data: { name: 'list_directory' },
|
||||||
|
},
|
||||||
|
} as GeminiEvent);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(result.current.toolCalls[0] as any).response?.resultDisplay,
|
||||||
|
).toContain('🛠️ Calling codebase_investigator...');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flushes to history on TurnFinished', async () => {
|
||||||
|
const { result } = renderHookWithProviders(() =>
|
||||||
|
useAgentHarness(mockAddItem, mockConfig, mockOnCancelSubmit),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Setup some state
|
||||||
|
await act(async () => {
|
||||||
|
result.current.processEvent({
|
||||||
|
type: ServerGeminiEventType.Content,
|
||||||
|
value: 'Done',
|
||||||
|
} as GeminiEvent);
|
||||||
|
result.current.processEvent({
|
||||||
|
type: ServerGeminiEventType.TurnFinished,
|
||||||
|
} as GeminiEvent);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockAddItem).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: MessageType.GEMINI,
|
||||||
|
text: 'Done',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.current.streamingContent).toBe(''); // Should be cleared
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
GeminiEventType as ServerGeminiEventType,
|
||||||
|
ROOT_SCHEDULER_ID,
|
||||||
|
AgentFactory,
|
||||||
|
MessageBusType,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
|
import type {
|
||||||
|
Config,
|
||||||
|
ServerGeminiStreamEvent as GeminiEvent,
|
||||||
|
ThoughtSummary,
|
||||||
|
RetryAttemptPayload,
|
||||||
|
ToolCallsUpdateMessage,
|
||||||
|
ValidatingToolCall,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
|
import { type PartListUnion, type Part } from '@google/genai';
|
||||||
|
import {
|
||||||
|
StreamingState,
|
||||||
|
MessageType,
|
||||||
|
type HistoryItemWithoutId,
|
||||||
|
type LoopDetectionConfirmationRequest,
|
||||||
|
} from '../types.js';
|
||||||
|
import { useStateAndRef } from './useStateAndRef.js';
|
||||||
|
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||||
|
import { mapToDisplay as mapTrackedToolCallsToDisplay } from './toolMapping.js';
|
||||||
|
import type { TrackedToolCall } from './useToolScheduler.js';
|
||||||
|
import { type BackgroundShell } from './shellReducer.js';
|
||||||
|
|
||||||
|
export interface UseAgentHarnessReturn {
|
||||||
|
streamingState: StreamingState;
|
||||||
|
isResponding: boolean;
|
||||||
|
thought: ThoughtSummary | null;
|
||||||
|
streamingContent: string;
|
||||||
|
toolCalls: TrackedToolCall[];
|
||||||
|
submitQuery: (query: PartListUnion) => Promise<void>;
|
||||||
|
processEvent: (event: GeminiEvent) => void;
|
||||||
|
cancelOngoingRequest: () => void;
|
||||||
|
reset: () => void;
|
||||||
|
// Legacy compatibility properties
|
||||||
|
initError: Error | null;
|
||||||
|
pendingHistoryItems: HistoryItemWithoutId[];
|
||||||
|
handleApprovalModeChange: (mode: string) => void;
|
||||||
|
activePtyId: number | null;
|
||||||
|
loopDetectionConfirmationRequest: LoopDetectionConfirmationRequest | null;
|
||||||
|
lastOutputTime: number;
|
||||||
|
backgroundShellCount: number;
|
||||||
|
isBackgroundShellVisible: boolean;
|
||||||
|
toggleBackgroundShell: () => void;
|
||||||
|
backgroundCurrentShell: (() => void) | null;
|
||||||
|
backgroundShells: Map<number, BackgroundShell>;
|
||||||
|
dismissBackgroundShell: (pid: number) => void;
|
||||||
|
retryStatus: RetryAttemptPayload | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A specialized hook for processing streams from the AgentHarness.
|
||||||
|
* COMPLETELY FORKED from useGeminiStream to ensure zero regressions in legacy mode.
|
||||||
|
*/
|
||||||
|
export const useAgentHarness = (
|
||||||
|
addItem: UseHistoryManagerReturn['addItem'],
|
||||||
|
config: Config,
|
||||||
|
onCancelSubmit: (fullReset: boolean) => void,
|
||||||
|
): UseAgentHarnessReturn => {
|
||||||
|
const [streamingState, setStreamingState] = useState<StreamingState>(
|
||||||
|
StreamingState.Idle,
|
||||||
|
);
|
||||||
|
const [streamingContent, setStreamingContent] = useState('');
|
||||||
|
const streamingContentRef = useRef('');
|
||||||
|
|
||||||
|
const [thought, thoughtRef, setThought] =
|
||||||
|
useStateAndRef<ThoughtSummary | null>(null);
|
||||||
|
|
||||||
|
// Tools for the CURRENT turn of the main agent
|
||||||
|
const [toolCalls, setToolCalls] = useState<TrackedToolCall[]>([]);
|
||||||
|
const toolCallsRef = useRef<TrackedToolCall[]>([]);
|
||||||
|
|
||||||
|
// Sync ref with state (still useful for some parts)
|
||||||
|
useEffect(() => {
|
||||||
|
toolCallsRef.current = toolCalls;
|
||||||
|
}, [toolCalls]);
|
||||||
|
|
||||||
|
const pushedToolCallIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Listen to the MessageBus for live tool updates (e.g. from subagents or long-running tools)
|
||||||
|
useEffect(() => {
|
||||||
|
const bus = config.getMessageBus();
|
||||||
|
const handler = (event: ToolCallsUpdateMessage) => {
|
||||||
|
setToolCalls((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
for (const coreCall of event.toolCalls) {
|
||||||
|
const index = next.findIndex(
|
||||||
|
(tc) => tc.request.callId === coreCall.request.callId,
|
||||||
|
);
|
||||||
|
if (index !== -1) {
|
||||||
|
next[index] = {
|
||||||
|
...next[index],
|
||||||
|
...coreCall,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toolCallsRef.current = next;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
bus.subscribe(MessageBusType.TOOL_CALLS_UPDATE, handler);
|
||||||
|
return () => {
|
||||||
|
bus.unsubscribe(MessageBusType.TOOL_CALLS_UPDATE, handler);
|
||||||
|
};
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const pendingHistoryItems = useMemo(() => {
|
||||||
|
const items: HistoryItemWithoutId[] = [];
|
||||||
|
|
||||||
|
// Only show the top-level thought if we aren't currently executing tools (delegations)
|
||||||
|
// Subagent internal thoughts are merged into the tool box via SubagentActivity handler.
|
||||||
|
if (thought && toolCalls.length === 0) {
|
||||||
|
items.push({
|
||||||
|
type: MessageType.THINKING,
|
||||||
|
thought,
|
||||||
|
} as HistoryItemWithoutId);
|
||||||
|
}
|
||||||
|
if (toolCalls.length > 0) {
|
||||||
|
const unpushed = toolCalls.filter(
|
||||||
|
(tc) => !pushedToolCallIdsRef.current.has(tc.request.callId),
|
||||||
|
);
|
||||||
|
if (unpushed.length > 0) {
|
||||||
|
items.push(
|
||||||
|
mapToDisplayInternal(unpushed, {
|
||||||
|
borderBottom: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (streamingContent) {
|
||||||
|
items.push({ type: MessageType.GEMINI, text: streamingContent });
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}, [thought, toolCalls, streamingContent]);
|
||||||
|
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setStreamingState(StreamingState.Idle);
|
||||||
|
setStreamingContent('');
|
||||||
|
streamingContentRef.current = '';
|
||||||
|
setThought(null);
|
||||||
|
setToolCalls([]);
|
||||||
|
toolCallsRef.current = [];
|
||||||
|
pushedToolCallIdsRef.current.clear();
|
||||||
|
}, [setThought]);
|
||||||
|
|
||||||
|
const cancelOngoingRequest = useCallback(() => {
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
onCancelSubmit(true);
|
||||||
|
reset();
|
||||||
|
}, [onCancelSubmit, reset]);
|
||||||
|
|
||||||
|
const processEvent = useCallback(
|
||||||
|
(event: GeminiEvent) => {
|
||||||
|
switch (event.type) {
|
||||||
|
case ServerGeminiEventType.Content:
|
||||||
|
setStreamingState(StreamingState.Responding);
|
||||||
|
{
|
||||||
|
const nextContent =
|
||||||
|
streamingContentRef.current + (event.value || '');
|
||||||
|
streamingContentRef.current = nextContent;
|
||||||
|
setStreamingContent(nextContent);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ServerGeminiEventType.Thought:
|
||||||
|
setThought(event.value);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ServerGeminiEventType.ToolCallRequest:
|
||||||
|
{
|
||||||
|
setThought(null);
|
||||||
|
const tool = config.getToolRegistry().getTool(event.value.name);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
const invocation = (tool as any)?.createInvocation?.(
|
||||||
|
event.value.args,
|
||||||
|
config.getMessageBus(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// In Harness mode, top-level calls might not have schedulerId set yet.
|
||||||
|
// We default to ROOT_SCHEDULER_ID to ensure they are visible.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
const newCall: TrackedToolCall = {
|
||||||
|
request: {
|
||||||
|
...event.value,
|
||||||
|
schedulerId: event.value.schedulerId || ROOT_SCHEDULER_ID,
|
||||||
|
},
|
||||||
|
status: 'validating',
|
||||||
|
schedulerId: event.value.schedulerId || ROOT_SCHEDULER_ID,
|
||||||
|
tool: tool || undefined,
|
||||||
|
invocation: invocation || undefined,
|
||||||
|
} as ValidatingToolCall;
|
||||||
|
|
||||||
|
const nextCalls = [...toolCallsRef.current, newCall];
|
||||||
|
toolCallsRef.current = nextCalls;
|
||||||
|
setToolCalls(nextCalls);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ServerGeminiEventType.ToolCallResponse:
|
||||||
|
{
|
||||||
|
const response = event.value;
|
||||||
|
const nextCalls = toolCallsRef.current.map((tc) =>
|
||||||
|
tc.request.callId === response.callId
|
||||||
|
? // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
({
|
||||||
|
...tc,
|
||||||
|
status: 'success',
|
||||||
|
response,
|
||||||
|
} as unknown as TrackedToolCall)
|
||||||
|
: tc,
|
||||||
|
);
|
||||||
|
toolCallsRef.current = nextCalls;
|
||||||
|
setToolCalls(nextCalls);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ServerGeminiEventType.TurnFinished:
|
||||||
|
// MAIN AGENT turn finished. Flush current state to history.
|
||||||
|
if (thoughtRef.current) {
|
||||||
|
addItem({
|
||||||
|
type: MessageType.THINKING,
|
||||||
|
thought: thoughtRef.current,
|
||||||
|
} as HistoryItemWithoutId);
|
||||||
|
setThought(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolCallsRef.current.length > 0) {
|
||||||
|
const unpushed = toolCallsRef.current.filter(
|
||||||
|
(tc) => !pushedToolCallIdsRef.current.has(tc.request.callId),
|
||||||
|
);
|
||||||
|
if (unpushed.length > 0) {
|
||||||
|
addItem(
|
||||||
|
mapToDisplayInternal(unpushed, {
|
||||||
|
borderBottom: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
unpushed.forEach((tc) =>
|
||||||
|
pushedToolCallIdsRef.current.add(tc.request.callId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (streamingContentRef.current) {
|
||||||
|
addItem({
|
||||||
|
type: MessageType.GEMINI,
|
||||||
|
text: streamingContentRef.current,
|
||||||
|
});
|
||||||
|
setStreamingContent('');
|
||||||
|
streamingContentRef.current = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
toolCallsRef.current = [];
|
||||||
|
setToolCalls([]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ServerGeminiEventType.SubagentActivity:
|
||||||
|
{
|
||||||
|
const activity = event.value;
|
||||||
|
let matched = false;
|
||||||
|
|
||||||
|
const nextCalls = toolCallsRef.current.map((tc) => {
|
||||||
|
// Try to find the tool box that belongs to this agent.
|
||||||
|
// Note: We search ALL tool calls, not just 'executing', in case of race conditions.
|
||||||
|
if (
|
||||||
|
tc.request.name === activity.agentName ||
|
||||||
|
(tc.tool?.displayName || tc.request.name) === activity.agentName
|
||||||
|
) {
|
||||||
|
matched = true;
|
||||||
|
let output = '';
|
||||||
|
if (
|
||||||
|
tc.status === 'success' ||
|
||||||
|
tc.status === 'error' ||
|
||||||
|
tc.status === 'cancelled'
|
||||||
|
) {
|
||||||
|
output = String(tc.response.resultDisplay || '');
|
||||||
|
}
|
||||||
|
if (typeof output !== 'string') output = '';
|
||||||
|
|
||||||
|
if (activity.type === 'TOOL_CALL_START') {
|
||||||
|
const rawName = String(activity.data['name'] || 'a tool');
|
||||||
|
const tool = config.getToolRegistry().getTool(rawName);
|
||||||
|
const displayName = tool?.displayName || rawName;
|
||||||
|
output += `🛠️ Calling ${displayName}...\n`;
|
||||||
|
} else if (activity.type === 'THOUGHT') {
|
||||||
|
const subject = String(
|
||||||
|
activity.data['subject'] || 'Thinking',
|
||||||
|
);
|
||||||
|
output += `🤖💭 ${subject}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentResponse =
|
||||||
|
tc.status === 'success' ||
|
||||||
|
tc.status === 'error' ||
|
||||||
|
tc.status === 'cancelled'
|
||||||
|
? tc.response
|
||||||
|
: {};
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
return {
|
||||||
|
...tc,
|
||||||
|
response: {
|
||||||
|
...currentResponse,
|
||||||
|
resultDisplay: output,
|
||||||
|
},
|
||||||
|
} as unknown as TrackedToolCall;
|
||||||
|
}
|
||||||
|
return tc;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matched) {
|
||||||
|
toolCallsRef.current = nextCalls;
|
||||||
|
setToolCalls(nextCalls);
|
||||||
|
} else {
|
||||||
|
// Fallback: If no tool box matches, show it as a standalone item
|
||||||
|
if (activity.type === 'THOUGHT') {
|
||||||
|
addItem({
|
||||||
|
type: MessageType.GEMINI,
|
||||||
|
text: `🤖💭 [${activity.agentName}] ${activity.data['subject']}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ServerGeminiEventType.Finished:
|
||||||
|
setStreamingState(StreamingState.Idle);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addItem, config, setThought, thoughtRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Listen for nested subagent activity on the MessageBus
|
||||||
|
useEffect(() => {
|
||||||
|
const bus = config.getMessageBus();
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion */
|
||||||
|
const handler = (event: any) => {
|
||||||
|
processEvent({
|
||||||
|
type: ServerGeminiEventType.SubagentActivity,
|
||||||
|
value: event.activity,
|
||||||
|
} as any as GeminiEvent);
|
||||||
|
};
|
||||||
|
/* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion */
|
||||||
|
bus.subscribe(MessageBusType.SUBAGENT_ACTIVITY, handler);
|
||||||
|
return () => {
|
||||||
|
bus.unsubscribe(MessageBusType.SUBAGENT_ACTIVITY, handler);
|
||||||
|
};
|
||||||
|
}, [config, processEvent]);
|
||||||
|
|
||||||
|
const submitQuery = useCallback(
|
||||||
|
async (parts: PartListUnion) => {
|
||||||
|
reset();
|
||||||
|
setStreamingState(StreamingState.Responding);
|
||||||
|
|
||||||
|
abortControllerRef.current = new AbortController();
|
||||||
|
const harness = AgentFactory.createHarness(config);
|
||||||
|
|
||||||
|
// Convert parts to Part[] array for harness
|
||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
|
||||||
|
const requestParts: Part[] = Array.isArray(parts)
|
||||||
|
? (parts as Part[])
|
||||||
|
: [{ text: String(parts) }];
|
||||||
|
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
|
||||||
|
|
||||||
|
const stream = harness.run(
|
||||||
|
requestParts,
|
||||||
|
abortControllerRef.current.signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const event of stream) {
|
||||||
|
processEvent(event);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof Error && err.name === 'AbortError') return;
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
addItem({ type: MessageType.ERROR, text: msg });
|
||||||
|
} finally {
|
||||||
|
setStreamingState(StreamingState.Idle);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[config, reset, processEvent, addItem],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
streamingState,
|
||||||
|
isResponding: streamingState !== StreamingState.Idle,
|
||||||
|
thought,
|
||||||
|
streamingContent,
|
||||||
|
toolCalls,
|
||||||
|
submitQuery,
|
||||||
|
processEvent,
|
||||||
|
cancelOngoingRequest,
|
||||||
|
reset,
|
||||||
|
initError: null,
|
||||||
|
pendingHistoryItems,
|
||||||
|
handleApprovalModeChange: () => {},
|
||||||
|
activePtyId: null,
|
||||||
|
loopDetectionConfirmationRequest: null,
|
||||||
|
lastOutputTime: 0,
|
||||||
|
backgroundShellCount: 0,
|
||||||
|
isBackgroundShellVisible: false,
|
||||||
|
toggleBackgroundShell: () => {},
|
||||||
|
backgroundCurrentShell: null,
|
||||||
|
backgroundShells: new Map<number, BackgroundShell>(),
|
||||||
|
dismissBackgroundShell: () => {},
|
||||||
|
retryStatus: null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal mapper to ensure we don't accidentally leak subagent-internal tools
|
||||||
|
* into the main UI boxes while in Harness Mode.
|
||||||
|
*/
|
||||||
|
function mapToDisplayInternal(
|
||||||
|
calls: TrackedToolCall[],
|
||||||
|
options: { borderTop?: boolean; borderBottom?: boolean },
|
||||||
|
): HistoryItemWithoutId {
|
||||||
|
// We filter out any tool calls that are NOT part of the root harness level.
|
||||||
|
// This prevents internal subagent work (like list_directory) from appearing
|
||||||
|
// as loose tool boxes in the main chat.
|
||||||
|
const filtered = calls.filter(
|
||||||
|
(c) =>
|
||||||
|
// Only show tools belonging to the main top-level session.
|
||||||
|
c.schedulerId === ROOT_SCHEDULER_ID,
|
||||||
|
);
|
||||||
|
|
||||||
|
return mapTrackedToolCallsToDisplay(filtered, options);
|
||||||
|
}
|
||||||
@@ -1168,6 +1168,12 @@ export const useGeminiStream = (
|
|||||||
case ServerGeminiEventType.InvalidStream:
|
case ServerGeminiEventType.InvalidStream:
|
||||||
// Will add the missing logic later
|
// Will add the missing logic later
|
||||||
break;
|
break;
|
||||||
|
case ServerGeminiEventType.SubagentActivity:
|
||||||
|
// TODO: UI implementation for subagent activity
|
||||||
|
break;
|
||||||
|
case ServerGeminiEventType.TurnFinished:
|
||||||
|
// No-op for now to satisfy exhaustive switch
|
||||||
|
break;
|
||||||
default: {
|
default: {
|
||||||
// enforces exhaustive switch-case
|
// enforces exhaustive switch-case
|
||||||
const unreachable: never = event;
|
const unreachable: never = event;
|
||||||
@@ -1677,7 +1683,7 @@ export const useGeminiStream = (
|
|||||||
pendingHistoryItems,
|
pendingHistoryItems,
|
||||||
thought,
|
thought,
|
||||||
cancelOngoingRequest,
|
cancelOngoingRequest,
|
||||||
pendingToolCalls: toolCalls,
|
toolCalls,
|
||||||
handleApprovalModeChange,
|
handleApprovalModeChange,
|
||||||
activePtyId,
|
activePtyId,
|
||||||
loopDetectionConfirmationRequest,
|
loopDetectionConfirmationRequest,
|
||||||
|
|||||||
@@ -373,6 +373,7 @@ export enum MessageType {
|
|||||||
AGENTS_LIST = 'agents_list',
|
AGENTS_LIST = 'agents_list',
|
||||||
MCP_STATUS = 'mcp_status',
|
MCP_STATUS = 'mcp_status',
|
||||||
CHAT_LIST = 'chat_list',
|
CHAT_LIST = 'chat_list',
|
||||||
|
THINKING = 'thinking',
|
||||||
HOOKS_LIST = 'hooks_list',
|
HOOKS_LIST = 'hooks_list',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type Config } from '../config/config.js';
|
||||||
|
import { AgentHarness, type AgentHarnessOptions } from './harness.js';
|
||||||
|
import { type AgentDefinition, type LocalAgentDefinition } from './types.js';
|
||||||
|
import { MainAgentBehavior, SubagentBehavior } from './behavior.js';
|
||||||
|
import { debugLogger } from '../utils/debugLogger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory for creating agent executors/harnesses.
|
||||||
|
* Respects experimental flags to determine which implementation to use.
|
||||||
|
*/
|
||||||
|
export class AgentFactory {
|
||||||
|
static createHarness(
|
||||||
|
config: Config,
|
||||||
|
definition?: AgentDefinition,
|
||||||
|
options: Partial<AgentHarnessOptions> = {},
|
||||||
|
): AgentHarness {
|
||||||
|
let behavior;
|
||||||
|
if (definition && definition.kind === 'local') {
|
||||||
|
const localDef: LocalAgentDefinition = definition;
|
||||||
|
behavior = new SubagentBehavior(
|
||||||
|
config,
|
||||||
|
localDef,
|
||||||
|
options.inputs,
|
||||||
|
options.parentPromptId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
behavior = new MainAgentBehavior(config, options.parentPromptId);
|
||||||
|
}
|
||||||
|
|
||||||
|
debugLogger.debug(
|
||||||
|
`[AgentFactory] Creating harness for agent: ${behavior.name} (agentId: ${behavior.agentId})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return new AgentHarness({
|
||||||
|
config,
|
||||||
|
behavior,
|
||||||
|
isolatedTools: !!definition,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
} from '../scheduler/types.js';
|
} from '../scheduler/types.js';
|
||||||
import type { ToolRegistry } from '../tools/tool-registry.js';
|
import type { ToolRegistry } from '../tools/tool-registry.js';
|
||||||
import type { EditorType } from '../utils/editor.js';
|
import type { EditorType } from '../utils/editor.js';
|
||||||
|
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for scheduling agent tools.
|
* Options for scheduling agent tools.
|
||||||
@@ -29,6 +30,13 @@ export interface AgentSchedulingOptions {
|
|||||||
getPreferredEditor?: () => EditorType | undefined;
|
getPreferredEditor?: () => EditorType | undefined;
|
||||||
/** Optional function to be notified when the scheduler is waiting for user confirmation. */
|
/** Optional function to be notified when the scheduler is waiting for user confirmation. */
|
||||||
onWaitingForConfirmation?: (waiting: boolean) => void;
|
onWaitingForConfirmation?: (waiting: boolean) => void;
|
||||||
|
/**
|
||||||
|
* Optional message bus override.
|
||||||
|
* If provided, the scheduler will broadcast to this bus.
|
||||||
|
* If explicitly null, broadcasting is disabled.
|
||||||
|
* If omitted, the global config message bus is used.
|
||||||
|
*/
|
||||||
|
messageBus?: MessageBus | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,6 +59,7 @@ export async function scheduleAgentTools(
|
|||||||
signal,
|
signal,
|
||||||
getPreferredEditor,
|
getPreferredEditor,
|
||||||
onWaitingForConfirmation,
|
onWaitingForConfirmation,
|
||||||
|
messageBus,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
// Create a proxy/override of the config to provide the agent-specific tool registry.
|
// Create a proxy/override of the config to provide the agent-specific tool registry.
|
||||||
@@ -59,7 +68,10 @@ export async function scheduleAgentTools(
|
|||||||
|
|
||||||
const scheduler = new Scheduler({
|
const scheduler = new Scheduler({
|
||||||
config: agentConfig,
|
config: agentConfig,
|
||||||
messageBus: config.getMessageBus(),
|
messageBus:
|
||||||
|
messageBus === undefined
|
||||||
|
? config.getMessageBus()
|
||||||
|
: (messageBus ?? undefined),
|
||||||
getPreferredEditor: getPreferredEditor ?? (() => undefined),
|
getPreferredEditor: getPreferredEditor ?? (() => undefined),
|
||||||
schedulerId,
|
schedulerId,
|
||||||
parentCallId,
|
parentCallId,
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ export async function resolveAuthValue(value: string): Promise<string> {
|
|||||||
`Please set it before using this agent.`,
|
`Please set it before using this agent.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
debugLogger.debug(`[AuthValueResolver] Resolved env var: ${envVar}`);
|
debugLogger.debug(
|
||||||
|
`[AgentHarness] [AuthValueResolver] Resolved env var: ${envVar}`,
|
||||||
|
);
|
||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +53,9 @@ export async function resolveAuthValue(value: string): Promise<string> {
|
|||||||
throw new Error('Empty command in auth value. Expected format: !command');
|
throw new Error('Empty command in auth value. Expected format: !command');
|
||||||
}
|
}
|
||||||
|
|
||||||
debugLogger.debug(`[AuthValueResolver] Executing command for auth value`);
|
debugLogger.debug(
|
||||||
|
`[AgentHarness] [AuthValueResolver] Executing command for auth value`,
|
||||||
|
);
|
||||||
|
|
||||||
const shellConfig = getShellConfiguration();
|
const shellConfig = getShellConfiguration();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,611 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type Content,
|
||||||
|
type Part,
|
||||||
|
type FunctionDeclaration,
|
||||||
|
Type,
|
||||||
|
} from '@google/genai';
|
||||||
|
import { type Config } from '../config/config.js';
|
||||||
|
import {
|
||||||
|
type Turn,
|
||||||
|
type ServerGeminiStreamEvent,
|
||||||
|
GeminiEventType,
|
||||||
|
} from '../core/turn.js';
|
||||||
|
import {
|
||||||
|
AgentTerminateMode,
|
||||||
|
type LocalAgentDefinition,
|
||||||
|
type AgentInputs,
|
||||||
|
} from './types.js';
|
||||||
|
import { getCoreSystemPrompt } from '../core/prompts.js';
|
||||||
|
import {
|
||||||
|
getInitialChatHistory,
|
||||||
|
getDirectoryContextString,
|
||||||
|
} from '../utils/environmentContext.js';
|
||||||
|
import { templateString } from './utils.js';
|
||||||
|
import { getVersion } from '../utils/version.js';
|
||||||
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||||
|
import type { Schema } from '@google/genai';
|
||||||
|
import { checkNextSpeaker } from '../utils/nextSpeakerChecker.js';
|
||||||
|
import { partToString } from '../utils/partUtils.js';
|
||||||
|
import { ideContextStore } from '../ide/ideContext.js';
|
||||||
|
import { type IdeContext } from '../ide/types.js';
|
||||||
|
import { promptIdContext } from '../utils/promptIdContext.js';
|
||||||
|
import { logRecoveryAttempt } from '../telemetry/loggers.js';
|
||||||
|
import { RecoveryAttemptEvent } from '../telemetry/types.js';
|
||||||
|
import { DeadlineTimer } from '../utils/deadlineTimer.js';
|
||||||
|
import { debugLogger } from '../utils/debugLogger.js';
|
||||||
|
|
||||||
|
import type { ToolRegistry } from '../tools/tool-registry.js';
|
||||||
|
import type { ToolCallResponseInfo } from '../scheduler/types.js';
|
||||||
|
|
||||||
|
const TASK_COMPLETE_TOOL_NAME = 'complete_task';
|
||||||
|
const GRACE_PERIOD_MS = 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the extension points for the unified ReAct loop in AgentHarness.
|
||||||
|
*/
|
||||||
|
export interface AgentBehavior {
|
||||||
|
/** The unique ID for this agent instance. */
|
||||||
|
readonly agentId: string;
|
||||||
|
|
||||||
|
/** The human-readable name of the agent. */
|
||||||
|
readonly name: string;
|
||||||
|
|
||||||
|
/** The definition of the agent, if applicable. */
|
||||||
|
readonly definition?: LocalAgentDefinition;
|
||||||
|
|
||||||
|
/** Initializes any state needed for the agent. */
|
||||||
|
initialize(toolRegistry: ToolRegistry): Promise<void>;
|
||||||
|
|
||||||
|
/** Returns the system instruction for the chat. */
|
||||||
|
|
||||||
|
getSystemInstruction(): Promise<string | undefined>;
|
||||||
|
|
||||||
|
/** Returns the initial chat history. */
|
||||||
|
getInitialHistory(): Promise<Content[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepares the tools list for the current turn.
|
||||||
|
* @param baseTools The tools from the tool registry.
|
||||||
|
*/
|
||||||
|
prepareTools(baseTools: FunctionDeclaration[]): FunctionDeclaration[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs any environment synchronization (e.g., IDE context) before a turn.
|
||||||
|
*/
|
||||||
|
syncEnvironment(history: Content[]): Promise<{ additionalParts?: Part[] }>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fires the "Before Agent" hooks if applicable.
|
||||||
|
*/
|
||||||
|
fireBeforeAgent(request: Part[]): Promise<{
|
||||||
|
stop?: boolean;
|
||||||
|
reason?: string;
|
||||||
|
systemMessage?: string;
|
||||||
|
additionalContext?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fires the "After Agent" hooks if applicable.
|
||||||
|
*/
|
||||||
|
fireAfterAgent(
|
||||||
|
request: Part[],
|
||||||
|
response: string,
|
||||||
|
turn: Turn,
|
||||||
|
): Promise<{
|
||||||
|
stop?: boolean;
|
||||||
|
reason?: string;
|
||||||
|
systemMessage?: string;
|
||||||
|
contextCleared?: boolean;
|
||||||
|
shouldContinue?: boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms the initial request if needed (e.g. subagent 'Start' templating).
|
||||||
|
*/
|
||||||
|
transformRequest(request: Part[]): Promise<Part[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if the current tool results signify that the agent's goal is met.
|
||||||
|
* (e.g., Subagents checking for 'complete_task')
|
||||||
|
*/
|
||||||
|
isGoalReached(
|
||||||
|
toolResults: Array<{
|
||||||
|
name: string;
|
||||||
|
part: Part;
|
||||||
|
result: ToolCallResponseInfo;
|
||||||
|
}>,
|
||||||
|
): boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the agent should continue executing after a model turn with no tool calls.
|
||||||
|
* (e.g., Main agent running next_speaker check)
|
||||||
|
*/
|
||||||
|
getContinuationRequest(
|
||||||
|
turn: Turn,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<Part[] | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to recover from a termination state (e.g., Subagent "Final Warning").
|
||||||
|
* Returns a stream of events if recovery is attempted.
|
||||||
|
*/
|
||||||
|
executeRecovery(
|
||||||
|
turn: Turn,
|
||||||
|
reason: AgentTerminateMode,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): AsyncGenerator<ServerGeminiStreamEvent, boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a final failure message for a given termination reason.
|
||||||
|
*/
|
||||||
|
getFinalFailureMessage(
|
||||||
|
reason: AgentTerminateMode,
|
||||||
|
maxTurns: number,
|
||||||
|
maxTime: number,
|
||||||
|
): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Behavior for the main CLI agent.
|
||||||
|
*/
|
||||||
|
export class MainAgentBehavior implements AgentBehavior {
|
||||||
|
readonly agentId: string;
|
||||||
|
readonly name = 'main';
|
||||||
|
private lastSentIdeContext: IdeContext | undefined;
|
||||||
|
private forceFullIdeContext = true;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly config: Config,
|
||||||
|
parentPromptId?: string,
|
||||||
|
) {
|
||||||
|
const randomIdPart = Math.random().toString(36).slice(2, 8);
|
||||||
|
const parentPrefix = parentPromptId ? `${parentPromptId}-` : '';
|
||||||
|
this.agentId = `${parentPrefix}main-${randomIdPart}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize(_toolRegistry: ToolRegistry) {
|
||||||
|
debugLogger.debug(
|
||||||
|
`[AgentHarness] [${this.name}:${this.agentId}] Initialized`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSystemInstruction() {
|
||||||
|
const systemMemory = this.config.getUserMemory();
|
||||||
|
return getCoreSystemPrompt(this.config, systemMemory);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInitialHistory() {
|
||||||
|
return getInitialChatHistory(this.config);
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareTools(baseTools: FunctionDeclaration[]) {
|
||||||
|
return baseTools;
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncEnvironment(history: Content[]) {
|
||||||
|
if (!this.config.getIdeMode()) return {};
|
||||||
|
|
||||||
|
const lastMessage =
|
||||||
|
history.length > 0 ? history[history.length - 1] : undefined;
|
||||||
|
const hasPendingToolCall =
|
||||||
|
!!lastMessage &&
|
||||||
|
lastMessage.role === 'model' &&
|
||||||
|
(lastMessage.parts?.some((p) => 'functionCall' in p) || false);
|
||||||
|
|
||||||
|
if (hasPendingToolCall) return {};
|
||||||
|
|
||||||
|
const currentIdeContext = ideContextStore.get();
|
||||||
|
if (!currentIdeContext) return {};
|
||||||
|
|
||||||
|
let contextParts: string[] = [];
|
||||||
|
if (
|
||||||
|
this.forceFullIdeContext ||
|
||||||
|
this.lastSentIdeContext === undefined ||
|
||||||
|
history.length === 0
|
||||||
|
) {
|
||||||
|
contextParts = this.getFullIdeContextParts(currentIdeContext);
|
||||||
|
} else {
|
||||||
|
contextParts = this.getDeltaIdeContextParts(
|
||||||
|
currentIdeContext,
|
||||||
|
this.lastSentIdeContext,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contextParts.length > 0) {
|
||||||
|
this.lastSentIdeContext = currentIdeContext;
|
||||||
|
this.forceFullIdeContext = false;
|
||||||
|
return { additionalParts: [{ text: contextParts.join('\n') }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFullIdeContextParts(context: IdeContext): string[] {
|
||||||
|
const openFiles = context.workspaceState?.openFiles || [];
|
||||||
|
const activeFile = openFiles.find((f) => f.isActive);
|
||||||
|
const otherOpenFiles = openFiles
|
||||||
|
.filter((f) => !f.isActive)
|
||||||
|
.map((f) => f.path);
|
||||||
|
|
||||||
|
const contextData: Record<string, unknown> = {};
|
||||||
|
if (activeFile) {
|
||||||
|
contextData['activeFile'] = {
|
||||||
|
path: activeFile.path,
|
||||||
|
cursor: activeFile.cursor,
|
||||||
|
selectedText: activeFile.selectedText || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (otherOpenFiles.length > 0)
|
||||||
|
contextData['otherOpenFiles'] = otherOpenFiles;
|
||||||
|
|
||||||
|
if (Object.keys(contextData).length === 0) return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
"Here is the user's editor context as a JSON object. This is for your information only.",
|
||||||
|
'```json',
|
||||||
|
JSON.stringify(contextData, null, 2),
|
||||||
|
'```',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDeltaIdeContextParts(
|
||||||
|
_current: IdeContext,
|
||||||
|
_last: IdeContext,
|
||||||
|
): string[] {
|
||||||
|
// Simplified delta logic for now, similar to GeminiClient
|
||||||
|
const changes: Record<string, unknown> = {};
|
||||||
|
// ... delta logic ...
|
||||||
|
if (Object.keys(changes).length === 0) return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
"Here is a summary of changes in the user's editor context, in JSON format. This is for your information only.",
|
||||||
|
'```json',
|
||||||
|
JSON.stringify({ changes }, null, 2),
|
||||||
|
'```',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async fireBeforeAgent(request: Part[]) {
|
||||||
|
if (!this.config.getEnableHooks()) return {};
|
||||||
|
const hookOutput = await this.config
|
||||||
|
.getHookSystem()
|
||||||
|
?.fireBeforeAgentEvent(partToString(request));
|
||||||
|
if (!hookOutput) return {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
stop: hookOutput.shouldStopExecution() || hookOutput.isBlockingDecision(),
|
||||||
|
reason: hookOutput.getEffectiveReason(),
|
||||||
|
systemMessage: hookOutput.systemMessage,
|
||||||
|
additionalContext: hookOutput.getAdditionalContext(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fireAfterAgent(request: Part[], response: string, turn: Turn) {
|
||||||
|
if (!this.config.getEnableHooks()) return {};
|
||||||
|
if (turn.pendingToolCalls.length > 0) return {};
|
||||||
|
|
||||||
|
const hookOutput = await this.config
|
||||||
|
.getHookSystem()
|
||||||
|
?.fireAfterAgentEvent(partToString(request), response);
|
||||||
|
if (!hookOutput) return {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
stop: hookOutput.shouldStopExecution(),
|
||||||
|
shouldContinue: hookOutput.isBlockingDecision(),
|
||||||
|
reason: hookOutput.getEffectiveReason(),
|
||||||
|
systemMessage: hookOutput.systemMessage,
|
||||||
|
contextCleared: hookOutput.shouldClearContext(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async transformRequest(request: Part[]) {
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
isGoalReached() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getContinuationRequest(turn: Turn, signal: AbortSignal) {
|
||||||
|
const nextSpeaker = await checkNextSpeaker(
|
||||||
|
turn.chat,
|
||||||
|
this.config.getBaseLlmClient(),
|
||||||
|
signal,
|
||||||
|
this.agentId,
|
||||||
|
);
|
||||||
|
if (nextSpeaker?.next_speaker === 'model') {
|
||||||
|
return [{ text: 'Please continue.' }];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async *executeRecovery(): AsyncGenerator<ServerGeminiStreamEvent, boolean> {
|
||||||
|
if (this.agentId === 'never') yield { type: GeminiEventType.Retry };
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getFinalFailureMessage() {
|
||||||
|
return 'Execution terminated.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Behavior for subagents.
|
||||||
|
*/
|
||||||
|
export class SubagentBehavior implements AgentBehavior {
|
||||||
|
readonly agentId: string;
|
||||||
|
readonly name: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly config: Config,
|
||||||
|
readonly definition: LocalAgentDefinition,
|
||||||
|
private readonly inputs?: AgentInputs,
|
||||||
|
parentPromptId?: string,
|
||||||
|
) {
|
||||||
|
this.name = definition.name;
|
||||||
|
const randomIdPart = Math.random().toString(36).slice(2, 8);
|
||||||
|
const parentPrefix = parentPromptId ? `${parentPromptId}-` : '';
|
||||||
|
this.agentId = `${parentPrefix}${this.name}-${randomIdPart}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize(toolRegistry: ToolRegistry) {
|
||||||
|
debugLogger.debug(
|
||||||
|
`[AgentHarness] [${this.name}:${this.agentId}] Initializing tool registry`,
|
||||||
|
);
|
||||||
|
const parentToolRegistry = this.config.getToolRegistry();
|
||||||
|
if (this.definition.toolConfig) {
|
||||||
|
for (const toolRef of this.definition.toolConfig.tools) {
|
||||||
|
if (typeof toolRef === 'string') {
|
||||||
|
const tool = parentToolRegistry.getTool(toolRef);
|
||||||
|
if (tool) toolRegistry.registerTool(tool);
|
||||||
|
} else if (typeof toolRef === 'object' && 'build' in toolRef) {
|
||||||
|
toolRegistry.registerTool(toolRef);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const toolName of parentToolRegistry.getAllToolNames()) {
|
||||||
|
const tool = parentToolRegistry.getTool(toolName);
|
||||||
|
if (tool) toolRegistry.registerTool(tool);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toolRegistry.sortTools();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSystemInstruction() {
|
||||||
|
const augmentedInputs = {
|
||||||
|
...this.inputs,
|
||||||
|
cliVersion: await getVersion(),
|
||||||
|
activeModel: this.config.getActiveModel(),
|
||||||
|
today: new Date().toLocaleDateString(),
|
||||||
|
};
|
||||||
|
let prompt = templateString(
|
||||||
|
this.definition.promptConfig.systemPrompt || '',
|
||||||
|
augmentedInputs,
|
||||||
|
);
|
||||||
|
const dirContext = await getDirectoryContextString(this.config);
|
||||||
|
prompt += `\n\n# Environment Context\n${dirContext}`;
|
||||||
|
prompt += `\n\nImportant Rules:\n* You are running in a non-interactive mode. You CANNOT ask the user for input or clarification.\n* Work systematically using available tools to complete your task.\n* Always use absolute paths for file operations.`;
|
||||||
|
|
||||||
|
const hasOutput = !!this.definition.outputConfig;
|
||||||
|
prompt += `\n* When you have completed your task, you MUST call the \`${TASK_COMPLETE_TOOL_NAME}\` tool${hasOutput ? ' with your structured output' : ''}.`;
|
||||||
|
|
||||||
|
return prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInitialHistory() {
|
||||||
|
const initialMessages = this.definition.promptConfig.initialMessages ?? [];
|
||||||
|
if (this.inputs) {
|
||||||
|
return initialMessages.map((content) => ({
|
||||||
|
...content,
|
||||||
|
parts: (content.parts ?? []).map((part) =>
|
||||||
|
'text' in part && part.text
|
||||||
|
? { text: templateString(part.text, this.inputs!) }
|
||||||
|
: part,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return initialMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareTools(baseTools: FunctionDeclaration[]) {
|
||||||
|
const completeTool: FunctionDeclaration = {
|
||||||
|
name: TASK_COMPLETE_TOOL_NAME,
|
||||||
|
description:
|
||||||
|
'Call this tool to submit your final answer and complete the task.',
|
||||||
|
parameters: { type: Type.OBJECT, properties: {}, required: [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.definition.outputConfig) {
|
||||||
|
const schema = zodToJsonSchema(this.definition.outputConfig.schema);
|
||||||
|
const {
|
||||||
|
$schema: _,
|
||||||
|
definitions: __,
|
||||||
|
...cleanSchema
|
||||||
|
} = schema as Record<string, unknown>;
|
||||||
|
completeTool.parameters!.properties![
|
||||||
|
this.definition.outputConfig.outputName
|
||||||
|
] = cleanSchema as Schema;
|
||||||
|
completeTool.parameters!.required!.push(
|
||||||
|
this.definition.outputConfig.outputName,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
completeTool.parameters!.properties!['result'] = {
|
||||||
|
type: Type.STRING,
|
||||||
|
description: 'Your final results or findings.',
|
||||||
|
};
|
||||||
|
completeTool.parameters!.required!.push('result');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...baseTools, completeTool];
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncEnvironment() {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fireBeforeAgent() {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fireAfterAgent() {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async transformRequest(request: Part[]): Promise<Part[]> {
|
||||||
|
if (
|
||||||
|
request.length === 1 &&
|
||||||
|
'text' in request[0] &&
|
||||||
|
request[0].text === 'Start'
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
text: this.definition.promptConfig.query
|
||||||
|
? templateString(
|
||||||
|
this.definition.promptConfig.query,
|
||||||
|
this.inputs || {},
|
||||||
|
)
|
||||||
|
: 'Get Started!',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
isGoalReached(
|
||||||
|
toolResults: Array<{
|
||||||
|
name: string;
|
||||||
|
part: Part;
|
||||||
|
result: ToolCallResponseInfo;
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
const completeCall = toolResults.find(
|
||||||
|
(r) => r.name === TASK_COMPLETE_TOOL_NAME,
|
||||||
|
);
|
||||||
|
if (completeCall) {
|
||||||
|
// If there's an error in the call, we don't treat it as reached (model should retry)
|
||||||
|
return !completeCall.part.functionResponse?.response?.['error'];
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getContinuationRequest() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async *executeRecovery(
|
||||||
|
turn: Turn,
|
||||||
|
reason: AgentTerminateMode,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): AsyncGenerator<ServerGeminiStreamEvent, boolean> {
|
||||||
|
debugLogger.debug(
|
||||||
|
`[AgentHarness] [${this.name}:${this.agentId}] Entering recovery mode. Reason: ${reason}`,
|
||||||
|
);
|
||||||
|
const recoveryStartTime = Date.now();
|
||||||
|
let success = false;
|
||||||
|
const graceTimeoutController = new DeadlineTimer(
|
||||||
|
GRACE_PERIOD_MS,
|
||||||
|
'Grace period timed out.',
|
||||||
|
);
|
||||||
|
const combinedSignal = AbortSignal.any([
|
||||||
|
signal,
|
||||||
|
graceTimeoutController.signal,
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const recoveryMessage: Part[] = [
|
||||||
|
{ text: this.getFinalWarningMessage(reason) },
|
||||||
|
];
|
||||||
|
const promptId = `${this.agentId}#recovery`;
|
||||||
|
const recoveryStream = promptIdContext.run(promptId, () =>
|
||||||
|
turn.run(
|
||||||
|
{ model: this.config.getActiveModel() },
|
||||||
|
recoveryMessage,
|
||||||
|
combinedSignal,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
for await (const event of recoveryStream) {
|
||||||
|
yield event;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if they called complete_task in the recovery turn
|
||||||
|
const completeCall = turn.pendingToolCalls.find(
|
||||||
|
(c) => c.name === TASK_COMPLETE_TOOL_NAME,
|
||||||
|
);
|
||||||
|
if (completeCall) {
|
||||||
|
success = true;
|
||||||
|
|
||||||
|
// Capture the result in the turn object explicitly
|
||||||
|
const outputName = this.definition.outputConfig?.outputName || 'result';
|
||||||
|
const rawFindings =
|
||||||
|
completeCall.args[outputName] || completeCall.args['result'];
|
||||||
|
|
||||||
|
if (rawFindings) {
|
||||||
|
turn.submittedOutput =
|
||||||
|
typeof rawFindings === 'object'
|
||||||
|
? JSON.stringify(rawFindings, null, 2)
|
||||||
|
: String(rawFindings);
|
||||||
|
|
||||||
|
debugLogger.debug(
|
||||||
|
`[AgentHarness] [${this.name}:${this.agentId}] Captured findings from recovery complete_task. Length: ${String(turn.submittedOutput).length}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
graceTimeoutController.abort();
|
||||||
|
logRecoveryAttempt(
|
||||||
|
this.config,
|
||||||
|
new RecoveryAttemptEvent(
|
||||||
|
this.agentId,
|
||||||
|
this.name,
|
||||||
|
reason,
|
||||||
|
Date.now() - recoveryStartTime,
|
||||||
|
success,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFinalWarningMessage(reason: AgentTerminateMode): string {
|
||||||
|
let explanation = '';
|
||||||
|
switch (reason) {
|
||||||
|
case AgentTerminateMode.TIMEOUT:
|
||||||
|
explanation = 'You have exceeded the time limit.';
|
||||||
|
break;
|
||||||
|
case AgentTerminateMode.MAX_TURNS:
|
||||||
|
explanation = 'You have exceeded the maximum number of turns.';
|
||||||
|
break;
|
||||||
|
case AgentTerminateMode.ERROR_NO_COMPLETE_TASK_CALL:
|
||||||
|
explanation = 'You have stopped calling tools without finishing.';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
explanation = 'Execution was interrupted.';
|
||||||
|
}
|
||||||
|
return `${explanation} You have one final chance to provide your findings. You MUST call \`${TASK_COMPLETE_TOOL_NAME}\` immediately with your best synthesis and conclusion for the main agent. Do not call any other tools.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getFinalFailureMessage(
|
||||||
|
reason: AgentTerminateMode,
|
||||||
|
maxTurns: number,
|
||||||
|
maxTime: number,
|
||||||
|
) {
|
||||||
|
switch (reason) {
|
||||||
|
case AgentTerminateMode.TIMEOUT:
|
||||||
|
return `Agent timed out after ${maxTime} minutes.`;
|
||||||
|
case AgentTerminateMode.MAX_TURNS:
|
||||||
|
return `Agent reached max turns limit (${maxTurns}).`;
|
||||||
|
case AgentTerminateMode.ERROR_NO_COMPLETE_TASK_CALL:
|
||||||
|
return `Agent stopped calling tools but did not call '${TASK_COMPLETE_TOOL_NAME}'.`;
|
||||||
|
default:
|
||||||
|
return 'Agent execution was terminated before completion.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -109,8 +109,8 @@ export const CodebaseInvestigatorAgent = (
|
|||||||
},
|
},
|
||||||
|
|
||||||
runConfig: {
|
runConfig: {
|
||||||
maxTimeMinutes: 3,
|
maxTimeMinutes: 10,
|
||||||
maxTurns: 10,
|
maxTurns: 50,
|
||||||
},
|
},
|
||||||
|
|
||||||
toolConfig: {
|
toolConfig: {
|
||||||
|
|||||||
@@ -0,0 +1,398 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||||
|
import { HarnessSubagentInvocation } from './harness-invocation.js';
|
||||||
|
import { makeFakeConfig } from '../test-utils/config.js';
|
||||||
|
import { AgentFactory } from './agent-factory.js';
|
||||||
|
import { type Turn } from '../core/turn.js';
|
||||||
|
import { type Config } from '../config/config.js';
|
||||||
|
import { type MessageBus } from '../confirmation-bus/message-bus.js';
|
||||||
|
import type { z } from 'zod';
|
||||||
|
import type { Part } from '@google/genai';
|
||||||
|
import { type LocalAgentDefinition } from './types.js';
|
||||||
|
|
||||||
|
vi.mock('../core/geminiChat.js', () => ({
|
||||||
|
GeminiChat: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./agent-factory.js', () => ({
|
||||||
|
AgentFactory: {
|
||||||
|
createHarness: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('HarnessSubagentInvocation', () => {
|
||||||
|
let mockConfig: Config;
|
||||||
|
let mockMessageBus: MessageBus;
|
||||||
|
let definition: LocalAgentDefinition<z.ZodUnknown>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockConfig = makeFakeConfig();
|
||||||
|
mockMessageBus = {
|
||||||
|
publish: vi.fn(),
|
||||||
|
subscribe: vi.fn(),
|
||||||
|
} as unknown as MessageBus;
|
||||||
|
|
||||||
|
definition = {
|
||||||
|
kind: 'local',
|
||||||
|
name: 'test-agent',
|
||||||
|
displayName: 'Test Agent',
|
||||||
|
description: 'A test agent',
|
||||||
|
inputConfig: {
|
||||||
|
inputSchema: { type: 'object', properties: {}, required: [] },
|
||||||
|
},
|
||||||
|
modelConfig: { model: 'test-model' },
|
||||||
|
runConfig: { maxTurns: 5 },
|
||||||
|
promptConfig: { systemPrompt: 'Test' },
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts result from complete_task tool call arguments', async () => {
|
||||||
|
const invocation = new HarnessSubagentInvocation(
|
||||||
|
definition,
|
||||||
|
mockConfig,
|
||||||
|
{},
|
||||||
|
mockMessageBus,
|
||||||
|
);
|
||||||
|
|
||||||
|
const mockHarness = {
|
||||||
|
run: vi.fn().mockReturnValue(
|
||||||
|
(async function* () {
|
||||||
|
// No intermediate events
|
||||||
|
yield* [];
|
||||||
|
})(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
(AgentFactory.createHarness as Mock).mockReturnValue(mockHarness);
|
||||||
|
|
||||||
|
const mockChat = {
|
||||||
|
getHistory: vi.fn().mockReturnValue([
|
||||||
|
{
|
||||||
|
role: 'model',
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
functionCall: {
|
||||||
|
name: 'complete_task',
|
||||||
|
args: { result: 'Extracted Finding' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockTurn = {
|
||||||
|
getResponseText: vi.fn().mockReturnValue(''), // Text is empty
|
||||||
|
chat: mockChat,
|
||||||
|
} as unknown as Turn;
|
||||||
|
|
||||||
|
// Simulate the generator returning the final turn
|
||||||
|
mockHarness.run.mockReturnValue(
|
||||||
|
(async function* () {
|
||||||
|
yield* [];
|
||||||
|
return mockTurn;
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await invocation.execute(new AbortController().signal);
|
||||||
|
|
||||||
|
expect(result.data?.['result']).toBe('Extracted Finding');
|
||||||
|
expect((result.llmContent as Part[])?.[0]).toEqual({
|
||||||
|
text: `Subagent 'test-agent' finished.
|
||||||
|
Termination Reason: goal
|
||||||
|
Result:
|
||||||
|
Extracted Finding`,
|
||||||
|
});
|
||||||
|
expect(result.returnDisplay).toContain('Extracted Finding');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers direct text response over complete_task arguments if available', async () => {
|
||||||
|
const invocation = new HarnessSubagentInvocation(
|
||||||
|
definition,
|
||||||
|
mockConfig,
|
||||||
|
{},
|
||||||
|
mockMessageBus,
|
||||||
|
);
|
||||||
|
|
||||||
|
const mockHarness = {
|
||||||
|
run: vi.fn(),
|
||||||
|
};
|
||||||
|
(AgentFactory.createHarness as Mock).mockReturnValue(mockHarness);
|
||||||
|
|
||||||
|
const mockChat = {
|
||||||
|
getHistory: vi.fn().mockReturnValue([
|
||||||
|
{
|
||||||
|
role: 'model',
|
||||||
|
parts: [{ text: 'Textual Result' }],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockTurn = {
|
||||||
|
getResponseText: vi.fn().mockReturnValue('Textual Result'),
|
||||||
|
chat: mockChat,
|
||||||
|
} as unknown as Turn;
|
||||||
|
|
||||||
|
mockHarness.run.mockReturnValue(
|
||||||
|
(async function* () {
|
||||||
|
yield* [];
|
||||||
|
return mockTurn;
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await invocation.execute(new AbortController().signal);
|
||||||
|
|
||||||
|
expect(result.data?.['result']).toBe('Textual Result');
|
||||||
|
expect((result.llmContent as Part[])?.[0]).toEqual({
|
||||||
|
text: `Subagent 'test-agent' finished.
|
||||||
|
Termination Reason: goal
|
||||||
|
Result:
|
||||||
|
Textual Result`,
|
||||||
|
});
|
||||||
|
expect(result.returnDisplay).toContain('Textual Result');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a default message if no result is found', async () => {
|
||||||
|
const invocation = new HarnessSubagentInvocation(
|
||||||
|
definition,
|
||||||
|
mockConfig,
|
||||||
|
{},
|
||||||
|
mockMessageBus,
|
||||||
|
);
|
||||||
|
|
||||||
|
const mockHarness = {
|
||||||
|
run: vi.fn(),
|
||||||
|
};
|
||||||
|
(AgentFactory.createHarness as Mock).mockReturnValue(mockHarness);
|
||||||
|
|
||||||
|
const mockChat = {
|
||||||
|
getHistory: vi.fn().mockReturnValue([]),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockTurn = {
|
||||||
|
getResponseText: vi.fn().mockReturnValue(''),
|
||||||
|
chat: mockChat,
|
||||||
|
} as unknown as Turn;
|
||||||
|
|
||||||
|
mockHarness.run.mockReturnValue(
|
||||||
|
(async function* () {
|
||||||
|
yield* [];
|
||||||
|
return mockTurn;
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await invocation.execute(new AbortController().signal);
|
||||||
|
|
||||||
|
expect(result.data?.['result']).toBe('Task completed.');
|
||||||
|
expect(result.returnDisplay).toContain('Task completed.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds the LAST relevant model message if multiple exist', async () => {
|
||||||
|
const invocation = new HarnessSubagentInvocation(
|
||||||
|
definition,
|
||||||
|
mockConfig,
|
||||||
|
{},
|
||||||
|
mockMessageBus,
|
||||||
|
);
|
||||||
|
|
||||||
|
const mockHarness = {
|
||||||
|
run: vi.fn(),
|
||||||
|
};
|
||||||
|
(AgentFactory.createHarness as Mock).mockReturnValue(mockHarness);
|
||||||
|
|
||||||
|
const mockChat = {
|
||||||
|
getHistory: vi.fn().mockReturnValue([
|
||||||
|
{
|
||||||
|
role: 'model',
|
||||||
|
parts: [{ text: 'Old Result' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
parts: [{ text: 'Keep going' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'model',
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
functionCall: {
|
||||||
|
name: 'complete_task',
|
||||||
|
args: { result: 'Newest Result' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockTurn = {
|
||||||
|
getResponseText: vi.fn().mockReturnValue(''),
|
||||||
|
chat: mockChat,
|
||||||
|
} as unknown as Turn;
|
||||||
|
|
||||||
|
mockHarness.run.mockReturnValue(
|
||||||
|
(async function* () {
|
||||||
|
yield* [];
|
||||||
|
return mockTurn;
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await invocation.execute(new AbortController().signal);
|
||||||
|
|
||||||
|
expect(result.data?.['result']).toBe('Newest Result');
|
||||||
|
expect(result.returnDisplay).toContain('Newest Result');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles model messages with only thoughts and no result-bearing parts', async () => {
|
||||||
|
const invocation = new HarnessSubagentInvocation(
|
||||||
|
definition,
|
||||||
|
mockConfig,
|
||||||
|
{},
|
||||||
|
mockMessageBus,
|
||||||
|
);
|
||||||
|
|
||||||
|
const mockHarness = {
|
||||||
|
run: vi.fn(),
|
||||||
|
};
|
||||||
|
(AgentFactory.createHarness as Mock).mockReturnValue(mockHarness);
|
||||||
|
|
||||||
|
const mockChat = {
|
||||||
|
getHistory: vi.fn().mockReturnValue([
|
||||||
|
{
|
||||||
|
role: 'model',
|
||||||
|
parts: [
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
{ thought: true, text: 'Thinking about finishing...' } as any,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockTurn = {
|
||||||
|
getResponseText: vi.fn().mockReturnValue(''),
|
||||||
|
chat: mockChat,
|
||||||
|
} as unknown as Turn;
|
||||||
|
|
||||||
|
mockHarness.run.mockReturnValue(
|
||||||
|
(async function* () {
|
||||||
|
yield* [];
|
||||||
|
return mockTurn;
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await invocation.execute(new AbortController().signal);
|
||||||
|
|
||||||
|
expect(result.data?.['result']).toBe('Task completed.');
|
||||||
|
expect(result.returnDisplay).toContain('Task completed.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts result using the custom outputName from outputConfig', async () => {
|
||||||
|
const customDefinition: LocalAgentDefinition = {
|
||||||
|
...definition,
|
||||||
|
outputConfig: {
|
||||||
|
outputName: 'report',
|
||||||
|
description: 'A custom report',
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
schema: { type: 'string' } as any,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const invocation = new HarnessSubagentInvocation(
|
||||||
|
customDefinition,
|
||||||
|
mockConfig,
|
||||||
|
{},
|
||||||
|
mockMessageBus,
|
||||||
|
);
|
||||||
|
|
||||||
|
const mockHarness = {
|
||||||
|
run: vi.fn(),
|
||||||
|
};
|
||||||
|
(AgentFactory.createHarness as Mock).mockReturnValue(mockHarness);
|
||||||
|
|
||||||
|
const mockChat = {
|
||||||
|
getHistory: vi.fn().mockReturnValue([
|
||||||
|
{
|
||||||
|
role: 'model',
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
functionCall: {
|
||||||
|
name: 'complete_task',
|
||||||
|
args: { report: 'The custom report content' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockTurn = {
|
||||||
|
getResponseText: vi.fn().mockReturnValue(''),
|
||||||
|
chat: mockChat,
|
||||||
|
} as unknown as Turn;
|
||||||
|
|
||||||
|
mockHarness.run.mockReturnValue(
|
||||||
|
(async function* () {
|
||||||
|
yield* [];
|
||||||
|
return mockTurn;
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await invocation.execute(new AbortController().signal);
|
||||||
|
|
||||||
|
expect(result.data?.['report']).toBe('The custom report content');
|
||||||
|
expect(result.returnDisplay).toContain('The custom report content');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prioritizes complete_task args over whitespace-only text', async () => {
|
||||||
|
const invocation = new HarnessSubagentInvocation(
|
||||||
|
definition,
|
||||||
|
mockConfig,
|
||||||
|
{},
|
||||||
|
mockMessageBus,
|
||||||
|
);
|
||||||
|
|
||||||
|
const mockHarness = {
|
||||||
|
run: vi.fn(),
|
||||||
|
};
|
||||||
|
(AgentFactory.createHarness as Mock).mockReturnValue(mockHarness);
|
||||||
|
|
||||||
|
const mockChat = {
|
||||||
|
getHistory: vi.fn().mockReturnValue([
|
||||||
|
{
|
||||||
|
role: 'model',
|
||||||
|
parts: [
|
||||||
|
{ text: ' \n ' },
|
||||||
|
{
|
||||||
|
functionCall: {
|
||||||
|
name: 'complete_task',
|
||||||
|
args: { result: 'Actual Result' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockTurn = {
|
||||||
|
getResponseText: vi.fn().mockReturnValue(' \n '),
|
||||||
|
chat: mockChat,
|
||||||
|
} as unknown as Turn;
|
||||||
|
|
||||||
|
mockHarness.run.mockReturnValue(
|
||||||
|
(async function* () {
|
||||||
|
yield* [];
|
||||||
|
return mockTurn;
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await invocation.execute(new AbortController().signal);
|
||||||
|
|
||||||
|
expect(result.data?.['result']).toBe('Actual Result');
|
||||||
|
expect(result.returnDisplay).toContain('Actual Result');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Config } from '../config/config.js';
|
||||||
|
import type { AnsiOutput } from '../utils/terminalSerializer.js';
|
||||||
|
import { BaseToolInvocation, type ToolResult } from '../tools/tools.js';
|
||||||
|
import { ToolErrorType } from '../tools/tool-error.js';
|
||||||
|
import { debugLogger } from '../utils/debugLogger.js';
|
||||||
|
import type { LocalAgentDefinition, AgentInputs } from './types.js';
|
||||||
|
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||||
|
import { MessageBusType } from '../confirmation-bus/types.js';
|
||||||
|
import { AgentFactory } from './agent-factory.js';
|
||||||
|
import { type Turn, GeminiEventType } from '../core/turn.js';
|
||||||
|
import { promptIdContext } from '../utils/promptIdContext.js';
|
||||||
|
|
||||||
|
const INPUT_PREVIEW_MAX_LENGTH = 50;
|
||||||
|
const DESCRIPTION_MAX_LENGTH = 200;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A specialized invocation for running subagents within the AgentHarness.
|
||||||
|
* COMPLETELY FORKED from LocalSubagentInvocation to ensure isolated logic.
|
||||||
|
*/
|
||||||
|
export class HarnessSubagentInvocation extends BaseToolInvocation<
|
||||||
|
AgentInputs,
|
||||||
|
ToolResult
|
||||||
|
> {
|
||||||
|
constructor(
|
||||||
|
private readonly definition: LocalAgentDefinition,
|
||||||
|
private readonly config: Config,
|
||||||
|
params: AgentInputs,
|
||||||
|
messageBus: MessageBus,
|
||||||
|
_toolName?: string,
|
||||||
|
_toolDisplayName?: string,
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
params,
|
||||||
|
messageBus,
|
||||||
|
_toolName ?? definition.name,
|
||||||
|
_toolDisplayName ?? definition.displayName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
const inputSummary = Object.entries(this.params)
|
||||||
|
.map(
|
||||||
|
([key, value]) =>
|
||||||
|
`${key}: ${String(value).slice(0, INPUT_PREVIEW_MAX_LENGTH)}`,
|
||||||
|
)
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
return `Running harness subagent '${this.definition.name}' with inputs: { ${inputSummary} }`.slice(
|
||||||
|
0,
|
||||||
|
DESCRIPTION_MAX_LENGTH,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(
|
||||||
|
signal: AbortSignal,
|
||||||
|
updateOutput?: (output: string | AnsiOutput) => void,
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
try {
|
||||||
|
if (updateOutput) {
|
||||||
|
updateOutput(`Subagent ${this.definition.name} starting (Harness Mode)...
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const harness = AgentFactory.createHarness(this.config, this.definition, {
|
||||||
|
inputs: this.params,
|
||||||
|
parentPromptId: promptIdContext.getStore(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const initialRequest = [{ text: 'Start' }];
|
||||||
|
const stream = harness.run(
|
||||||
|
initialRequest,
|
||||||
|
signal,
|
||||||
|
this.definition.runConfig?.maxTurns,
|
||||||
|
);
|
||||||
|
|
||||||
|
let turn: Turn | undefined;
|
||||||
|
let lastThought = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await stream.next();
|
||||||
|
if (done) {
|
||||||
|
turn = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = value;
|
||||||
|
if (updateOutput) {
|
||||||
|
if (event.type === GeminiEventType.Thought && 'value' in event) {
|
||||||
|
lastThought = event.value.subject;
|
||||||
|
updateOutput(`🤖💭 ${lastThought}\n`);
|
||||||
|
|
||||||
|
// Also publish to message bus so UI hooks can see it regardless of where they listen
|
||||||
|
void this.messageBus.publish({
|
||||||
|
type: MessageBusType.SUBAGENT_ACTIVITY,
|
||||||
|
activity: {
|
||||||
|
agentName: this.definition.name,
|
||||||
|
type: 'THOUGHT',
|
||||||
|
data: { subject: lastThought },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
event.type === GeminiEventType.SubagentActivity &&
|
||||||
|
'value' in event
|
||||||
|
) {
|
||||||
|
if (event.value.type === 'TOOL_CALL_START') {
|
||||||
|
const toolName = String(event.value.data['name'] || 'a tool');
|
||||||
|
updateOutput(`🛠️ Calling ${toolName}...\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward the core activity to the global bus
|
||||||
|
void this.messageBus.publish({
|
||||||
|
type: MessageBusType.SUBAGENT_ACTIVITY,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
activity: event.value as any,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!turn) {
|
||||||
|
throw new Error('Agent failed to return a valid turn.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Initialize result with the explicit submitted output if available
|
||||||
|
let finalResultRaw: unknown = turn.submittedOutput;
|
||||||
|
|
||||||
|
// 2. Fallback: If no explicit output, try textual response
|
||||||
|
if (finalResultRaw === undefined) {
|
||||||
|
const output = turn.getResponseText();
|
||||||
|
debugLogger.debug(
|
||||||
|
`[AgentHarness] [Invocation:${this.definition.name}] Initial response text: "${output}"`,
|
||||||
|
);
|
||||||
|
if (output.trim()) {
|
||||||
|
finalResultRaw = output;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputName = this.definition.outputConfig?.outputName || 'result';
|
||||||
|
|
||||||
|
// 3. Fallback: If still no result, extract from 'complete_task' tool call arguments (Directly from the turn)
|
||||||
|
if (finalResultRaw === undefined) {
|
||||||
|
const completeCall = turn.pendingToolCalls?.find(
|
||||||
|
(c) => c.name === 'complete_task',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (completeCall) {
|
||||||
|
debugLogger.debug(
|
||||||
|
`[AgentHarness] [Invocation:${this.definition.name}] Found 'complete_task' call in pending tool calls.`,
|
||||||
|
);
|
||||||
|
finalResultRaw =
|
||||||
|
completeCall.args[outputName] || completeCall.args['result'];
|
||||||
|
|
||||||
|
if (finalResultRaw !== undefined) {
|
||||||
|
debugLogger.debug(
|
||||||
|
`[AgentHarness] [Invocation:${this.definition.name}] Extracted raw result from complete_task args (${outputName}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Fallback: If no result yet, look for any definitive findings in the history
|
||||||
|
if (finalResultRaw === undefined) {
|
||||||
|
debugLogger.debug(
|
||||||
|
`[AgentHarness] [Invocation:${this.definition.name}] No direct result found, checking history...`,
|
||||||
|
);
|
||||||
|
const history = turn.chat.getHistory();
|
||||||
|
|
||||||
|
// Find the last model message that has either non-thought text or a complete_task call
|
||||||
|
const lastMsgWithResult = history.findLast(
|
||||||
|
(m) =>
|
||||||
|
m.role === 'model' &&
|
||||||
|
m.parts &&
|
||||||
|
(m.parts.some(
|
||||||
|
(p) =>
|
||||||
|
!('thought' in p && p.thought) && 'text' in p && p.text?.trim(),
|
||||||
|
) ||
|
||||||
|
m.parts.some(
|
||||||
|
(p) =>
|
||||||
|
'functionCall' in p &&
|
||||||
|
p.functionCall &&
|
||||||
|
p.functionCall.name === 'complete_task',
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (lastMsgWithResult?.parts) {
|
||||||
|
// Check for text part first (likely injected by Harness)
|
||||||
|
const textPart = lastMsgWithResult.parts.find(
|
||||||
|
(p) =>
|
||||||
|
!('thought' in p && p.thought) && 'text' in p && p.text?.trim(),
|
||||||
|
);
|
||||||
|
if (textPart && 'text' in textPart && textPart.text) {
|
||||||
|
finalResultRaw = textPart.text;
|
||||||
|
debugLogger.debug(
|
||||||
|
`[AgentHarness] [Invocation:${this.definition.name}] Extracted result from history text part.`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Check for complete_task call in history (what the tests use)
|
||||||
|
const callPart = lastMsgWithResult.parts.find(
|
||||||
|
(p) =>
|
||||||
|
'functionCall' in p && p.functionCall?.name === 'complete_task',
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
callPart &&
|
||||||
|
'functionCall' in callPart &&
|
||||||
|
callPart.functionCall
|
||||||
|
) {
|
||||||
|
finalResultRaw =
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
(callPart.functionCall.args as Record<string, unknown>)?.[
|
||||||
|
outputName
|
||||||
|
] ||
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
(callPart.functionCall.args as Record<string, unknown>)?.[
|
||||||
|
'result'
|
||||||
|
];
|
||||||
|
if (finalResultRaw !== undefined) {
|
||||||
|
debugLogger.debug(
|
||||||
|
`[AgentHarness] [Invocation:${this.definition.name}] Extracted result from history function call.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalResultString =
|
||||||
|
typeof finalResultRaw === 'object'
|
||||||
|
? JSON.stringify(finalResultRaw, null, 2)
|
||||||
|
: String(finalResultRaw ?? 'Task completed.');
|
||||||
|
|
||||||
|
const displayContent = `
|
||||||
|
Subagent ${this.definition.name} Finished (Harness Mode)
|
||||||
|
|
||||||
|
Result:
|
||||||
|
${finalResultString}
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (updateOutput) {
|
||||||
|
updateOutput(displayContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse as JSON if it's a string that looks like an object, to satisfy schema requirements
|
||||||
|
let finalResultData = finalResultRaw ?? 'Task completed.';
|
||||||
|
if (
|
||||||
|
typeof finalResultData === 'string' &&
|
||||||
|
finalResultData.trim().startsWith('{')
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
finalResultData = JSON.parse(finalResultData);
|
||||||
|
debugLogger.debug(
|
||||||
|
`[AgentHarness] [Invocation:${this.definition.name}] Parsed string result into JSON object.`,
|
||||||
|
);
|
||||||
|
} catch (_e) {
|
||||||
|
// Not valid JSON, keep as string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debugLogger.debug(
|
||||||
|
`[AgentHarness] [Invocation:${this.definition.name}] Returning data to parent: ${JSON.stringify(
|
||||||
|
finalResultData,
|
||||||
|
).slice(0, 500)}...`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const resultContent = `Subagent '${this.definition.name}' finished.
|
||||||
|
Termination Reason: goal
|
||||||
|
Result:
|
||||||
|
${finalResultString}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
llmContent: [{ text: resultContent }],
|
||||||
|
returnDisplay: displayContent,
|
||||||
|
data: {
|
||||||
|
[outputName]: finalResultData,
|
||||||
|
result: finalResultData,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
llmContent: [],
|
||||||
|
returnDisplay: `Subagent Failed: ${this.definition.name}
|
||||||
|
Error: ${errorMessage}`,
|
||||||
|
error: {
|
||||||
|
message: errorMessage,
|
||||||
|
type: ToolErrorType.EXECUTION_FAILED,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||||
|
import { AgentHarness } from './harness.js';
|
||||||
|
import { makeFakeConfig } from '../test-utils/config.js';
|
||||||
|
import { GeminiChat, StreamEventType } from '../core/geminiChat.js';
|
||||||
|
import { GeminiEventType, type ServerGeminiStreamEvent } from '../core/turn.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { AgentTerminateMode, type LocalAgentDefinition } from './types.js';
|
||||||
|
import { scheduleAgentTools } from './agent-scheduler.js';
|
||||||
|
import { logAgentFinish } from '../telemetry/loggers.js';
|
||||||
|
import { type Config } from '../config/config.js';
|
||||||
|
import { MainAgentBehavior, SubagentBehavior } from './behavior.js';
|
||||||
|
|
||||||
|
vi.mock('../telemetry/loggers.js', async (importOriginal) => {
|
||||||
|
const actual =
|
||||||
|
await importOriginal<typeof import('../telemetry/loggers.js')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
logAgentStart: vi.fn(),
|
||||||
|
logAgentFinish: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../core/geminiChat.js', () => ({
|
||||||
|
GeminiChat: vi.fn(),
|
||||||
|
StreamEventType: {
|
||||||
|
CHUNK: 'chunk',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./agent-scheduler.js', () => ({
|
||||||
|
scheduleAgentTools: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('AgentHarness', () => {
|
||||||
|
let mockConfig: Config;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockConfig = makeFakeConfig();
|
||||||
|
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
|
||||||
|
getTool: vi.fn(),
|
||||||
|
getAllToolNames: vi.fn().mockReturnValue([]),
|
||||||
|
getFunctionDeclarations: vi.fn().mockReturnValue([]),
|
||||||
|
registerTool: vi.fn(),
|
||||||
|
sortTools: vi.fn(),
|
||||||
|
});
|
||||||
|
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
|
||||||
|
getAllDefinitions: vi.fn().mockReturnValue([]),
|
||||||
|
});
|
||||||
|
mockConfig.getEnableHooks = vi.fn().mockReturnValue(false);
|
||||||
|
mockConfig.getHookSystem = vi.fn().mockReturnValue(null);
|
||||||
|
mockConfig.getIdeMode = vi.fn().mockReturnValue(false);
|
||||||
|
mockConfig.getBaseLlmClient = vi.fn().mockReturnValue({});
|
||||||
|
mockConfig.getModelRouterService = vi.fn().mockReturnValue({
|
||||||
|
route: vi.fn().mockResolvedValue({
|
||||||
|
model: 'gemini-test-model',
|
||||||
|
metadata: { source: 'test' },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SubagentBehavior', () => {
|
||||||
|
it('executes a subagent and finishes when complete_task is called', async () => {
|
||||||
|
const definition: LocalAgentDefinition<z.ZodUnknown> = {
|
||||||
|
kind: 'local',
|
||||||
|
name: 'test-agent',
|
||||||
|
displayName: 'Test Agent',
|
||||||
|
description: 'A test agent',
|
||||||
|
inputConfig: {
|
||||||
|
inputSchema: { type: 'object', properties: {}, required: [] },
|
||||||
|
},
|
||||||
|
modelConfig: { model: 'gemini-test-model' },
|
||||||
|
runConfig: { maxTurns: 5, maxTimeMinutes: 5 },
|
||||||
|
promptConfig: { systemPrompt: 'You are a test agent.' },
|
||||||
|
outputConfig: {
|
||||||
|
outputName: 'result',
|
||||||
|
description: 'The final result.',
|
||||||
|
schema: z.unknown(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const behavior = new SubagentBehavior(mockConfig, definition);
|
||||||
|
const harness = new AgentHarness({
|
||||||
|
config: mockConfig,
|
||||||
|
behavior,
|
||||||
|
isolatedTools: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockChat = {
|
||||||
|
sendMessageStream: vi.fn(),
|
||||||
|
setTools: vi.fn(),
|
||||||
|
getHistory: vi.fn().mockReturnValue([]),
|
||||||
|
addHistory: vi.fn(),
|
||||||
|
setSystemInstruction: vi.fn(),
|
||||||
|
maybeIncludeSchemaDepthContext: vi.fn(),
|
||||||
|
getLastPromptTokenCount: vi.fn().mockReturnValue(0),
|
||||||
|
} as unknown as GeminiChat;
|
||||||
|
(GeminiChat as unknown as Mock).mockReturnValue(mockChat);
|
||||||
|
|
||||||
|
// Mock model response with complete_task call
|
||||||
|
(mockChat.sendMessageStream as Mock).mockResolvedValue(
|
||||||
|
(async function* () {
|
||||||
|
yield {
|
||||||
|
type: StreamEventType.CHUNK,
|
||||||
|
value: {
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: { parts: [{ text: 'Done!' }] },
|
||||||
|
finishReason: 'STOP',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
functionCalls: [
|
||||||
|
{
|
||||||
|
name: 'complete_task',
|
||||||
|
args: { result: 'Success' },
|
||||||
|
id: 'call_1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mock tool execution
|
||||||
|
(scheduleAgentTools as unknown as Mock).mockResolvedValue([
|
||||||
|
{
|
||||||
|
request: {
|
||||||
|
name: 'complete_task',
|
||||||
|
args: { result: 'Success' },
|
||||||
|
callId: 'call_1',
|
||||||
|
},
|
||||||
|
status: 'success',
|
||||||
|
response: {
|
||||||
|
responseParts: [
|
||||||
|
{
|
||||||
|
functionResponse: {
|
||||||
|
name: 'complete_task',
|
||||||
|
response: { status: 'OK' },
|
||||||
|
id: 'call_1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const events: ServerGeminiStreamEvent[] = [];
|
||||||
|
const run = harness.run(
|
||||||
|
[{ text: 'Start' }],
|
||||||
|
new AbortController().signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await run.next();
|
||||||
|
if (done) break;
|
||||||
|
events.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(
|
||||||
|
events.some(
|
||||||
|
(e) =>
|
||||||
|
e.type === GeminiEventType.ToolCallRequest &&
|
||||||
|
e.value.name === 'complete_task',
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(vi.mocked(logAgentFinish)).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({ terminate_reason: AgentTerminateMode.GOAL }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multiple turns and model routing', async () => {
|
||||||
|
const definition: LocalAgentDefinition<z.ZodUnknown> = {
|
||||||
|
kind: 'local',
|
||||||
|
name: 'multi-turn-agent',
|
||||||
|
description: 'Testing multiple turns',
|
||||||
|
inputConfig: {
|
||||||
|
inputSchema: { type: 'object', properties: {}, required: [] },
|
||||||
|
},
|
||||||
|
modelConfig: { model: 'initial-model' },
|
||||||
|
runConfig: { maxTurns: 5 },
|
||||||
|
promptConfig: { systemPrompt: 'Test' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const behavior = new SubagentBehavior(mockConfig, definition);
|
||||||
|
const harness = new AgentHarness({ config: mockConfig, behavior });
|
||||||
|
|
||||||
|
const mockChat = {
|
||||||
|
sendMessageStream: vi.fn(),
|
||||||
|
setTools: vi.fn(),
|
||||||
|
getHistory: vi.fn().mockReturnValue([]),
|
||||||
|
addHistory: vi.fn(),
|
||||||
|
setSystemInstruction: vi.fn(),
|
||||||
|
maybeIncludeSchemaDepthContext: vi.fn(),
|
||||||
|
getLastPromptTokenCount: vi.fn().mockReturnValue(0),
|
||||||
|
} as unknown as GeminiChat;
|
||||||
|
(GeminiChat as unknown as Mock).mockReturnValue(mockChat);
|
||||||
|
|
||||||
|
// Turn 1: Model calls a tool
|
||||||
|
(mockChat.sendMessageStream as Mock).mockResolvedValueOnce(
|
||||||
|
(async function* () {
|
||||||
|
yield {
|
||||||
|
type: StreamEventType.CHUNK,
|
||||||
|
value: {
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: { parts: [{ text: 'Thinking...' }] },
|
||||||
|
finishReason: 'STOP',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
functionCalls: [{ name: 'tool_1', args: {}, id: 'c1' }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Turn 2: Model finishes with complete_task
|
||||||
|
(mockChat.sendMessageStream as Mock).mockResolvedValueOnce(
|
||||||
|
(async function* () {
|
||||||
|
yield {
|
||||||
|
type: StreamEventType.CHUNK,
|
||||||
|
value: {
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: { parts: [{ text: 'Done' }] },
|
||||||
|
finishReason: 'STOP',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
functionCalls: [
|
||||||
|
{
|
||||||
|
name: 'complete_task',
|
||||||
|
args: { result: 'Success' },
|
||||||
|
id: 'c2',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
|
||||||
|
(scheduleAgentTools as unknown as Mock).mockResolvedValue([
|
||||||
|
{
|
||||||
|
request: { name: 'tool_1', callId: 'c1' },
|
||||||
|
status: 'success',
|
||||||
|
response: {
|
||||||
|
responseParts: [
|
||||||
|
{ functionResponse: { name: 'tool_1', response: {}, id: 'c1' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const run = harness.run(
|
||||||
|
[{ text: 'Start' }],
|
||||||
|
new AbortController().signal,
|
||||||
|
);
|
||||||
|
while (true) {
|
||||||
|
const { done } = await run.next();
|
||||||
|
if (done) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have called LLM twice
|
||||||
|
expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockConfig.getModelRouterService().route).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attempts recovery when max turns is reached', async () => {
|
||||||
|
const definition: LocalAgentDefinition<z.ZodUnknown> = {
|
||||||
|
kind: 'local',
|
||||||
|
name: 'unproductive-agent',
|
||||||
|
description: 'Reaches max turns',
|
||||||
|
inputConfig: {
|
||||||
|
inputSchema: { type: 'object', properties: {}, required: [] },
|
||||||
|
},
|
||||||
|
modelConfig: { model: 'test' },
|
||||||
|
runConfig: { maxTurns: 1 },
|
||||||
|
promptConfig: { systemPrompt: 'Test' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const behavior = new SubagentBehavior(mockConfig, definition);
|
||||||
|
const harness = new AgentHarness({ config: mockConfig, behavior });
|
||||||
|
|
||||||
|
const mockChat = {
|
||||||
|
sendMessageStream: vi.fn(),
|
||||||
|
setTools: vi.fn(),
|
||||||
|
getHistory: vi.fn().mockReturnValue([]),
|
||||||
|
addHistory: vi.fn(),
|
||||||
|
setSystemInstruction: vi.fn(),
|
||||||
|
maybeIncludeSchemaDepthContext: vi.fn(),
|
||||||
|
getLastPromptTokenCount: vi.fn().mockReturnValue(0),
|
||||||
|
} as unknown as GeminiChat;
|
||||||
|
(GeminiChat as unknown as Mock).mockReturnValue(mockChat);
|
||||||
|
|
||||||
|
// Turn 1: Model does nothing (just content) -> reaches limit
|
||||||
|
(mockChat.sendMessageStream as Mock).mockResolvedValueOnce(
|
||||||
|
(async function* () {
|
||||||
|
yield {
|
||||||
|
type: StreamEventType.CHUNK,
|
||||||
|
value: {
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: { parts: [{ text: 'Thinking...' }] },
|
||||||
|
finishReason: 'STOP',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Turn 2 (Recovery): Model yields complete_task
|
||||||
|
(mockChat.sendMessageStream as Mock).mockResolvedValueOnce(
|
||||||
|
(async function* () {
|
||||||
|
yield {
|
||||||
|
type: StreamEventType.CHUNK,
|
||||||
|
value: {
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: { parts: [{ text: 'Final Answer' }] },
|
||||||
|
finishReason: 'STOP',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
functionCalls: [
|
||||||
|
{
|
||||||
|
name: 'complete_task',
|
||||||
|
args: { result: 'Recovered' },
|
||||||
|
id: 'rec',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const run = harness.run(
|
||||||
|
[{ text: 'Start' }],
|
||||||
|
new AbortController().signal,
|
||||||
|
);
|
||||||
|
while (true) {
|
||||||
|
const { done } = await run.next();
|
||||||
|
if (done) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expect goal to be reached via recovery
|
||||||
|
expect(vi.mocked(logAgentFinish)).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({ terminate_reason: AgentTerminateMode.GOAL }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MainAgentBehavior', () => {
|
||||||
|
it('fires BeforeAgent hooks and handles blocking', async () => {
|
||||||
|
const behavior = new MainAgentBehavior(mockConfig);
|
||||||
|
const harness = new AgentHarness({ config: mockConfig, behavior });
|
||||||
|
|
||||||
|
const mockHookSystem = {
|
||||||
|
fireBeforeAgentEvent: vi.fn().mockResolvedValue({
|
||||||
|
shouldStopExecution: () => true,
|
||||||
|
isBlockingDecision: () => true,
|
||||||
|
getEffectiveReason: () => 'Blocked by hook',
|
||||||
|
systemMessage: 'Access denied',
|
||||||
|
getAdditionalContext: () => undefined,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
mockConfig.getHookSystem = vi.fn().mockReturnValue(mockHookSystem);
|
||||||
|
mockConfig.getEnableHooks = vi.fn().mockReturnValue(true);
|
||||||
|
|
||||||
|
const events: ServerGeminiStreamEvent[] = [];
|
||||||
|
const run = harness.run(
|
||||||
|
[{ text: 'Hello' }],
|
||||||
|
new AbortController().signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await run.next();
|
||||||
|
if (done) break;
|
||||||
|
events.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(
|
||||||
|
events.some(
|
||||||
|
(e) =>
|
||||||
|
e.type === GeminiEventType.Error &&
|
||||||
|
e.value.error.message === 'Access denied',
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(vi.mocked(logAgentFinish)).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({
|
||||||
|
terminate_reason: AgentTerminateMode.ABORTED,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('syncs IDE context when IDE mode is enabled', async () => {
|
||||||
|
const behavior = new MainAgentBehavior(mockConfig);
|
||||||
|
const harness = new AgentHarness({ config: mockConfig, behavior });
|
||||||
|
|
||||||
|
mockConfig.getIdeMode = vi.fn().mockReturnValue(true);
|
||||||
|
|
||||||
|
const mockChat = {
|
||||||
|
sendMessageStream: vi.fn().mockResolvedValue(
|
||||||
|
(async function* () {
|
||||||
|
yield {
|
||||||
|
type: StreamEventType.CHUNK,
|
||||||
|
value: {
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: { parts: [{ text: 'Response' }] },
|
||||||
|
finishReason: 'STOP',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
),
|
||||||
|
setTools: vi.fn(),
|
||||||
|
getHistory: vi.fn().mockReturnValue([]),
|
||||||
|
addHistory: vi.fn(),
|
||||||
|
setSystemInstruction: vi.fn(),
|
||||||
|
getLastPromptTokenCount: vi.fn().mockReturnValue(0),
|
||||||
|
} as unknown as GeminiChat;
|
||||||
|
(GeminiChat as unknown as Mock).mockReturnValue(mockChat);
|
||||||
|
|
||||||
|
const syncSpy = vi.spyOn(behavior, 'syncEnvironment');
|
||||||
|
|
||||||
|
const run = harness.run(
|
||||||
|
[{ text: 'Hello' }],
|
||||||
|
new AbortController().signal,
|
||||||
|
);
|
||||||
|
while (true) {
|
||||||
|
const { done } = await run.next();
|
||||||
|
if (done) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(syncSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,645 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type Part,
|
||||||
|
type FunctionDeclaration,
|
||||||
|
type Content,
|
||||||
|
} from '@google/genai';
|
||||||
|
import { type Config } from '../config/config.js';
|
||||||
|
import { GeminiChat } from '../core/geminiChat.js';
|
||||||
|
import {
|
||||||
|
Turn,
|
||||||
|
GeminiEventType,
|
||||||
|
type ServerGeminiStreamEvent,
|
||||||
|
CompressionStatus,
|
||||||
|
} from '../core/turn.js';
|
||||||
|
import {
|
||||||
|
AgentTerminateMode,
|
||||||
|
type AgentInputs,
|
||||||
|
DEFAULT_MAX_TURNS,
|
||||||
|
DEFAULT_MAX_TIME_MINUTES,
|
||||||
|
} from './types.js';
|
||||||
|
import { LoopDetectionService } from '../services/loopDetectionService.js';
|
||||||
|
import { ChatCompressionService } from '../services/chatCompressionService.js';
|
||||||
|
import { ToolOutputMaskingService } from '../services/toolOutputMaskingService.js';
|
||||||
|
import { resolveModel } from '../config/models.js';
|
||||||
|
import { type RoutingContext } from '../routing/routingStrategy.js';
|
||||||
|
import { ToolRegistry } from '../tools/tool-registry.js';
|
||||||
|
import { SubagentTool } from './subagent-tool.js';
|
||||||
|
import { scheduleAgentTools } from './agent-scheduler.js';
|
||||||
|
import {
|
||||||
|
type ToolCallRequestInfo,
|
||||||
|
type ToolCallResponseInfo,
|
||||||
|
ROOT_SCHEDULER_ID,
|
||||||
|
} from '../scheduler/types.js';
|
||||||
|
import { promptIdContext } from '../utils/promptIdContext.js';
|
||||||
|
import { logAgentStart, logAgentFinish } from '../telemetry/loggers.js';
|
||||||
|
import { AgentStartEvent, AgentFinishEvent } from '../telemetry/types.js';
|
||||||
|
import { DeadlineTimer } from '../utils/deadlineTimer.js';
|
||||||
|
import { type AgentBehavior } from './behavior.js';
|
||||||
|
import { debugLogger } from '../utils/debugLogger.js';
|
||||||
|
|
||||||
|
const TASK_COMPLETE_TOOL_NAME = 'complete_task';
|
||||||
|
|
||||||
|
export interface AgentHarnessOptions {
|
||||||
|
config: Config;
|
||||||
|
behavior: AgentBehavior;
|
||||||
|
/** Is this an isolated tool registry (subagents)? If not provided, uses global. */
|
||||||
|
isolatedTools?: boolean;
|
||||||
|
/** Inputs for subagent templating. */
|
||||||
|
inputs?: AgentInputs;
|
||||||
|
/** If provided, this prompt_id will be used as a prefix. */
|
||||||
|
parentPromptId?: string;
|
||||||
|
/** Existing chat history to initialize with (e.g. for main agent turns). */
|
||||||
|
history?: Content[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A unified harness for executing agents (both main CLI and subagents).
|
||||||
|
* Consolidates ReAct loop logic, tool scheduling, and state management.
|
||||||
|
*
|
||||||
|
* Uses an AgentBehavior plugin to handle specific personality differences.
|
||||||
|
*/
|
||||||
|
export class AgentHarness {
|
||||||
|
private readonly config: Config;
|
||||||
|
private readonly behavior: AgentBehavior;
|
||||||
|
private readonly loopDetector: LoopDetectionService;
|
||||||
|
private readonly compressionService: ChatCompressionService;
|
||||||
|
private readonly toolOutputMaskingService: ToolOutputMaskingService;
|
||||||
|
private readonly toolRegistry: ToolRegistry;
|
||||||
|
private readonly initialHistory?: Content[];
|
||||||
|
|
||||||
|
private chat?: GeminiChat;
|
||||||
|
private currentSequenceModel: string | null = null;
|
||||||
|
private turnCounter = 0;
|
||||||
|
|
||||||
|
constructor(options: AgentHarnessOptions) {
|
||||||
|
this.config = options.config;
|
||||||
|
this.behavior = options.behavior;
|
||||||
|
this.initialHistory = options.history;
|
||||||
|
|
||||||
|
this.loopDetector = new LoopDetectionService(this.config);
|
||||||
|
this.compressionService = new ChatCompressionService();
|
||||||
|
this.toolOutputMaskingService = new ToolOutputMaskingService();
|
||||||
|
|
||||||
|
// Use an isolated tool registry for subagents, or the global one for the main agent.
|
||||||
|
this.toolRegistry = options.isolatedTools
|
||||||
|
? new ToolRegistry(this.config, this.config.getMessageBus())
|
||||||
|
: this.config.getToolRegistry();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the harness, creating the underlying chat object.
|
||||||
|
*/
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
await this.behavior.initialize(this.toolRegistry);
|
||||||
|
this.chat = await this.createChat();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createChat(): Promise<GeminiChat> {
|
||||||
|
const systemInstruction = await this.behavior.getSystemInstruction();
|
||||||
|
const history =
|
||||||
|
this.initialHistory ?? (await this.behavior.getInitialHistory());
|
||||||
|
const tools = this.prepareToolsList();
|
||||||
|
|
||||||
|
return new GeminiChat(
|
||||||
|
this.config,
|
||||||
|
systemInstruction,
|
||||||
|
[{ functionDeclarations: tools }],
|
||||||
|
history,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private prepareToolsList(): FunctionDeclaration[] {
|
||||||
|
const modelId = this.currentSequenceModel ?? undefined;
|
||||||
|
const baseTools = this.toolRegistry.getFunctionDeclarations(modelId);
|
||||||
|
return this.behavior.prepareTools(baseTools);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the agent with the given request.
|
||||||
|
*/
|
||||||
|
async *run(
|
||||||
|
request: Part[],
|
||||||
|
signal: AbortSignal,
|
||||||
|
maxTurns?: number,
|
||||||
|
): AsyncGenerator<ServerGeminiStreamEvent, Turn> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const maxTurnsLimit = maxTurns ?? DEFAULT_MAX_TURNS;
|
||||||
|
const maxTimeMinutes = DEFAULT_MAX_TIME_MINUTES;
|
||||||
|
|
||||||
|
debugLogger.debug(
|
||||||
|
`[AgentHarness] [${this.behavior.name}:${this.behavior.agentId}] Starting unified ReAct loop. maxTurns: ${maxTurnsLimit}, maxTime: ${maxTimeMinutes}m`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const deadlineTimer = new DeadlineTimer(
|
||||||
|
maxTimeMinutes * 60 * 1000,
|
||||||
|
'Agent timed out.',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Track time spent waiting for user confirmation
|
||||||
|
const onWaitingForConfirmation = (waiting: boolean) => {
|
||||||
|
if (waiting) {
|
||||||
|
deadlineTimer.pause();
|
||||||
|
} else {
|
||||||
|
deadlineTimer.resume();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const combinedSignal = AbortSignal.any([signal, deadlineTimer.signal]);
|
||||||
|
|
||||||
|
logAgentStart(
|
||||||
|
this.config,
|
||||||
|
new AgentStartEvent(this.behavior.agentId, this.behavior.name),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!this.chat) {
|
||||||
|
await this.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
let turn = new Turn(this.chat!, this.behavior.agentId);
|
||||||
|
let currentRequest = await this.behavior.transformRequest(request);
|
||||||
|
|
||||||
|
let terminateReason = AgentTerminateMode.ABORTED;
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (this.turnCounter < maxTurnsLimit) {
|
||||||
|
const promptId = `${this.behavior.agentId}#${this.turnCounter}`;
|
||||||
|
const historySize = this.chat?.getHistory().length || 0;
|
||||||
|
debugLogger.debug(
|
||||||
|
`[AgentHarness] [${this.behavior.name}:${this.behavior.agentId}] Starting turn ${this.turnCounter} (promptId: ${promptId}). History size: ${historySize} messages.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (combinedSignal.aborted) {
|
||||||
|
terminateReason = deadlineTimer.signal.aborted
|
||||||
|
? AgentTerminateMode.TIMEOUT
|
||||||
|
: AgentTerminateMode.ABORTED;
|
||||||
|
if (terminateReason === AgentTerminateMode.ABORTED) {
|
||||||
|
yield { type: GeminiEventType.UserCancelled };
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Hook: Before Agent
|
||||||
|
const beforeResult =
|
||||||
|
await this.behavior.fireBeforeAgent(currentRequest);
|
||||||
|
if (beforeResult.stop) {
|
||||||
|
terminateReason = AgentTerminateMode.ABORTED;
|
||||||
|
if (beforeResult.systemMessage) {
|
||||||
|
yield {
|
||||||
|
type: GeminiEventType.Error,
|
||||||
|
value: { error: { message: beforeResult.systemMessage } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (beforeResult.additionalContext) {
|
||||||
|
currentRequest.push({
|
||||||
|
text: `<hook_context>${beforeResult.additionalContext}</hook_context>`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Sync Environment (IDE Context etc)
|
||||||
|
const envSync = await this.behavior.syncEnvironment(
|
||||||
|
this.chat!.getHistory(),
|
||||||
|
);
|
||||||
|
if (envSync.additionalParts) {
|
||||||
|
currentRequest.push(...envSync.additionalParts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Compression
|
||||||
|
const compressionResult = await this.tryCompressChat(promptId);
|
||||||
|
if (
|
||||||
|
compressionResult.compressionStatus === CompressionStatus.COMPRESSED
|
||||||
|
) {
|
||||||
|
yield {
|
||||||
|
type: GeminiEventType.ChatCompressed,
|
||||||
|
value: compressionResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.toolOutputMaskingService.mask(
|
||||||
|
this.chat!.getHistory(),
|
||||||
|
this.config,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Loop Detection
|
||||||
|
if (await this.loopDetector.turnStarted(combinedSignal)) {
|
||||||
|
terminateReason = AgentTerminateMode.LOOP_DETECTED;
|
||||||
|
yield { type: GeminiEventType.LoopDetected };
|
||||||
|
return turn;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Model Selection/Routing
|
||||||
|
const modelToUse = await this.selectModel(
|
||||||
|
currentRequest,
|
||||||
|
combinedSignal,
|
||||||
|
);
|
||||||
|
if (!this.currentSequenceModel) {
|
||||||
|
yield { type: GeminiEventType.ModelInfo, value: modelToUse };
|
||||||
|
this.currentSequenceModel = modelToUse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Update tools for this model
|
||||||
|
this.chat!.setTools([
|
||||||
|
{ functionDeclarations: this.prepareToolsList() },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 7. Run the turn
|
||||||
|
const turnStream = promptIdContext.run(promptId, () =>
|
||||||
|
turn.run({ model: modelToUse }, currentRequest, combinedSignal),
|
||||||
|
);
|
||||||
|
let hasError = false;
|
||||||
|
let cumulativeResponse = '';
|
||||||
|
|
||||||
|
for await (const event of turnStream) {
|
||||||
|
yield event;
|
||||||
|
if (event.type === GeminiEventType.Error) hasError = true;
|
||||||
|
if (event.type === GeminiEventType.Content && event.value) {
|
||||||
|
cumulativeResponse += event.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subagent activity reporting
|
||||||
|
if (this.behavior.name !== 'main') {
|
||||||
|
const displayName =
|
||||||
|
this.behavior.definition?.displayName || this.behavior.name;
|
||||||
|
|
||||||
|
if (event.type === GeminiEventType.Thought) {
|
||||||
|
yield {
|
||||||
|
type: GeminiEventType.SubagentActivity,
|
||||||
|
value: {
|
||||||
|
agentName: displayName,
|
||||||
|
type: 'THOUGHT',
|
||||||
|
data: { subject: event.value.subject },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === GeminiEventType.ToolCallRequest) {
|
||||||
|
yield {
|
||||||
|
type: GeminiEventType.SubagentActivity,
|
||||||
|
value: {
|
||||||
|
agentName: displayName,
|
||||||
|
type: 'TOOL_CALL_START',
|
||||||
|
data: { name: event.value.name, args: event.value.args },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasError) {
|
||||||
|
terminateReason = AgentTerminateMode.ERROR;
|
||||||
|
return turn;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Hook: After Agent
|
||||||
|
const afterResult = await this.behavior.fireAfterAgent(
|
||||||
|
currentRequest,
|
||||||
|
cumulativeResponse,
|
||||||
|
turn,
|
||||||
|
);
|
||||||
|
if (afterResult.stop) {
|
||||||
|
terminateReason = AgentTerminateMode.GOAL;
|
||||||
|
if (afterResult.contextCleared) {
|
||||||
|
await this.initialize();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (afterResult.shouldContinue) {
|
||||||
|
currentRequest = [{ text: afterResult.reason || 'Continue' }];
|
||||||
|
this.turnCounter++;
|
||||||
|
turn = new Turn(this.chat!, this.behavior.agentId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (combinedSignal.aborted) {
|
||||||
|
terminateReason = deadlineTimer.signal.aborted
|
||||||
|
? AgentTerminateMode.TIMEOUT
|
||||||
|
: AgentTerminateMode.ABORTED;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Handle tool calls or termination
|
||||||
|
if (turn.pendingToolCalls.length > 0) {
|
||||||
|
const toolResults = await this.executeTools(
|
||||||
|
turn.pendingToolCalls,
|
||||||
|
combinedSignal,
|
||||||
|
onWaitingForConfirmation,
|
||||||
|
);
|
||||||
|
|
||||||
|
debugLogger.debug(
|
||||||
|
`[AgentHarness] [${this.behavior.name}:${this.behavior.agentId}] Received ${toolResults.length} tool results. Names: ${toolResults.map((tr) => tr.name).join(', ')}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Yield responses so UI knows they are done
|
||||||
|
for (const result of toolResults) {
|
||||||
|
debugLogger.debug(
|
||||||
|
`[AgentHarness] [${this.behavior.name}:${this.behavior.agentId}] Tool ${result.name} finished. Display length: ${String(result.result?.resultDisplay).length}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.result) {
|
||||||
|
yield {
|
||||||
|
type: GeminiEventType.ToolCallResponse,
|
||||||
|
value: result.result,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Subagent activity reporting
|
||||||
|
if (this.behavior.name !== 'main') {
|
||||||
|
yield {
|
||||||
|
type: GeminiEventType.SubagentActivity,
|
||||||
|
value: {
|
||||||
|
agentName: this.behavior.name,
|
||||||
|
type: 'TOOL_CALL_END',
|
||||||
|
data: {
|
||||||
|
name: result.name,
|
||||||
|
output: result.result.resultDisplay,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const tool = this.toolRegistry.getTool(result.name);
|
||||||
|
if (tool instanceof SubagentTool) {
|
||||||
|
yield {
|
||||||
|
type: GeminiEventType.SubagentActivity,
|
||||||
|
value: {
|
||||||
|
agentName: this.behavior.name,
|
||||||
|
type: 'TOOL_CALL_END',
|
||||||
|
data: {
|
||||||
|
name: result.name,
|
||||||
|
output: result.result.resultDisplay,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const goalReached = this.behavior.isGoalReached(toolResults);
|
||||||
|
debugLogger.debug(
|
||||||
|
`[AgentHarness] [${this.behavior.name}:${this.behavior.agentId}] isGoalReached check: ${goalReached}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (goalReached) {
|
||||||
|
terminateReason = AgentTerminateMode.GOAL;
|
||||||
|
debugLogger.debug(
|
||||||
|
`[AgentHarness] [${this.behavior.name}:${this.behavior.agentId}] Goal reached. Processing findings for ${toolResults.length} tool results.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extract results from the 'complete_task' tool call arguments
|
||||||
|
for (const r of toolResults) {
|
||||||
|
const completeCall = turn.pendingToolCalls.find(
|
||||||
|
(c) => c.name === TASK_COMPLETE_TOOL_NAME,
|
||||||
|
);
|
||||||
|
|
||||||
|
let findingsText: string | undefined;
|
||||||
|
|
||||||
|
if (r.name === TASK_COMPLETE_TOOL_NAME && completeCall) {
|
||||||
|
const outputName =
|
||||||
|
this.behavior.definition?.outputConfig?.outputName ||
|
||||||
|
'result';
|
||||||
|
const args = completeCall.args;
|
||||||
|
const rawFindings = args[outputName] || args['result'];
|
||||||
|
|
||||||
|
debugLogger.debug(
|
||||||
|
`[AgentHarness] [${this.behavior.name}:${this.behavior.agentId}] Extracting from complete_task args (${outputName}). Found: ${!!rawFindings}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rawFindings !== undefined) {
|
||||||
|
// CAPTURE RAW DATA: Don't stringify if it's an object/array,
|
||||||
|
// we need to preserve structure for the parent model.
|
||||||
|
turn.submittedOutput = rawFindings;
|
||||||
|
|
||||||
|
findingsText =
|
||||||
|
typeof rawFindings === 'object'
|
||||||
|
? JSON.stringify(rawFindings, null, 2)
|
||||||
|
: String(rawFindings);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const findings =
|
||||||
|
r.result?.data?.['result'] || r.result?.resultDisplay;
|
||||||
|
if (findings !== undefined) {
|
||||||
|
findingsText = String(findings);
|
||||||
|
// Also capture as raw if not already set
|
||||||
|
if (turn.submittedOutput === undefined) {
|
||||||
|
turn.submittedOutput = findings;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (findingsText) {
|
||||||
|
debugLogger.debug(
|
||||||
|
`[AgentHarness] [${this.behavior.name}:${this.behavior.agentId}] Captured findings text. Length: ${findingsText.length}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return turn;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentRequest = toolResults.map((r) => r.part);
|
||||||
|
this.turnCounter++;
|
||||||
|
if (this.turnCounter >= maxTurnsLimit) {
|
||||||
|
terminateReason = AgentTerminateMode.MAX_TURNS;
|
||||||
|
debugLogger.debug(
|
||||||
|
`[AgentHarness] [${this.behavior.name}:${this.behavior.agentId}] Reached turn limit (${maxTurnsLimit}).`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
turn = new Turn(this.chat!, this.behavior.agentId);
|
||||||
|
|
||||||
|
// Only yield TurnFinished if we are the main agent.
|
||||||
|
// Nested subagent turns should be internal and not trigger UI flushes in the parent.
|
||||||
|
if (this.behavior.name === 'main') {
|
||||||
|
yield { type: GeminiEventType.TurnFinished };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No tool calls. Check for continuation.
|
||||||
|
const nextParts = await this.behavior.getContinuationRequest(
|
||||||
|
turn,
|
||||||
|
combinedSignal,
|
||||||
|
);
|
||||||
|
if (nextParts) {
|
||||||
|
currentRequest = nextParts;
|
||||||
|
this.turnCounter++;
|
||||||
|
if (this.turnCounter >= maxTurnsLimit) {
|
||||||
|
terminateReason = AgentTerminateMode.MAX_TURNS;
|
||||||
|
debugLogger.debug(
|
||||||
|
`[AgentHarness] [${this.behavior.name}:${this.behavior.agentId}] Reached turn limit (${maxTurnsLimit}) during continuation.`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
turn = new Turn(this.chat!, this.behavior.agentId);
|
||||||
|
if (this.behavior.name === 'main') {
|
||||||
|
yield { type: GeminiEventType.TurnFinished };
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.behavior.name !== 'main') {
|
||||||
|
terminateReason = AgentTerminateMode.ERROR_NO_COMPLETE_TASK_CALL;
|
||||||
|
} else {
|
||||||
|
terminateReason = AgentTerminateMode.GOAL;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FINALIZATION & RECOVERY
|
||||||
|
if (
|
||||||
|
terminateReason !== AgentTerminateMode.GOAL &&
|
||||||
|
terminateReason !== AgentTerminateMode.ABORTED
|
||||||
|
) {
|
||||||
|
if (this.turnCounter >= maxTurnsLimit)
|
||||||
|
terminateReason = AgentTerminateMode.MAX_TURNS;
|
||||||
|
|
||||||
|
const recoverySuccess = yield* this.behavior.executeRecovery(
|
||||||
|
turn,
|
||||||
|
terminateReason,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
if (recoverySuccess) {
|
||||||
|
terminateReason = AgentTerminateMode.GOAL;
|
||||||
|
return turn;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.behavior.name !== 'main') {
|
||||||
|
yield {
|
||||||
|
type: GeminiEventType.Error,
|
||||||
|
value: {
|
||||||
|
error: {
|
||||||
|
message: this.behavior.getFinalFailureMessage(
|
||||||
|
terminateReason,
|
||||||
|
maxTurnsLimit,
|
||||||
|
maxTimeMinutes,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
deadlineTimer.abort();
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
debugLogger.debug(
|
||||||
|
`[AgentHarness] [${this.behavior.name}:${this.behavior.agentId}] Finished. Outcome: ${terminateReason}, Duration: ${duration}ms, Turns: ${this.turnCounter}`,
|
||||||
|
);
|
||||||
|
logAgentFinish(
|
||||||
|
this.config,
|
||||||
|
new AgentFinishEvent(
|
||||||
|
this.behavior.agentId,
|
||||||
|
this.behavior.name,
|
||||||
|
duration,
|
||||||
|
this.turnCounter,
|
||||||
|
terminateReason,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return turn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tryCompressChat(promptId: string) {
|
||||||
|
const model =
|
||||||
|
this.currentSequenceModel ?? resolveModel(this.config.getActiveModel());
|
||||||
|
const { info } = await this.compressionService.compress(
|
||||||
|
this.chat!,
|
||||||
|
promptId,
|
||||||
|
false,
|
||||||
|
model,
|
||||||
|
this.config,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async selectModel(
|
||||||
|
request: Part[],
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<string> {
|
||||||
|
if (this.currentSequenceModel) return this.currentSequenceModel;
|
||||||
|
const routingContext: RoutingContext = {
|
||||||
|
history: this.chat!.getHistory(true),
|
||||||
|
request,
|
||||||
|
signal,
|
||||||
|
requestedModel: this.config.getModel(),
|
||||||
|
};
|
||||||
|
const decision = await this.config
|
||||||
|
.getModelRouterService()
|
||||||
|
.route(routingContext);
|
||||||
|
return decision.model;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async executeTools(
|
||||||
|
calls: ToolCallRequestInfo[],
|
||||||
|
signal: AbortSignal,
|
||||||
|
onWaitingForConfirmation?: (waiting: boolean) => void,
|
||||||
|
): Promise<
|
||||||
|
Array<{ name: string; part: Part; result: ToolCallResponseInfo }>
|
||||||
|
> {
|
||||||
|
const taskCompleteCalls = calls.filter(
|
||||||
|
(c) => c.name === TASK_COMPLETE_TOOL_NAME,
|
||||||
|
);
|
||||||
|
const otherCalls = calls.filter((c) => c.name !== TASK_COMPLETE_TOOL_NAME);
|
||||||
|
|
||||||
|
debugLogger.debug(
|
||||||
|
`[AgentHarness] [${this.behavior.name}:${this.behavior.agentId}] Executing ${calls.length} tool calls (${otherCalls.length} scheduled)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let completedCalls: Array<{
|
||||||
|
request: ToolCallRequestInfo;
|
||||||
|
response: ToolCallResponseInfo;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
if (otherCalls.length > 0) {
|
||||||
|
const schedulerId =
|
||||||
|
this.behavior.name === 'main'
|
||||||
|
? ROOT_SCHEDULER_ID
|
||||||
|
: this.behavior.agentId;
|
||||||
|
|
||||||
|
completedCalls = await scheduleAgentTools(this.config, otherCalls, {
|
||||||
|
schedulerId,
|
||||||
|
toolRegistry: this.toolRegistry,
|
||||||
|
signal,
|
||||||
|
onWaitingForConfirmation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = completedCalls.map((call) => ({
|
||||||
|
name: call.request.name,
|
||||||
|
part: call.response.responseParts[0],
|
||||||
|
result: call.response,
|
||||||
|
}));
|
||||||
|
|
||||||
|
for (const call of taskCompleteCalls) {
|
||||||
|
const response: ToolCallResponseInfo = {
|
||||||
|
callId: call.callId,
|
||||||
|
responseParts: [
|
||||||
|
{
|
||||||
|
functionResponse: {
|
||||||
|
name: TASK_COMPLETE_TOOL_NAME,
|
||||||
|
response: { result: 'Task completed locally' },
|
||||||
|
id: call.callId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
resultDisplay: 'Task completed locally',
|
||||||
|
error: undefined,
|
||||||
|
errorType: undefined,
|
||||||
|
contentLength: 'Task completed locally'.length,
|
||||||
|
};
|
||||||
|
results.push({
|
||||||
|
name: TASK_COMPLETE_TOOL_NAME,
|
||||||
|
part: response.responseParts[0],
|
||||||
|
result: response,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -235,6 +235,9 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
|||||||
onWaitingForConfirmation?: (waiting: boolean) => void,
|
onWaitingForConfirmation?: (waiting: boolean) => void,
|
||||||
): Promise<AgentTurnResult> {
|
): Promise<AgentTurnResult> {
|
||||||
const promptId = `${this.agentId}#${turnCounter}`;
|
const promptId = `${this.agentId}#${turnCounter}`;
|
||||||
|
debugLogger.debug(
|
||||||
|
`[LegacySubagent] [${this.definition.name}:${this.agentId}] Starting turn ${turnCounter} (promptId: ${promptId})`,
|
||||||
|
);
|
||||||
|
|
||||||
await this.tryCompressChat(chat, promptId);
|
await this.tryCompressChat(chat, promptId);
|
||||||
|
|
||||||
@@ -242,6 +245,14 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
|||||||
this.callModel(chat, currentMessage, combinedSignal, promptId),
|
this.callModel(chat, currentMessage, combinedSignal, promptId),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (functionCalls.length > 0) {
|
||||||
|
debugLogger.debug(
|
||||||
|
`[LegacySubagent] [${this.definition.name}:${this.agentId}] Model made ${
|
||||||
|
functionCalls.length
|
||||||
|
} function calls: ${functionCalls.map((fc) => fc.name).join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (combinedSignal.aborted) {
|
if (combinedSignal.aborted) {
|
||||||
const terminateReason = timeoutSignal.aborted
|
const terminateReason = timeoutSignal.aborted
|
||||||
? AgentTerminateMode.TIMEOUT
|
? AgentTerminateMode.TIMEOUT
|
||||||
@@ -296,7 +307,8 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
|||||||
reason:
|
reason:
|
||||||
| AgentTerminateMode.TIMEOUT
|
| AgentTerminateMode.TIMEOUT
|
||||||
| AgentTerminateMode.MAX_TURNS
|
| AgentTerminateMode.MAX_TURNS
|
||||||
| AgentTerminateMode.ERROR_NO_COMPLETE_TASK_CALL,
|
| AgentTerminateMode.ERROR_NO_COMPLETE_TASK_CALL
|
||||||
|
| AgentTerminateMode.LOOP_DETECTED,
|
||||||
): string {
|
): string {
|
||||||
let explanation = '';
|
let explanation = '';
|
||||||
switch (reason) {
|
switch (reason) {
|
||||||
@@ -327,7 +339,8 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
|||||||
reason:
|
reason:
|
||||||
| AgentTerminateMode.TIMEOUT
|
| AgentTerminateMode.TIMEOUT
|
||||||
| AgentTerminateMode.MAX_TURNS
|
| AgentTerminateMode.MAX_TURNS
|
||||||
| AgentTerminateMode.ERROR_NO_COMPLETE_TASK_CALL,
|
| AgentTerminateMode.ERROR_NO_COMPLETE_TASK_CALL
|
||||||
|
| AgentTerminateMode.LOOP_DETECTED,
|
||||||
externalSignal: AbortSignal, // The original signal passed to run()
|
externalSignal: AbortSignal, // The original signal passed to run()
|
||||||
onWaitingForConfirmation?: (waiting: boolean) => void,
|
onWaitingForConfirmation?: (waiting: boolean) => void,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
@@ -441,6 +454,10 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
|||||||
// Combine the external signal with the internal timeout signal.
|
// Combine the external signal with the internal timeout signal.
|
||||||
const combinedSignal = AbortSignal.any([signal, deadlineTimer.signal]);
|
const combinedSignal = AbortSignal.any([signal, deadlineTimer.signal]);
|
||||||
|
|
||||||
|
debugLogger.debug(
|
||||||
|
`[LocalAgentExecutor] [${this.definition.name}:${this.agentId}] Starting agent run`,
|
||||||
|
);
|
||||||
|
|
||||||
logAgentStart(
|
logAgentStart(
|
||||||
this.runtimeContext,
|
this.runtimeContext,
|
||||||
new AgentStartEvent(this.agentId, this.definition.name),
|
new AgentStartEvent(this.agentId, this.definition.name),
|
||||||
@@ -612,12 +629,16 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
|||||||
throw error; // Re-throw other errors or external aborts.
|
throw error; // Re-throw other errors or external aborts.
|
||||||
} finally {
|
} finally {
|
||||||
deadlineTimer.abort();
|
deadlineTimer.abort();
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
debugLogger.debug(
|
||||||
|
`[LocalAgentExecutor] [${this.definition.name}:${this.agentId}] Finished. Outcome: ${terminateReason}, Duration: ${duration}ms, Turns: ${turnCounter}`,
|
||||||
|
);
|
||||||
logAgentFinish(
|
logAgentFinish(
|
||||||
this.runtimeContext,
|
this.runtimeContext,
|
||||||
new AgentFinishEvent(
|
new AgentFinishEvent(
|
||||||
this.agentId,
|
this.agentId,
|
||||||
this.definition.name,
|
this.definition.name,
|
||||||
Date.now() - startTime,
|
duration,
|
||||||
turnCounter,
|
turnCounter,
|
||||||
terminateReason,
|
terminateReason,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -126,6 +126,10 @@ ${output.result}
|
|||||||
return {
|
return {
|
||||||
llmContent: [{ text: resultContent }],
|
llmContent: [{ text: resultContent }],
|
||||||
returnDisplay: displayContent,
|
returnDisplay: displayContent,
|
||||||
|
data: {
|
||||||
|
result: output.result,
|
||||||
|
terminate_reason: output.terminate_reason,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import type { Config } from '../config/config.js';
|
import type { Config } from '../config/config.js';
|
||||||
import type { AgentDefinition, AgentInputs } from './types.js';
|
import type { AgentDefinition, AgentInputs } from './types.js';
|
||||||
import { LocalSubagentInvocation } from './local-invocation.js';
|
import { LocalSubagentInvocation } from './local-invocation.js';
|
||||||
|
import { HarnessSubagentInvocation } from './harness-invocation.js';
|
||||||
import { RemoteAgentInvocation } from './remote-invocation.js';
|
import { RemoteAgentInvocation } from './remote-invocation.js';
|
||||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||||
|
|
||||||
@@ -79,6 +80,17 @@ export class SubagentToolWrapper extends BaseDeclarativeTool<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.config.isAgentHarnessEnabled()) {
|
||||||
|
return new HarnessSubagentInvocation(
|
||||||
|
definition,
|
||||||
|
this.config,
|
||||||
|
params,
|
||||||
|
effectiveMessageBus,
|
||||||
|
_toolName,
|
||||||
|
_toolDisplayName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return new LocalSubagentInvocation(
|
return new LocalSubagentInvocation(
|
||||||
definition,
|
definition,
|
||||||
this.config,
|
this.config,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export enum AgentTerminateMode {
|
|||||||
MAX_TURNS = 'MAX_TURNS',
|
MAX_TURNS = 'MAX_TURNS',
|
||||||
ABORTED = 'ABORTED',
|
ABORTED = 'ABORTED',
|
||||||
ERROR_NO_COMPLETE_TASK_CALL = 'ERROR_NO_COMPLETE_TASK_CALL',
|
ERROR_NO_COMPLETE_TASK_CALL = 'ERROR_NO_COMPLETE_TASK_CALL',
|
||||||
|
LOOP_DETECTED = 'LOOP_DETECTED',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,7 +44,7 @@ export const DEFAULT_QUERY_STRING = 'Get Started!';
|
|||||||
/**
|
/**
|
||||||
* The default maximum number of conversational turns for an agent.
|
* The default maximum number of conversational turns for an agent.
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_MAX_TURNS = 15;
|
export const DEFAULT_MAX_TURNS = 40;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The default maximum execution time for an agent in minutes.
|
* The default maximum execution time for an agent in minutes.
|
||||||
|
|||||||
@@ -470,6 +470,7 @@ export interface ConfigParameters {
|
|||||||
disabledHooks?: string[];
|
disabledHooks?: string[];
|
||||||
projectHooks?: { [K in HookEventName]?: HookDefinition[] };
|
projectHooks?: { [K in HookEventName]?: HookDefinition[] };
|
||||||
enableAgents?: boolean;
|
enableAgents?: boolean;
|
||||||
|
enableAgentHarness?: boolean;
|
||||||
enableEventDrivenScheduler?: boolean;
|
enableEventDrivenScheduler?: boolean;
|
||||||
skillsSupport?: boolean;
|
skillsSupport?: boolean;
|
||||||
disabledSkills?: string[];
|
disabledSkills?: string[];
|
||||||
@@ -654,6 +655,7 @@ export class Config {
|
|||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
private readonly enableAgents: boolean;
|
private readonly enableAgents: boolean;
|
||||||
|
private readonly enableAgentHarness: boolean;
|
||||||
private agents: AgentSettings;
|
private agents: AgentSettings;
|
||||||
private readonly enableEventDrivenScheduler: boolean;
|
private readonly enableEventDrivenScheduler: boolean;
|
||||||
private readonly skillsSupport: boolean;
|
private readonly skillsSupport: boolean;
|
||||||
@@ -748,6 +750,7 @@ export class Config {
|
|||||||
this.disableLoopDetection = params.disableLoopDetection ?? false;
|
this.disableLoopDetection = params.disableLoopDetection ?? false;
|
||||||
this._activeModel = params.model;
|
this._activeModel = params.model;
|
||||||
this.enableAgents = params.enableAgents ?? false;
|
this.enableAgents = params.enableAgents ?? false;
|
||||||
|
this.enableAgentHarness = params.enableAgentHarness ?? false;
|
||||||
this.agents = params.agents ?? {};
|
this.agents = params.agents ?? {};
|
||||||
this.disableLLMCorrection = params.disableLLMCorrection ?? true;
|
this.disableLLMCorrection = params.disableLLMCorrection ?? true;
|
||||||
this.planEnabled = params.plan ?? false;
|
this.planEnabled = params.plan ?? false;
|
||||||
@@ -1969,6 +1972,10 @@ export class Config {
|
|||||||
return this.enableAgents;
|
return this.enableAgents;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isAgentHarnessEnabled(): boolean {
|
||||||
|
return this.enableAgentHarness;
|
||||||
|
}
|
||||||
|
|
||||||
isEventDrivenSchedulerEnabled(): boolean {
|
isEventDrivenSchedulerEnabled(): boolean {
|
||||||
return this.enableEventDrivenScheduler;
|
return this.enableEventDrivenScheduler;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,10 @@ export class MessageBus extends EventEmitter {
|
|||||||
|
|
||||||
async publish(message: Message): Promise<void> {
|
async publish(message: Message): Promise<void> {
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
debugLogger.debug(`[MESSAGE_BUS] publish: ${safeJsonStringify(message)}`);
|
const json = safeJsonStringify(message);
|
||||||
|
debugLogger.debug(
|
||||||
|
`[MESSAGE_BUS] publish: ${json.length > 500 ? json.substring(0, 500) + '...' : json}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (!this.isValidMessage(message)) {
|
if (!this.isValidMessage(message)) {
|
||||||
|
|||||||
@@ -19,10 +19,20 @@ export enum MessageBusType {
|
|||||||
TOOL_EXECUTION_FAILURE = 'tool-execution-failure',
|
TOOL_EXECUTION_FAILURE = 'tool-execution-failure',
|
||||||
UPDATE_POLICY = 'update-policy',
|
UPDATE_POLICY = 'update-policy',
|
||||||
TOOL_CALLS_UPDATE = 'tool-calls-update',
|
TOOL_CALLS_UPDATE = 'tool-calls-update',
|
||||||
|
SUBAGENT_ACTIVITY = 'subagent-activity',
|
||||||
ASK_USER_REQUEST = 'ask-user-request',
|
ASK_USER_REQUEST = 'ask-user-request',
|
||||||
ASK_USER_RESPONSE = 'ask-user-response',
|
ASK_USER_RESPONSE = 'ask-user-response',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SubagentActivityMessage {
|
||||||
|
type: MessageBusType.SUBAGENT_ACTIVITY;
|
||||||
|
activity: {
|
||||||
|
agentName: string;
|
||||||
|
type: 'THOUGHT' | 'TOOL_CALL_START';
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface ToolCallsUpdateMessage {
|
export interface ToolCallsUpdateMessage {
|
||||||
type: MessageBusType.TOOL_CALLS_UPDATE;
|
type: MessageBusType.TOOL_CALLS_UPDATE;
|
||||||
toolCalls: ToolCall[];
|
toolCalls: ToolCall[];
|
||||||
@@ -180,4 +190,5 @@ export type Message =
|
|||||||
| UpdatePolicy
|
| UpdatePolicy
|
||||||
| AskUserRequest
|
| AskUserRequest
|
||||||
| AskUserResponse
|
| AskUserResponse
|
||||||
| ToolCallsUpdateMessage;
|
| ToolCallsUpdateMessage
|
||||||
|
| SubagentActivityMessage;
|
||||||
|
|||||||
@@ -243,6 +243,7 @@ describe('Gemini Client (client.ts)', () => {
|
|||||||
getShowModelInfoInChat: vi.fn().mockReturnValue(false),
|
getShowModelInfoInChat: vi.fn().mockReturnValue(false),
|
||||||
getContinueOnFailedApiCall: vi.fn(),
|
getContinueOnFailedApiCall: vi.fn(),
|
||||||
getProjectRoot: vi.fn().mockReturnValue('/test/project/root'),
|
getProjectRoot: vi.fn().mockReturnValue('/test/project/root'),
|
||||||
|
isAgentHarnessEnabled: vi.fn().mockReturnValue(false),
|
||||||
storage: {
|
storage: {
|
||||||
getProjectTempDir: vi.fn().mockReturnValue('/test/temp'),
|
getProjectTempDir: vi.fn().mockReturnValue('/test/temp'),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import type {
|
import type {
|
||||||
GenerateContentConfig,
|
GenerateContentConfig,
|
||||||
PartListUnion,
|
PartListUnion,
|
||||||
|
Part,
|
||||||
Content,
|
Content,
|
||||||
Tool,
|
Tool,
|
||||||
GenerateContentResponse,
|
GenerateContentResponse,
|
||||||
@@ -62,8 +63,10 @@ import {
|
|||||||
} from '../availability/policyHelpers.js';
|
} from '../availability/policyHelpers.js';
|
||||||
import { resolveModel } from '../config/models.js';
|
import { resolveModel } from '../config/models.js';
|
||||||
import type { RetryAvailabilityContext } from '../utils/retry.js';
|
import type { RetryAvailabilityContext } from '../utils/retry.js';
|
||||||
import { partToString } from '../utils/partUtils.js';
|
import { partToString, toPartArray } from '../utils/partUtils.js';
|
||||||
import { coreEvents, CoreEvent } from '../utils/events.js';
|
import { coreEvents, CoreEvent } from '../utils/events.js';
|
||||||
|
import { AgentFactory } from '../agents/agent-factory.js';
|
||||||
|
import { type AgentHarness } from '../agents/harness.js';
|
||||||
|
|
||||||
const MAX_TURNS = 100;
|
const MAX_TURNS = 100;
|
||||||
|
|
||||||
@@ -90,6 +93,7 @@ export class GeminiClient {
|
|||||||
private currentSequenceModel: string | null = null;
|
private currentSequenceModel: string | null = null;
|
||||||
private lastSentIdeContext: IdeContext | undefined;
|
private lastSentIdeContext: IdeContext | undefined;
|
||||||
private forceFullIdeContext = true;
|
private forceFullIdeContext = true;
|
||||||
|
private harness?: AgentHarness;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* At any point in this conversation, was compression triggered without
|
* At any point in this conversation, was compression triggered without
|
||||||
@@ -556,6 +560,9 @@ export class GeminiClient {
|
|||||||
let turn = new Turn(this.getChat(), prompt_id);
|
let turn = new Turn(this.getChat(), prompt_id);
|
||||||
|
|
||||||
this.sessionTurnCount++;
|
this.sessionTurnCount++;
|
||||||
|
debugLogger.debug(
|
||||||
|
`[LegacyLoop] processTurn started. sessionTurnCount: ${this.sessionTurnCount}, prompt_id: ${prompt_id}`,
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
this.config.getMaxSessionTurns() > 0 &&
|
this.config.getMaxSessionTurns() > 0 &&
|
||||||
this.sessionTurnCount > this.config.getMaxSessionTurns()
|
this.sessionTurnCount > this.config.getMaxSessionTurns()
|
||||||
@@ -788,10 +795,55 @@ export class GeminiClient {
|
|||||||
isInvalidStreamRetry: boolean = false,
|
isInvalidStreamRetry: boolean = false,
|
||||||
displayContent?: PartListUnion,
|
displayContent?: PartListUnion,
|
||||||
): AsyncGenerator<ServerGeminiStreamEvent, Turn> {
|
): AsyncGenerator<ServerGeminiStreamEvent, Turn> {
|
||||||
|
debugLogger.debug(
|
||||||
|
`[LegacyLoop] sendMessageStream started. prompt_id: ${prompt_id}, turns left: ${turns}`,
|
||||||
|
);
|
||||||
if (!isInvalidStreamRetry) {
|
if (!isInvalidStreamRetry) {
|
||||||
this.config.resetTurn();
|
this.config.resetTurn();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.config.isAgentHarnessEnabled()) {
|
||||||
|
debugLogger.debug(
|
||||||
|
'[GeminiClient] Using AgentHarness for message execution.',
|
||||||
|
);
|
||||||
|
this.sessionTurnCount++;
|
||||||
|
if (
|
||||||
|
this.config.getMaxSessionTurns() > 0 &&
|
||||||
|
this.sessionTurnCount > this.config.getMaxSessionTurns()
|
||||||
|
) {
|
||||||
|
yield { type: GeminiEventType.MaxSessionTurns };
|
||||||
|
return new Turn(this.getChat(), prompt_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.harness || this.lastPromptId !== prompt_id) {
|
||||||
|
this.harness = AgentFactory.createHarness(this.config, undefined, {
|
||||||
|
parentPromptId: prompt_id,
|
||||||
|
history: this.getChat().getHistory(),
|
||||||
|
});
|
||||||
|
this.lastPromptId = prompt_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestParts: Part[] = toPartArray(request);
|
||||||
|
const stream = this.harness.run(requestParts, signal, turns);
|
||||||
|
|
||||||
|
let turn: Turn | undefined;
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await stream.next();
|
||||||
|
if (done) {
|
||||||
|
turn = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
yield value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (turn) {
|
||||||
|
// Sync history back to GeminiClient's chat for transcript persistence
|
||||||
|
this.getChat().setHistory(turn.chat.getHistory());
|
||||||
|
return turn;
|
||||||
|
}
|
||||||
|
return new Turn(this.getChat(), prompt_id);
|
||||||
|
}
|
||||||
|
|
||||||
const hooksEnabled = this.config.getEnableHooks();
|
const hooksEnabled = this.config.getEnableHooks();
|
||||||
const messageBus = this.config.getMessageBus();
|
const messageBus = this.config.getMessageBus();
|
||||||
|
|
||||||
|
|||||||
@@ -68,8 +68,23 @@ export enum GeminiEventType {
|
|||||||
ModelInfo = 'model_info',
|
ModelInfo = 'model_info',
|
||||||
AgentExecutionStopped = 'agent_execution_stopped',
|
AgentExecutionStopped = 'agent_execution_stopped',
|
||||||
AgentExecutionBlocked = 'agent_execution_blocked',
|
AgentExecutionBlocked = 'agent_execution_blocked',
|
||||||
|
SubagentActivity = 'subagent_activity',
|
||||||
|
TurnFinished = 'turn_finished',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ServerGeminiSubagentActivityEvent = {
|
||||||
|
type: GeminiEventType.SubagentActivity;
|
||||||
|
value: {
|
||||||
|
agentName: string;
|
||||||
|
type: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServerGeminiTurnFinishedEvent = {
|
||||||
|
type: GeminiEventType.TurnFinished;
|
||||||
|
};
|
||||||
|
|
||||||
export type ServerGeminiRetryEvent = {
|
export type ServerGeminiRetryEvent = {
|
||||||
type: GeminiEventType.Retry;
|
type: GeminiEventType.Retry;
|
||||||
};
|
};
|
||||||
@@ -229,7 +244,9 @@ export type ServerGeminiStreamEvent =
|
|||||||
| ServerGeminiInvalidStreamEvent
|
| ServerGeminiInvalidStreamEvent
|
||||||
| ServerGeminiModelInfoEvent
|
| ServerGeminiModelInfoEvent
|
||||||
| ServerGeminiAgentExecutionStoppedEvent
|
| ServerGeminiAgentExecutionStoppedEvent
|
||||||
| ServerGeminiAgentExecutionBlockedEvent;
|
| ServerGeminiAgentExecutionBlockedEvent
|
||||||
|
| ServerGeminiSubagentActivityEvent
|
||||||
|
| ServerGeminiTurnFinishedEvent;
|
||||||
|
|
||||||
// A turn manages the agentic loop turn within the server context.
|
// A turn manages the agentic loop turn within the server context.
|
||||||
export class Turn {
|
export class Turn {
|
||||||
@@ -239,9 +256,10 @@ export class Turn {
|
|||||||
private debugResponses: GenerateContentResponse[] = [];
|
private debugResponses: GenerateContentResponse[] = [];
|
||||||
private pendingCitations = new Set<string>();
|
private pendingCitations = new Set<string>();
|
||||||
finishReason: FinishReason | undefined = undefined;
|
finishReason: FinishReason | undefined = undefined;
|
||||||
|
submittedOutput: unknown;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly chat: GeminiChat,
|
readonly chat: GeminiChat,
|
||||||
private readonly prompt_id: string,
|
private readonly prompt_id: string,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ export * from './prompts/mcp-prompts.js';
|
|||||||
|
|
||||||
// Export agent definitions
|
// Export agent definitions
|
||||||
export * from './agents/types.js';
|
export * from './agents/types.js';
|
||||||
|
export * from './agents/agent-factory.js';
|
||||||
export * from './agents/agentLoader.js';
|
export * from './agents/agentLoader.js';
|
||||||
export * from './agents/local-executor.js';
|
export * from './agents/local-executor.js';
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import { awaitConfirmation, resolveConfirmation } from './confirmation.js';
|
import { awaitConfirmation, resolveConfirmation } from './confirmation.js';
|
||||||
|
import * as EditorUtils from '../utils/editor.js';
|
||||||
import {
|
import {
|
||||||
MessageBusType,
|
MessageBusType,
|
||||||
type ToolConfirmationResponse,
|
type ToolConfirmationResponse,
|
||||||
@@ -34,6 +35,8 @@ import type { Config } from '../config/config.js';
|
|||||||
import type { EditorType } from '../utils/editor.js';
|
import type { EditorType } from '../utils/editor.js';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
// Mock Dependencies
|
// Mock Dependencies
|
||||||
vi.mock('node:crypto', () => ({
|
vi.mock('node:crypto', () => ({
|
||||||
randomUUID: vi.fn(),
|
randomUUID: vi.fn(),
|
||||||
@@ -123,6 +126,7 @@ describe('confirmation.ts', () => {
|
|||||||
let toolMock: Mocked<AnyDeclarativeTool>;
|
let toolMock: Mocked<AnyDeclarativeTool>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.spyOn(EditorUtils, 'resolveEditorAsync').mockResolvedValue('vim');
|
||||||
signal = new AbortController().signal;
|
signal = new AbortController().signal;
|
||||||
|
|
||||||
mockState = {
|
mockState = {
|
||||||
|
|||||||
@@ -219,7 +219,11 @@ describe('Scheduler (Orchestrator)', () => {
|
|||||||
|
|
||||||
let capturedTerminalHandler: TerminalCallHandler | undefined;
|
let capturedTerminalHandler: TerminalCallHandler | undefined;
|
||||||
vi.mocked(SchedulerStateManager).mockImplementation(
|
vi.mocked(SchedulerStateManager).mockImplementation(
|
||||||
(_messageBus, _schedulerId, onTerminalCall) => {
|
(
|
||||||
|
_messageBus: MessageBus | undefined,
|
||||||
|
_schedulerId: string | undefined,
|
||||||
|
onTerminalCall: TerminalCallHandler | undefined,
|
||||||
|
) => {
|
||||||
capturedTerminalHandler = onTerminalCall;
|
capturedTerminalHandler = onTerminalCall;
|
||||||
return mockStateManager as unknown as SchedulerStateManager;
|
return mockStateManager as unknown as SchedulerStateManager;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ interface SchedulerQueueItem {
|
|||||||
|
|
||||||
export interface SchedulerOptions {
|
export interface SchedulerOptions {
|
||||||
config: Config;
|
config: Config;
|
||||||
messageBus: MessageBus;
|
messageBus?: MessageBus;
|
||||||
getPreferredEditor: () => EditorType | undefined;
|
getPreferredEditor: () => EditorType | undefined;
|
||||||
schedulerId: string;
|
schedulerId: string;
|
||||||
parentCallId?: string;
|
parentCallId?: string;
|
||||||
@@ -87,7 +87,7 @@ export class Scheduler {
|
|||||||
private readonly executor: ToolExecutor;
|
private readonly executor: ToolExecutor;
|
||||||
private readonly modifier: ToolModificationHandler;
|
private readonly modifier: ToolModificationHandler;
|
||||||
private readonly config: Config;
|
private readonly config: Config;
|
||||||
private readonly messageBus: MessageBus;
|
private readonly messageBus?: MessageBus;
|
||||||
private readonly getPreferredEditor: () => EditorType | undefined;
|
private readonly getPreferredEditor: () => EditorType | undefined;
|
||||||
private readonly schedulerId: string;
|
private readonly schedulerId: string;
|
||||||
private readonly parentCallId?: string;
|
private readonly parentCallId?: string;
|
||||||
@@ -112,11 +112,13 @@ export class Scheduler {
|
|||||||
this.executor = new ToolExecutor(this.config);
|
this.executor = new ToolExecutor(this.config);
|
||||||
this.modifier = new ToolModificationHandler();
|
this.modifier = new ToolModificationHandler();
|
||||||
|
|
||||||
this.setupMessageBusListener(this.messageBus);
|
if (this.messageBus) {
|
||||||
|
this.setupMessageBusListener(this.messageBus);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupMessageBusListener(messageBus: MessageBus): void {
|
private setupMessageBusListener(messageBus: MessageBus): void {
|
||||||
if (Scheduler.subscribedMessageBuses.has(messageBus)) {
|
if (!messageBus || Scheduler.subscribedMessageBuses.has(messageBus)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,7 +434,7 @@ export class Scheduler {
|
|||||||
let outcome = ToolConfirmationOutcome.ProceedOnce;
|
let outcome = ToolConfirmationOutcome.ProceedOnce;
|
||||||
let lastDetails: SerializableConfirmationDetails | undefined;
|
let lastDetails: SerializableConfirmationDetails | undefined;
|
||||||
|
|
||||||
if (decision === PolicyDecision.ASK_USER) {
|
if (decision === PolicyDecision.ASK_USER && this.messageBus) {
|
||||||
const result = await resolveConfirmation(toolCall, signal, {
|
const result = await resolveConfirmation(toolCall, signal, {
|
||||||
config: this.config,
|
config: this.config,
|
||||||
messageBus: this.messageBus,
|
messageBus: this.messageBus,
|
||||||
@@ -449,10 +451,12 @@ export class Scheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle Policy Updates
|
// Handle Policy Updates
|
||||||
await updatePolicy(toolCall.tool, outcome, lastDetails, {
|
if (this.messageBus) {
|
||||||
config: this.config,
|
await updatePolicy(toolCall.tool, outcome, lastDetails, {
|
||||||
messageBus: this.messageBus,
|
config: this.config,
|
||||||
});
|
messageBus: this.messageBus,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Handle cancellation (cascades to entire batch)
|
// Handle cancellation (cascades to entire batch)
|
||||||
if (outcome === ToolConfirmationOutcome.Cancel) {
|
if (outcome === ToolConfirmationOutcome.Cancel) {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export class SchedulerStateManager {
|
|||||||
private _completedBatch: CompletedToolCall[] = [];
|
private _completedBatch: CompletedToolCall[] = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly messageBus: MessageBus,
|
private readonly messageBus: MessageBus | undefined,
|
||||||
private readonly schedulerId: string = ROOT_SCHEDULER_ID,
|
private readonly schedulerId: string = ROOT_SCHEDULER_ID,
|
||||||
private readonly onTerminalCall?: TerminalCallHandler,
|
private readonly onTerminalCall?: TerminalCallHandler,
|
||||||
) {}
|
) {}
|
||||||
@@ -210,6 +210,10 @@ export class SchedulerStateManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private emitUpdate() {
|
private emitUpdate() {
|
||||||
|
if (!this.messageBus) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const snapshot = this.getSnapshot();
|
const snapshot = this.getSnapshot();
|
||||||
|
|
||||||
// Fire and forget - The message bus handles the publish and error handling.
|
// Fire and forget - The message bus handles the publish and error handling.
|
||||||
|
|||||||
@@ -168,3 +168,17 @@ export function appendToLastTextPart(
|
|||||||
|
|
||||||
return newPrompt;
|
return newPrompt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a PartListUnion into an array of Parts.
|
||||||
|
*/
|
||||||
|
export function toPartArray(value: PartListUnion): Part[] {
|
||||||
|
if (!value) return [];
|
||||||
|
const items = Array.isArray(value) ? value : [value];
|
||||||
|
return items.map((item) => {
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
return { text: item };
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1486,6 +1486,13 @@
|
|||||||
"default": false,
|
"default": false,
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"enableAgentHarness": {
|
||||||
|
"title": "Enable Agent Harness",
|
||||||
|
"description": "Enable the new unified agent harness (experimental).",
|
||||||
|
"markdownDescription": "Enable the new unified agent harness (experimental).\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `false`",
|
||||||
|
"default": false,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"extensionManagement": {
|
"extensionManagement": {
|
||||||
"title": "Extension Management",
|
"title": "Extension Management",
|
||||||
"description": "Enable extension management features.",
|
"description": "Enable extension management features.",
|
||||||
|
|||||||
Reference in New Issue
Block a user