mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 00:01:24 -07:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38c706dbb9 | |||
| 5e18b14c10 | |||
| 8e2629759d | |||
| 146442f2a2 | |||
| a3a3e66922 | |||
| d4555a473e | |||
| cdac58fe82 | |||
| 4a95ab62d4 | |||
| 0223181cf4 |
@@ -1540,7 +1540,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
- **`experimental.enableAgents`** (boolean):
|
||||
- **Description:** Enable local and remote subagents.
|
||||
- **Default:** `false`
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.worktrees`** (boolean):
|
||||
|
||||
@@ -63,9 +63,6 @@ describe.skipIf(!chromeAvailable)('browser-policy', () => {
|
||||
rig.setup('browser-policy-skip-confirmation', {
|
||||
fakeResponsesPath: join(__dirname, 'browser-policy.responses'),
|
||||
settings: {
|
||||
experimental: {
|
||||
enableAgents: true,
|
||||
},
|
||||
agents: {
|
||||
overrides: {
|
||||
browser_agent: {
|
||||
@@ -183,9 +180,6 @@ priority = 200
|
||||
rig.setup('browser-session-warning', {
|
||||
fakeResponsesPath: join(__dirname, 'browser-agent.cleanup.responses'),
|
||||
settings: {
|
||||
experimental: {
|
||||
enableAgents: true,
|
||||
},
|
||||
general: {
|
||||
enableAutoUpdateNotification: false,
|
||||
},
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as os from 'node:os';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import { TestRig, skipFlaky } from './test-helper.js';
|
||||
|
||||
describe('Ctrl+C exit', () => {
|
||||
describe.skipIf(skipFlaky)('Ctrl+C exit', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -4,10 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig, checkModelOutputContent, GEMINI_DIR } from './test-helper.js';
|
||||
import { TestRig, checkModelOutputContent } from './test-helper.js';
|
||||
|
||||
describe('Plan Mode', () => {
|
||||
let rig: TestRig;
|
||||
@@ -36,27 +34,23 @@ describe('Plan Mode', () => {
|
||||
},
|
||||
);
|
||||
|
||||
// We use a prompt that asks for both a read-only action and a write action.
|
||||
// "List files" (read-only) followed by "touch denied.txt" (write).
|
||||
const result = await rig.run({
|
||||
approvalMode: 'plan',
|
||||
stdin:
|
||||
'Please list the files in the current directory, and then attempt to create a new file named "denied.txt" using a shell command.',
|
||||
args: 'Please list the files in the current directory, and then attempt to create a new file named "denied.txt" using a shell command.',
|
||||
});
|
||||
|
||||
const lsCallFound = await rig.waitForToolCall('list_directory');
|
||||
expect(lsCallFound, 'Expected list_directory to be called').toBe(true);
|
||||
|
||||
const shellCallFound = await rig.waitForToolCall('run_shell_command');
|
||||
expect(shellCallFound, 'Expected run_shell_command to fail').toBe(false);
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const lsLog = toolLogs.find((l) => l.toolRequest.name === 'list_directory');
|
||||
expect(
|
||||
toolLogs.find((l) => l.toolRequest.name === 'run_shell_command'),
|
||||
).toBeUndefined();
|
||||
const shellLog = toolLogs.find(
|
||||
(l) => l.toolRequest.name === 'run_shell_command',
|
||||
);
|
||||
|
||||
expect(lsLog, 'Expected list_directory to be called').toBeDefined();
|
||||
expect(lsLog?.toolRequest.success).toBe(true);
|
||||
expect(
|
||||
shellLog,
|
||||
'Expected run_shell_command to be blocked (not even called)',
|
||||
).toBeUndefined();
|
||||
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: ['Plan Mode', 'read-only'],
|
||||
@@ -84,23 +78,11 @@ describe('Plan Mode', () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Disable the interactive terminal setup prompt in tests
|
||||
writeFileSync(
|
||||
join(rig.homeDir!, GEMINI_DIR, 'state.json'),
|
||||
JSON.stringify({ terminalSetupPromptShown: true }, null, 2),
|
||||
);
|
||||
|
||||
const run = await rig.runInteractive({
|
||||
await rig.run({
|
||||
approvalMode: 'plan',
|
||||
args: 'Create a file called plan.md in the plans directory.',
|
||||
});
|
||||
|
||||
await run.type('Create a file called plan.md in the plans directory.');
|
||||
await run.type('\r');
|
||||
|
||||
await rig.expectToolCallSuccess(['write_file'], 30000, (args) =>
|
||||
args.includes('plan.md'),
|
||||
);
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const planWrite = toolLogs.find(
|
||||
(l) =>
|
||||
@@ -108,7 +90,25 @@ describe('Plan Mode', () => {
|
||||
l.toolRequest.args.includes('plans') &&
|
||||
l.toolRequest.args.includes('plan.md'),
|
||||
);
|
||||
expect(planWrite?.toolRequest.success).toBe(true);
|
||||
|
||||
if (!planWrite) {
|
||||
console.error(
|
||||
'All tool calls found:',
|
||||
toolLogs.map((l) => ({
|
||||
name: l.toolRequest.name,
|
||||
args: l.toolRequest.args,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
expect(
|
||||
planWrite,
|
||||
'Expected write_file to be called for plan.md',
|
||||
).toBeDefined();
|
||||
expect(
|
||||
planWrite?.toolRequest.success,
|
||||
`Expected write_file to succeed, but it failed with error: ${planWrite?.toolRequest.error}`,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should deny write_file to non-plans directory in plan mode', async () => {
|
||||
@@ -131,19 +131,11 @@ describe('Plan Mode', () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Disable the interactive terminal setup prompt in tests
|
||||
writeFileSync(
|
||||
join(rig.homeDir!, GEMINI_DIR, 'state.json'),
|
||||
JSON.stringify({ terminalSetupPromptShown: true }, null, 2),
|
||||
);
|
||||
|
||||
const run = await rig.runInteractive({
|
||||
await rig.run({
|
||||
approvalMode: 'plan',
|
||||
args: 'Create a file called hello.txt in the current directory.',
|
||||
});
|
||||
|
||||
await run.type('Create a file called hello.txt in the current directory.');
|
||||
await run.type('\r');
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const writeLog = toolLogs.find(
|
||||
(l) =>
|
||||
@@ -151,10 +143,11 @@ describe('Plan Mode', () => {
|
||||
l.toolRequest.args.includes('hello.txt'),
|
||||
);
|
||||
|
||||
// In Plan Mode, writes outside the plans directory should be blocked.
|
||||
// Model is undeterministic, sometimes it doesn't even try, but if it does, it must fail.
|
||||
if (writeLog) {
|
||||
expect(writeLog.toolRequest.success).toBe(false);
|
||||
expect(
|
||||
writeLog.toolRequest.success,
|
||||
'Expected write_file to non-plans dir to fail',
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -169,28 +162,69 @@ describe('Plan Mode', () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Disable the interactive terminal setup prompt in tests
|
||||
writeFileSync(
|
||||
join(rig.homeDir!, GEMINI_DIR, 'state.json'),
|
||||
JSON.stringify({ terminalSetupPromptShown: true }, null, 2),
|
||||
);
|
||||
|
||||
// Start in default mode and ask to enter plan mode.
|
||||
await rig.run({
|
||||
approvalMode: 'default',
|
||||
stdin:
|
||||
'I want to perform a complex refactoring. Please enter plan mode so we can design it first.',
|
||||
args: 'I want to perform a complex refactoring. Please enter plan mode so we can design it first.',
|
||||
});
|
||||
|
||||
const enterPlanCallFound = await rig.waitForToolCall('enter_plan_mode');
|
||||
expect(enterPlanCallFound, 'Expected enter_plan_mode to be called').toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const enterLog = toolLogs.find(
|
||||
(l) => l.toolRequest.name === 'enter_plan_mode',
|
||||
);
|
||||
expect(enterLog, 'Expected enter_plan_mode to be called').toBeDefined();
|
||||
expect(enterLog?.toolRequest.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow write_file to the plans directory in plan mode even without a session ID', async () => {
|
||||
const plansDir = '.gemini/tmp/foo/plans';
|
||||
const testName =
|
||||
'should allow write_file to the plans directory in plan mode even without a session ID';
|
||||
|
||||
await rig.setup(testName, {
|
||||
settings: {
|
||||
experimental: { plan: true },
|
||||
tools: {
|
||||
core: ['write_file', 'read_file', 'list_directory'],
|
||||
},
|
||||
general: {
|
||||
defaultApprovalMode: 'plan',
|
||||
plan: {
|
||||
directory: plansDir,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await rig.run({
|
||||
approvalMode: 'plan',
|
||||
args: 'Create a file called plan-no-session.md in the plans directory.',
|
||||
});
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const planWrite = toolLogs.find(
|
||||
(l) =>
|
||||
l.toolRequest.name === 'write_file' &&
|
||||
l.toolRequest.args.includes('plans') &&
|
||||
l.toolRequest.args.includes('plan-no-session.md'),
|
||||
);
|
||||
|
||||
if (!planWrite) {
|
||||
console.error(
|
||||
'All tool calls found:',
|
||||
toolLogs.map((l) => ({
|
||||
name: l.toolRequest.name,
|
||||
args: l.toolRequest.args,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
expect(
|
||||
planWrite,
|
||||
'Expected write_file to be called for plan-no-session.md',
|
||||
).toBeDefined();
|
||||
expect(
|
||||
planWrite?.toolRequest.success,
|
||||
`Expected write_file to succeed, but it failed with error: ${planWrite?.toolRequest.error}`,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+9
-9
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.36.0-nightly.20260317.2f90b4653",
|
||||
"version": "0.36.0-preview.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.36.0-nightly.20260317.2f90b4653",
|
||||
"version": "0.36.0-preview.3",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -17413,7 +17413,7 @@
|
||||
},
|
||||
"packages/a2a-server": {
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.36.0-nightly.20260317.2f90b4653",
|
||||
"version": "0.36.0-preview.3",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
"@google-cloud/storage": "^7.16.0",
|
||||
@@ -17528,7 +17528,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.36.0-nightly.20260317.2f90b4653",
|
||||
"version": "0.36.0-preview.3",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.16.1",
|
||||
@@ -17700,7 +17700,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.36.0-nightly.20260317.2f90b4653",
|
||||
"version": "0.36.0-preview.3",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
@@ -17966,7 +17966,7 @@
|
||||
},
|
||||
"packages/devtools": {
|
||||
"name": "@google/gemini-cli-devtools",
|
||||
"version": "0.36.0-nightly.20260317.2f90b4653",
|
||||
"version": "0.36.0-preview.3",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"ws": "^8.16.0"
|
||||
@@ -17981,7 +17981,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@google/gemini-cli-sdk",
|
||||
"version": "0.36.0-nightly.20260317.2f90b4653",
|
||||
"version": "0.36.0-preview.3",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -17998,7 +17998,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.36.0-nightly.20260317.2f90b4653",
|
||||
"version": "0.36.0-preview.3",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -18015,7 +18015,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"version": "0.36.0-nightly.20260317.2f90b4653",
|
||||
"version": "0.36.0-preview.3",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.36.0-nightly.20260317.2f90b4653",
|
||||
"version": "0.36.0-preview.3",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -14,7 +14,7 @@
|
||||
"url": "git+https://github.com/google-gemini/gemini-cli.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.36.0-nightly.20260317.2f90b4653"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.36.0-preview.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.36.0-nightly.20260317.2f90b4653",
|
||||
"version": "0.36.0-preview.3",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -29,6 +29,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
PRIORITY_YOLO_ALLOW_ALL: 998,
|
||||
Config: vi.fn().mockImplementation((params) => {
|
||||
const mockConfig = {
|
||||
...params,
|
||||
@@ -341,11 +342,11 @@ describe('loadConfig', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should default enableAgents to false when not provided', async () => {
|
||||
it('should default enableAgents to true when not provided', async () => {
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
expect(Config).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enableAgents: false,
|
||||
enableAgents: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -128,7 +128,7 @@ export async function loadConfig(
|
||||
interactive: !isHeadlessMode(),
|
||||
enableInteractiveShell: !isHeadlessMode(),
|
||||
ptyInfo: 'auto',
|
||||
enableAgents: settings.experimental?.enableAgents ?? false,
|
||||
enableAgents: settings.experimental?.enableAgents ?? true,
|
||||
};
|
||||
|
||||
const fileService = new FileDiscoveryService(workspaceDir, {
|
||||
|
||||
+5
-21
@@ -6,19 +6,12 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// --- Fast Path for Version ---
|
||||
// We check for version flags at the very top to avoid loading any heavy dependencies.
|
||||
// process.env.CLI_VERSION is defined during the build process by esbuild.
|
||||
if (process.argv.includes('--version') || process.argv.includes('-v')) {
|
||||
console.log(process.env['CLI_VERSION'] || 'unknown');
|
||||
process.exit(0);
|
||||
}
|
||||
import { main } from './src/gemini.js';
|
||||
import { FatalError, writeToStderr } from '@google/gemini-cli-core';
|
||||
import { runExitCleanup } from './src/utils/cleanup.js';
|
||||
|
||||
// --- Global Entry Point ---
|
||||
|
||||
let writeToStderrFn: (message: string) => void = (msg) =>
|
||||
process.stderr.write(msg);
|
||||
|
||||
// Suppress known race condition error in node-pty on Windows
|
||||
// Tracking bug: https://github.com/microsoft/node-pty/issues/827
|
||||
process.on('uncaughtException', (error) => {
|
||||
@@ -35,22 +28,13 @@ process.on('uncaughtException', (error) => {
|
||||
// For other errors, we rely on the default behavior, but since we attached a listener,
|
||||
// we must manually replicate it.
|
||||
if (error instanceof Error) {
|
||||
writeToStderrFn(error.stack + '\n');
|
||||
writeToStderr(error.stack + '\n');
|
||||
} else {
|
||||
writeToStderrFn(String(error) + '\n');
|
||||
writeToStderr(String(error) + '\n');
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
const [{ main }, { FatalError, writeToStderr }, { runExitCleanup }] =
|
||||
await Promise.all([
|
||||
import('./src/gemini.js'),
|
||||
import('@google/gemini-cli-core'),
|
||||
import('./src/utils/cleanup.js'),
|
||||
]);
|
||||
|
||||
writeToStderrFn = writeToStderr;
|
||||
|
||||
main().catch(async (error) => {
|
||||
// Set a timeout to force exit if cleanup hangs
|
||||
const cleanupTimeout = setTimeout(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.36.0-nightly.20260317.2f90b4653",
|
||||
"version": "0.36.0-preview.3",
|
||||
"description": "Gemini CLI",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -27,7 +27,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.36.0-nightly.20260317.2f90b4653"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.36.0-preview.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.16.1",
|
||||
|
||||
@@ -400,7 +400,7 @@ describe('SettingsSchema', () => {
|
||||
expect(setting).toBeDefined();
|
||||
expect(setting.type).toBe('boolean');
|
||||
expect(setting.category).toBe('Experimental');
|
||||
expect(setting.default).toBe(false);
|
||||
expect(setting.default).toBe(true);
|
||||
expect(setting.requiresRestart).toBe(true);
|
||||
expect(setting.showInDialog).toBe(false);
|
||||
expect(setting.description).toBe('Enable local and remote subagents.');
|
||||
|
||||
@@ -1932,7 +1932,7 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Enable Agents',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
default: true,
|
||||
description: 'Enable local and remote subagents.',
|
||||
showInDialog: false,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.36.0-nightly.20260317.2f90b4653",
|
||||
"version": "0.36.0-preview.3",
|
||||
"description": "Gemini CLI Core",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -128,7 +128,10 @@ describe('A2AClientManager', () => {
|
||||
|
||||
describe('getInstance / dispatcher initialization', () => {
|
||||
it('should use UndiciAgent when no proxy is configured', async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
await manager.loadAgent('TestAgent', {
|
||||
type: 'url',
|
||||
url: 'http://test.agent/card',
|
||||
});
|
||||
|
||||
const resolverOptions = vi.mocked(DefaultAgentCardResolver).mock
|
||||
.calls[0][0];
|
||||
@@ -153,7 +156,10 @@ describe('A2AClientManager', () => {
|
||||
} as Config;
|
||||
|
||||
manager = new A2AClientManager(mockConfigWithProxy);
|
||||
await manager.loadAgent('TestProxyAgent', 'http://test.proxy.agent/card');
|
||||
await manager.loadAgent('TestProxyAgent', {
|
||||
type: 'url',
|
||||
url: 'http://test.proxy.agent/card',
|
||||
});
|
||||
|
||||
const resolverOptions = vi.mocked(DefaultAgentCardResolver).mock
|
||||
.calls[0][0];
|
||||
@@ -172,28 +178,40 @@ describe('A2AClientManager', () => {
|
||||
|
||||
describe('loadAgent', () => {
|
||||
it('should create and cache an A2AClient', async () => {
|
||||
const agentCard = await manager.loadAgent(
|
||||
'TestAgent',
|
||||
'http://test.agent/card',
|
||||
);
|
||||
const agentCard = await manager.loadAgent('TestAgent', {
|
||||
type: 'url',
|
||||
url: 'http://test.agent/card',
|
||||
});
|
||||
expect(manager.getAgentCard('TestAgent')).toBe(agentCard);
|
||||
expect(manager.getClient('TestAgent')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should configure ClientFactory with REST, JSON-RPC, and gRPC transports', async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
await manager.loadAgent('TestAgent', {
|
||||
type: 'url',
|
||||
url: 'http://test.agent/card',
|
||||
});
|
||||
expect(ClientFactoryOptions.createFrom).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if an agent with the same name is already loaded', async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
await manager.loadAgent('TestAgent', {
|
||||
type: 'url',
|
||||
url: 'http://test.agent/card',
|
||||
});
|
||||
await expect(
|
||||
manager.loadAgent('TestAgent', 'http://test.agent/card'),
|
||||
manager.loadAgent('TestAgent', {
|
||||
type: 'url',
|
||||
url: 'http://test.agent/card',
|
||||
}),
|
||||
).rejects.toThrow("Agent with name 'TestAgent' is already loaded.");
|
||||
});
|
||||
|
||||
it('should use native fetch by default', async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
await manager.loadAgent('TestAgent', {
|
||||
type: 'url',
|
||||
url: 'http://test.agent/card',
|
||||
});
|
||||
expect(createAuthenticatingFetchWithRetry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -204,7 +222,7 @@ describe('A2AClientManager', () => {
|
||||
};
|
||||
await manager.loadAgent(
|
||||
'TestAgent',
|
||||
'http://test.agent/card',
|
||||
{ type: 'url', url: 'http://test.agent/card' },
|
||||
customAuthHandler as unknown as AuthenticationHandler,
|
||||
);
|
||||
|
||||
@@ -221,7 +239,7 @@ describe('A2AClientManager', () => {
|
||||
};
|
||||
await manager.loadAgent(
|
||||
'AuthCardAgent',
|
||||
'http://authcard.agent/card',
|
||||
{ type: 'url', url: 'http://authcard.agent/card' },
|
||||
customAuthHandler as unknown as AuthenticationHandler,
|
||||
);
|
||||
|
||||
@@ -252,7 +270,7 @@ describe('A2AClientManager', () => {
|
||||
|
||||
await manager.loadAgent(
|
||||
'AuthCardAgent401',
|
||||
'http://authcard.agent/card',
|
||||
{ type: 'url', url: 'http://authcard.agent/card' },
|
||||
customAuthHandler as unknown as AuthenticationHandler,
|
||||
);
|
||||
|
||||
@@ -267,19 +285,65 @@ describe('A2AClientManager', () => {
|
||||
});
|
||||
|
||||
it('should log a debug message upon loading an agent', async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
await manager.loadAgent('TestAgent', {
|
||||
type: 'url',
|
||||
url: 'http://test.agent/card',
|
||||
});
|
||||
expect(debugLogger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Loaded agent 'TestAgent'"),
|
||||
);
|
||||
});
|
||||
|
||||
it('should clear the cache', async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
await manager.loadAgent('TestAgent', {
|
||||
type: 'url',
|
||||
url: 'http://test.agent/card',
|
||||
});
|
||||
manager.clearCache();
|
||||
expect(manager.getAgentCard('TestAgent')).toBeUndefined();
|
||||
expect(manager.getClient('TestAgent')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should load an agent from inline JSON without calling resolver', async () => {
|
||||
const inlineJson = JSON.stringify(mockAgentCard);
|
||||
const agentCard = await manager.loadAgent('JsonAgent', {
|
||||
type: 'json',
|
||||
json: inlineJson,
|
||||
});
|
||||
expect(agentCard).toBeDefined();
|
||||
expect(agentCard.name).toBe('test-agent');
|
||||
expect(manager.getAgentCard('JsonAgent')).toBe(agentCard);
|
||||
expect(manager.getClient('JsonAgent')).toBeDefined();
|
||||
// Resolver should not have been called for inline JSON
|
||||
const resolverInstance = vi.mocked(DefaultAgentCardResolver).mock
|
||||
.results[0]?.value;
|
||||
if (resolverInstance) {
|
||||
expect(resolverInstance.resolve).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw a descriptive error for invalid inline JSON', async () => {
|
||||
await expect(
|
||||
manager.loadAgent('BadJsonAgent', {
|
||||
type: 'json',
|
||||
json: 'not valid json {{',
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
/Failed to parse inline agent card JSON for agent 'BadJsonAgent'/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should log "inline JSON" for JSON-loaded agents', async () => {
|
||||
const inlineJson = JSON.stringify(mockAgentCard);
|
||||
await manager.loadAgent('JsonLogAgent', {
|
||||
type: 'json',
|
||||
json: inlineJson,
|
||||
});
|
||||
expect(debugLogger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('inline JSON'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw if resolveAgentCard fails', async () => {
|
||||
const resolverInstance = {
|
||||
resolve: vi.fn().mockRejectedValue(new Error('Resolution failed')),
|
||||
@@ -289,7 +353,10 @@ describe('A2AClientManager', () => {
|
||||
);
|
||||
|
||||
await expect(
|
||||
manager.loadAgent('FailAgent', 'http://fail.agent'),
|
||||
manager.loadAgent('FailAgent', {
|
||||
type: 'url',
|
||||
url: 'http://fail.agent',
|
||||
}),
|
||||
).rejects.toThrow('Resolution failed');
|
||||
});
|
||||
|
||||
@@ -304,7 +371,10 @@ describe('A2AClientManager', () => {
|
||||
);
|
||||
|
||||
await expect(
|
||||
manager.loadAgent('FailAgent', 'http://fail.agent'),
|
||||
manager.loadAgent('FailAgent', {
|
||||
type: 'url',
|
||||
url: 'http://fail.agent',
|
||||
}),
|
||||
).rejects.toThrow('Factory failed');
|
||||
});
|
||||
});
|
||||
@@ -318,7 +388,10 @@ describe('A2AClientManager', () => {
|
||||
|
||||
describe('sendMessageStream', () => {
|
||||
beforeEach(async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
await manager.loadAgent('TestAgent', {
|
||||
type: 'url',
|
||||
url: 'http://test.agent/card',
|
||||
});
|
||||
});
|
||||
|
||||
it('should send a message and return a stream', async () => {
|
||||
@@ -433,7 +506,10 @@ describe('A2AClientManager', () => {
|
||||
|
||||
describe('getTask', () => {
|
||||
beforeEach(async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
await manager.loadAgent('TestAgent', {
|
||||
type: 'url',
|
||||
url: 'http://test.agent/card',
|
||||
});
|
||||
});
|
||||
|
||||
it('should get a task from the correct agent', async () => {
|
||||
@@ -462,7 +538,10 @@ describe('A2AClientManager', () => {
|
||||
|
||||
describe('cancelTask', () => {
|
||||
beforeEach(async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
await manager.loadAgent('TestAgent', {
|
||||
type: 'url',
|
||||
url: 'http://test.agent/card',
|
||||
});
|
||||
});
|
||||
|
||||
it('should cancel a task on the correct agent', async () => {
|
||||
|
||||
@@ -26,6 +26,7 @@ import * as grpc from '@grpc/grpc-js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Agent as UndiciAgent, ProxyAgent } from 'undici';
|
||||
import { normalizeAgentCard } from './a2aUtils.js';
|
||||
import type { AgentCardLoadOptions } from './types.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { classifyAgentError } from './a2a-errors.js';
|
||||
@@ -85,7 +86,7 @@ export class A2AClientManager {
|
||||
*/
|
||||
async loadAgent(
|
||||
name: string,
|
||||
agentCardUrl: string,
|
||||
options: AgentCardLoadOptions,
|
||||
authHandler?: AuthenticationHandler,
|
||||
): Promise<AgentCard> {
|
||||
if (this.clients.has(name) && this.agentCards.has(name)) {
|
||||
@@ -119,7 +120,24 @@ export class A2AClientManager {
|
||||
};
|
||||
|
||||
const resolver = new DefaultAgentCardResolver({ fetchImpl: cardFetch });
|
||||
const rawCard = await resolver.resolve(agentCardUrl, '');
|
||||
|
||||
let rawCard: unknown;
|
||||
let urlIdentifier = 'inline JSON';
|
||||
|
||||
if (options.type === 'json') {
|
||||
try {
|
||||
rawCard = JSON.parse(options.json);
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`Failed to parse inline agent card JSON for agent '${name}': ${msg}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
urlIdentifier = options.url;
|
||||
rawCard = await resolver.resolve(options.url, '');
|
||||
}
|
||||
|
||||
// TODO: Remove normalizeAgentCard once @a2a-js/sdk handles
|
||||
// proto field name aliases (supportedInterfaces → additionalInterfaces,
|
||||
// protocolBinding → transport).
|
||||
@@ -153,12 +171,12 @@ export class A2AClientManager {
|
||||
this.agentCards.set(name, agentCard);
|
||||
|
||||
debugLogger.debug(
|
||||
`[A2AClientManager] Loaded agent '${name}' from ${agentCardUrl}`,
|
||||
`[A2AClientManager] Loaded agent '${name}' from ${urlIdentifier}`,
|
||||
);
|
||||
|
||||
return agentCard;
|
||||
} catch (error: unknown) {
|
||||
throw classifyAgentError(name, agentCardUrl, error);
|
||||
throw classifyAgentError(name, urlIdentifier, error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
DEFAULT_MAX_TIME_MINUTES,
|
||||
DEFAULT_MAX_TURNS,
|
||||
type LocalAgentDefinition,
|
||||
type RemoteAgentDefinition,
|
||||
getAgentCardLoadOptions,
|
||||
getRemoteAgentTargetUrl,
|
||||
} from './types.js';
|
||||
|
||||
describe('loader', () => {
|
||||
@@ -232,6 +235,75 @@ agent_card_url: https://example.com/card
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse a remote agent with agent_card_json', async () => {
|
||||
const cardJson = JSON.stringify({
|
||||
name: 'json-agent',
|
||||
url: 'https://example.com/agent',
|
||||
version: '1.0',
|
||||
});
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
kind: remote
|
||||
name: json-remote
|
||||
description: A JSON-based remote agent
|
||||
agent_card_json: '${cardJson}'
|
||||
---
|
||||
`);
|
||||
const result = await parseAgentMarkdown(filePath);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
kind: 'remote',
|
||||
name: 'json-remote',
|
||||
description: 'A JSON-based remote agent',
|
||||
agent_card_json: cardJson,
|
||||
});
|
||||
// Should NOT have agent_card_url
|
||||
expect(result[0]).not.toHaveProperty('agent_card_url');
|
||||
});
|
||||
|
||||
it('should reject agent_card_json that is not valid JSON', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
kind: remote
|
||||
name: invalid-json-remote
|
||||
agent_card_json: "not valid json {{"
|
||||
---
|
||||
`);
|
||||
await expect(parseAgentMarkdown(filePath)).rejects.toThrow(
|
||||
/agent_card_json must be valid JSON/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject a remote agent with both agent_card_url and agent_card_json', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
kind: remote
|
||||
name: both-fields
|
||||
agent_card_url: https://example.com/card
|
||||
agent_card_json: '{"name":"test"}'
|
||||
---
|
||||
`);
|
||||
await expect(parseAgentMarkdown(filePath)).rejects.toThrow(
|
||||
/Validation failed/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should infer remote kind from agent_card_json', async () => {
|
||||
const cardJson = JSON.stringify({
|
||||
name: 'test',
|
||||
url: 'https://example.com',
|
||||
});
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
name: inferred-json-remote
|
||||
agent_card_json: '${cardJson}'
|
||||
---
|
||||
`);
|
||||
const result = await parseAgentMarkdown(filePath);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
kind: 'remote',
|
||||
name: 'inferred-json-remote',
|
||||
agent_card_json: cardJson,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw AgentLoadError if agent name is not a valid slug', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
name: Invalid Name With Spaces
|
||||
@@ -242,6 +314,99 @@ Body`);
|
||||
/Name must be a valid slug/,
|
||||
);
|
||||
});
|
||||
|
||||
describe('error formatting and kind inference', () => {
|
||||
it('should only show local agent errors when kind is inferred as local (via kind field)', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
kind: local
|
||||
name: invalid-local
|
||||
# missing description
|
||||
---
|
||||
Body`);
|
||||
const error = await parseAgentMarkdown(filePath).catch((e) => e);
|
||||
expect(error).toBeInstanceOf(AgentLoadError);
|
||||
expect(error.message).toContain('Validation failed');
|
||||
expect(error.message).toContain('description: Required');
|
||||
expect(error.message).not.toContain('Remote Agent');
|
||||
});
|
||||
|
||||
it('should only show local agent errors when kind is inferred as local (via local-specific keys)', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
name: invalid-local
|
||||
# missing description
|
||||
tools:
|
||||
- run_shell_command
|
||||
---
|
||||
Body`);
|
||||
const error = await parseAgentMarkdown(filePath).catch((e) => e);
|
||||
expect(error).toBeInstanceOf(AgentLoadError);
|
||||
expect(error.message).toContain('Validation failed');
|
||||
expect(error.message).toContain('description: Required');
|
||||
expect(error.message).not.toContain('Remote Agent');
|
||||
});
|
||||
|
||||
it('should only show remote agent errors when kind is inferred as remote (via kind field)', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
kind: remote
|
||||
name: invalid-remote
|
||||
# missing agent_card_url
|
||||
---
|
||||
Body`);
|
||||
const error = await parseAgentMarkdown(filePath).catch((e) => e);
|
||||
expect(error).toBeInstanceOf(AgentLoadError);
|
||||
expect(error.message).toContain('Validation failed');
|
||||
expect(error.message).toContain('agent_card_url: Required');
|
||||
expect(error.message).not.toContain('Local Agent');
|
||||
});
|
||||
|
||||
it('should only show remote agent errors when kind is inferred as remote (via remote-specific keys)', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
name: invalid-remote
|
||||
auth:
|
||||
type: apiKey
|
||||
key: my_key
|
||||
# missing agent_card_url
|
||||
---
|
||||
Body`);
|
||||
const error = await parseAgentMarkdown(filePath).catch((e) => e);
|
||||
expect(error).toBeInstanceOf(AgentLoadError);
|
||||
expect(error.message).toContain('Validation failed');
|
||||
expect(error.message).toContain('agent_card_url: Required');
|
||||
expect(error.message).not.toContain('Local Agent');
|
||||
});
|
||||
|
||||
it('should show errors for both types when kind cannot be inferred', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
name: invalid-unknown
|
||||
# missing description and missing agent_card_url, no specific keys
|
||||
---
|
||||
Body`);
|
||||
const error = await parseAgentMarkdown(filePath).catch((e) => e);
|
||||
expect(error).toBeInstanceOf(AgentLoadError);
|
||||
expect(error.message).toContain('Validation failed');
|
||||
expect(error.message).toContain('(Local Agent)');
|
||||
expect(error.message).toContain('(Remote Agent)');
|
||||
expect(error.message).toContain('description: Required');
|
||||
expect(error.message).toContain('agent_card_url: Required');
|
||||
});
|
||||
|
||||
it('should format errors without a stray colon when the path is empty (e.g. strict object with unknown keys)', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
kind: local
|
||||
name: my-agent
|
||||
description: test
|
||||
unknown_field: true
|
||||
---
|
||||
Body`);
|
||||
const error = await parseAgentMarkdown(filePath).catch((e) => e);
|
||||
expect(error).toBeInstanceOf(AgentLoadError);
|
||||
expect(error.message).toContain(
|
||||
"Unrecognized key(s) in object: 'unknown_field'",
|
||||
);
|
||||
expect(error.message).not.toContain(': Unrecognized key(s)');
|
||||
expect(error.message).not.toContain('Required');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('markdownToAgentDefinition', () => {
|
||||
@@ -372,6 +537,40 @@ Body`);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert remote agent definition with agent_card_json', () => {
|
||||
const cardJson = JSON.stringify({
|
||||
name: 'json-agent',
|
||||
url: 'https://example.com/agent',
|
||||
});
|
||||
const markdown = {
|
||||
kind: 'remote' as const,
|
||||
name: 'json-remote',
|
||||
description: 'A JSON remote agent',
|
||||
agent_card_json: cardJson,
|
||||
};
|
||||
|
||||
const result = markdownToAgentDefinition(
|
||||
markdown,
|
||||
) as RemoteAgentDefinition;
|
||||
expect(result.kind).toBe('remote');
|
||||
expect(result.name).toBe('json-remote');
|
||||
expect(result.agentCardJson).toBe(cardJson);
|
||||
expect(result.agentCardUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw for remote agent with neither agent_card_url nor agent_card_json', () => {
|
||||
// Cast to bypass compile-time check — this tests the runtime guard
|
||||
const markdown = {
|
||||
kind: 'remote' as const,
|
||||
name: 'no-card-agent',
|
||||
description: 'Missing card info',
|
||||
} as Parameters<typeof markdownToAgentDefinition>[0];
|
||||
|
||||
expect(() => markdownToAgentDefinition(markdown)).toThrow(
|
||||
/neither agent_card_json nor agent_card_url/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadAgentsFromDirectory', () => {
|
||||
@@ -744,5 +943,103 @@ auth:
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error for an unknown auth type in markdownToAgentDefinition', () => {
|
||||
const markdown = {
|
||||
kind: 'remote' as const,
|
||||
name: 'unknown-auth-agent',
|
||||
agent_card_url: 'https://example.com/card',
|
||||
auth: {
|
||||
type: 'apiKey' as const,
|
||||
key: 'some-key',
|
||||
},
|
||||
};
|
||||
|
||||
// Mutate the object at runtime to bypass TypeScript compile-time checks cleanly
|
||||
Object.assign(markdown.auth, { type: 'some-unknown-type' });
|
||||
|
||||
expect(() => markdownToAgentDefinition(markdown)).toThrow(
|
||||
/Unknown auth type: some-unknown-type/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAgentCardLoadOptions', () => {
|
||||
it('should return json options when agentCardJson is present', () => {
|
||||
const def = {
|
||||
name: 'test',
|
||||
agentCardJson: '{"url":"http://x"}',
|
||||
} as RemoteAgentDefinition;
|
||||
const opts = getAgentCardLoadOptions(def);
|
||||
expect(opts).toEqual({ type: 'json', json: '{"url":"http://x"}' });
|
||||
});
|
||||
|
||||
it('should return url options when agentCardUrl is present', () => {
|
||||
const def = {
|
||||
name: 'test',
|
||||
agentCardUrl: 'http://x/card',
|
||||
} as RemoteAgentDefinition;
|
||||
const opts = getAgentCardLoadOptions(def);
|
||||
expect(opts).toEqual({ type: 'url', url: 'http://x/card' });
|
||||
});
|
||||
|
||||
it('should prefer agentCardJson over agentCardUrl when both present', () => {
|
||||
const def = {
|
||||
name: 'test',
|
||||
agentCardJson: '{"url":"http://x"}',
|
||||
agentCardUrl: 'http://x/card',
|
||||
} as RemoteAgentDefinition;
|
||||
const opts = getAgentCardLoadOptions(def);
|
||||
expect(opts.type).toBe('json');
|
||||
});
|
||||
|
||||
it('should throw when neither is present', () => {
|
||||
const def = { name: 'orphan' } as RemoteAgentDefinition;
|
||||
expect(() => getAgentCardLoadOptions(def)).toThrow(
|
||||
/Remote agent 'orphan' has neither agentCardUrl nor agentCardJson/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRemoteAgentTargetUrl', () => {
|
||||
it('should return agentCardUrl when present', () => {
|
||||
const def = {
|
||||
name: 'test',
|
||||
agentCardUrl: 'http://x/card',
|
||||
} as RemoteAgentDefinition;
|
||||
expect(getRemoteAgentTargetUrl(def)).toBe('http://x/card');
|
||||
});
|
||||
|
||||
it('should extract url from agentCardJson when agentCardUrl is absent', () => {
|
||||
const def = {
|
||||
name: 'test',
|
||||
agentCardJson: JSON.stringify({
|
||||
name: 'agent',
|
||||
url: 'https://example.com/agent',
|
||||
}),
|
||||
} as RemoteAgentDefinition;
|
||||
expect(getRemoteAgentTargetUrl(def)).toBe('https://example.com/agent');
|
||||
});
|
||||
|
||||
it('should return undefined when JSON has no url field', () => {
|
||||
const def = {
|
||||
name: 'test',
|
||||
agentCardJson: JSON.stringify({ name: 'agent' }),
|
||||
} as RemoteAgentDefinition;
|
||||
expect(getRemoteAgentTargetUrl(def)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when agentCardJson is invalid JSON', () => {
|
||||
const def = {
|
||||
name: 'test',
|
||||
agentCardJson: 'not json',
|
||||
} as RemoteAgentDefinition;
|
||||
expect(getRemoteAgentTargetUrl(def)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when neither field is present', () => {
|
||||
const def = { name: 'test' } as RemoteAgentDefinition;
|
||||
expect(getRemoteAgentTargetUrl(def)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import * as crypto from 'node:crypto';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
type AgentDefinition,
|
||||
type RemoteAgentDefinition,
|
||||
DEFAULT_MAX_TURNS,
|
||||
DEFAULT_MAX_TIME_MINUTES,
|
||||
} from './types.js';
|
||||
@@ -21,79 +22,6 @@ import { isValidToolName } from '../tools/tool-names.js';
|
||||
import { FRONTMATTER_REGEX } from '../skills/skillLoader.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
|
||||
/**
|
||||
* DTO for Markdown parsing - represents the structure from frontmatter.
|
||||
*/
|
||||
interface FrontmatterBaseAgentDefinition {
|
||||
name: string;
|
||||
display_name?: string;
|
||||
}
|
||||
|
||||
interface FrontmatterMCPServerConfig {
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
cwd?: string;
|
||||
url?: string;
|
||||
http_url?: string;
|
||||
headers?: Record<string, string>;
|
||||
tcp?: string;
|
||||
type?: 'sse' | 'http';
|
||||
timeout?: number;
|
||||
trust?: boolean;
|
||||
description?: string;
|
||||
include_tools?: string[];
|
||||
exclude_tools?: string[];
|
||||
}
|
||||
|
||||
interface FrontmatterLocalAgentDefinition
|
||||
extends FrontmatterBaseAgentDefinition {
|
||||
kind: 'local';
|
||||
description: string;
|
||||
tools?: string[];
|
||||
mcp_servers?: Record<string, FrontmatterMCPServerConfig>;
|
||||
system_prompt: string;
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
max_turns?: number;
|
||||
timeout_mins?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication configuration for remote agents in frontmatter format.
|
||||
*/
|
||||
interface FrontmatterAuthConfig {
|
||||
type: 'apiKey' | 'http' | 'google-credentials' | 'oauth';
|
||||
// API Key
|
||||
key?: string;
|
||||
name?: string;
|
||||
// HTTP
|
||||
scheme?: string;
|
||||
token?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
value?: string;
|
||||
// Google Credentials
|
||||
scopes?: string[];
|
||||
// OAuth2
|
||||
client_id?: string;
|
||||
client_secret?: string;
|
||||
authorization_url?: string;
|
||||
token_url?: string;
|
||||
}
|
||||
|
||||
interface FrontmatterRemoteAgentDefinition
|
||||
extends FrontmatterBaseAgentDefinition {
|
||||
kind: 'remote';
|
||||
description?: string;
|
||||
agent_card_url: string;
|
||||
auth?: FrontmatterAuthConfig;
|
||||
}
|
||||
|
||||
type FrontmatterAgentDefinition =
|
||||
| FrontmatterLocalAgentDefinition
|
||||
| FrontmatterRemoteAgentDefinition;
|
||||
|
||||
/**
|
||||
* Error thrown when an agent definition is invalid or cannot be loaded.
|
||||
*/
|
||||
@@ -159,15 +87,13 @@ const localAgentSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Base fields shared by all auth configs.
|
||||
*/
|
||||
type FrontmatterLocalAgentDefinition = z.infer<typeof localAgentSchema> & {
|
||||
system_prompt: string;
|
||||
};
|
||||
|
||||
// Base fields shared by all auth configs.
|
||||
const baseAuthFields = {};
|
||||
|
||||
/**
|
||||
* API Key auth schema.
|
||||
* Supports sending key in header, query parameter, or cookie.
|
||||
*/
|
||||
const apiKeyAuthSchema = z.object({
|
||||
...baseAuthFields,
|
||||
type: z.literal('apiKey'),
|
||||
@@ -175,11 +101,6 @@ const apiKeyAuthSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* HTTP auth schema (Bearer or Basic).
|
||||
* Note: Validation for scheme-specific fields is applied in authConfigSchema
|
||||
* since discriminatedUnion doesn't support refined schemas directly.
|
||||
*/
|
||||
const httpAuthSchema = z.object({
|
||||
...baseAuthFields,
|
||||
type: z.literal('http'),
|
||||
@@ -190,19 +111,12 @@ const httpAuthSchema = z.object({
|
||||
value: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Google Credentials auth schema.
|
||||
*/
|
||||
const googleCredentialsAuthSchema = z.object({
|
||||
...baseAuthFields,
|
||||
type: z.literal('google-credentials'),
|
||||
scopes: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* OAuth2 auth schema.
|
||||
* authorization_url and token_url can be discovered from the agent card if omitted.
|
||||
*/
|
||||
const oauth2AuthSchema = z.object({
|
||||
...baseAuthFields,
|
||||
type: z.literal('oauth'),
|
||||
@@ -222,18 +136,16 @@ const authConfigSchema = z
|
||||
])
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.type === 'http') {
|
||||
if (data.value) {
|
||||
// Raw mode - only scheme and value are needed
|
||||
return;
|
||||
}
|
||||
if (data.scheme === 'Bearer' && !data.token) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Bearer scheme requires "token"',
|
||||
path: ['token'],
|
||||
});
|
||||
}
|
||||
if (data.scheme === 'Basic') {
|
||||
if (data.value) return;
|
||||
if (data.scheme === 'Bearer') {
|
||||
if (!data.token) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Bearer scheme requires "token"',
|
||||
path: ['token'],
|
||||
});
|
||||
}
|
||||
} else if (data.scheme === 'Basic') {
|
||||
if (!data.username) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
@@ -248,55 +160,127 @@ const authConfigSchema = z
|
||||
path: ['password'],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `HTTP scheme "${data.scheme}" requires "value"`,
|
||||
path: ['value'],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const remoteAgentSchema = z
|
||||
.object({
|
||||
kind: z.literal('remote').optional().default('remote'),
|
||||
name: nameSchema,
|
||||
description: z.string().optional(),
|
||||
display_name: z.string().optional(),
|
||||
type FrontmatterAuthConfig = z.infer<typeof authConfigSchema>;
|
||||
|
||||
const baseRemoteAgentSchema = z.object({
|
||||
kind: z.literal('remote').optional().default('remote'),
|
||||
name: nameSchema,
|
||||
description: z.string().optional(),
|
||||
display_name: z.string().optional(),
|
||||
auth: authConfigSchema.optional(),
|
||||
});
|
||||
|
||||
const remoteAgentUrlSchema = baseRemoteAgentSchema
|
||||
.extend({
|
||||
agent_card_url: z.string().url(),
|
||||
auth: authConfigSchema.optional(),
|
||||
agent_card_json: z.undefined().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// Use a Zod union to automatically discriminate between local and remote
|
||||
// agent types.
|
||||
const remoteAgentJsonSchema = baseRemoteAgentSchema
|
||||
.extend({
|
||||
agent_card_url: z.undefined().optional(),
|
||||
agent_card_json: z.string().refine(
|
||||
(val) => {
|
||||
try {
|
||||
JSON.parse(val);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ message: 'agent_card_json must be valid JSON' },
|
||||
),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const remoteAgentSchema = z.union([
|
||||
remoteAgentUrlSchema,
|
||||
remoteAgentJsonSchema,
|
||||
]);
|
||||
type FrontmatterRemoteAgentDefinition = z.infer<typeof remoteAgentSchema>;
|
||||
|
||||
type FrontmatterAgentDefinition =
|
||||
| FrontmatterLocalAgentDefinition
|
||||
| FrontmatterRemoteAgentDefinition;
|
||||
|
||||
const agentUnionOptions = [
|
||||
{ schema: localAgentSchema, label: 'Local Agent' },
|
||||
{ schema: remoteAgentSchema, label: 'Remote Agent' },
|
||||
] as const;
|
||||
{ label: 'Local Agent' },
|
||||
{ label: 'Remote Agent' },
|
||||
{ label: 'Remote Agent' },
|
||||
];
|
||||
|
||||
const remoteAgentsListSchema = z.array(remoteAgentSchema);
|
||||
|
||||
const markdownFrontmatterSchema = z.union([
|
||||
agentUnionOptions[0].schema,
|
||||
agentUnionOptions[1].schema,
|
||||
localAgentSchema,
|
||||
remoteAgentUrlSchema,
|
||||
remoteAgentJsonSchema,
|
||||
]);
|
||||
|
||||
function formatZodError(error: z.ZodError, context: string): string {
|
||||
const issues = error.issues
|
||||
.map((i) => {
|
||||
// Handle union errors specifically to give better context
|
||||
function guessIntendedKind(rawInput: unknown): 'local' | 'remote' | undefined {
|
||||
if (typeof rawInput !== 'object' || rawInput === null) return undefined;
|
||||
const input = rawInput as Partial<FrontmatterLocalAgentDefinition> &
|
||||
Partial<FrontmatterRemoteAgentDefinition>;
|
||||
|
||||
if (input.kind === 'local') return 'local';
|
||||
if (input.kind === 'remote') return 'remote';
|
||||
|
||||
const hasLocalKeys =
|
||||
'tools' in input ||
|
||||
'mcp_servers' in input ||
|
||||
'model' in input ||
|
||||
'temperature' in input ||
|
||||
'max_turns' in input ||
|
||||
'timeout_mins' in input;
|
||||
const hasRemoteKeys =
|
||||
'agent_card_url' in input || 'auth' in input || 'agent_card_json' in input;
|
||||
|
||||
if (hasLocalKeys && !hasRemoteKeys) return 'local';
|
||||
if (hasRemoteKeys && !hasLocalKeys) return 'remote';
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatZodError(
|
||||
error: z.ZodError,
|
||||
context: string,
|
||||
rawInput?: unknown,
|
||||
): string {
|
||||
const intendedKind = rawInput ? guessIntendedKind(rawInput) : undefined;
|
||||
|
||||
const formatIssues = (issues: z.ZodIssue[], unionPrefix?: string): string[] =>
|
||||
issues.flatMap((i) => {
|
||||
if (i.code === z.ZodIssueCode.invalid_union) {
|
||||
return i.unionErrors
|
||||
.map((unionError, index) => {
|
||||
const label =
|
||||
agentUnionOptions[index]?.label ?? `Agent type #${index + 1}`;
|
||||
const unionIssues = unionError.issues
|
||||
.map((u) => `${u.path.join('.')}: ${u.message}`)
|
||||
.join(', ');
|
||||
return `(${label}) ${unionIssues}`;
|
||||
})
|
||||
.join('\n');
|
||||
return i.unionErrors.flatMap((unionError, index) => {
|
||||
const label = unionPrefix
|
||||
? unionPrefix
|
||||
: ((agentUnionOptions[index] as { label?: string })?.label ??
|
||||
`Branch #${index + 1}`);
|
||||
|
||||
if (intendedKind === 'local' && label === 'Remote Agent') return [];
|
||||
if (intendedKind === 'remote' && label === 'Local Agent') return [];
|
||||
|
||||
return formatIssues(unionError.issues, label);
|
||||
});
|
||||
}
|
||||
return `${i.path.join('.')}: ${i.message}`;
|
||||
})
|
||||
.join('\n');
|
||||
return `${context}:\n${issues}`;
|
||||
const prefix = unionPrefix ? `(${unionPrefix}) ` : '';
|
||||
const path = i.path.length > 0 ? `${i.path.join('.')}: ` : '';
|
||||
return `${prefix}${path}${i.message}`;
|
||||
});
|
||||
|
||||
const formatted = Array.from(new Set(formatIssues(error.issues))).join('\n');
|
||||
return `${context}:\n${formatted}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -343,8 +327,7 @@ export async function parseAgentMarkdown(
|
||||
} catch (error) {
|
||||
throw new AgentLoadError(
|
||||
filePath,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
`YAML frontmatter parsing failed: ${(error as Error).message}`,
|
||||
`YAML frontmatter parsing failed: ${getErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -368,7 +351,7 @@ export async function parseAgentMarkdown(
|
||||
if (!result.success) {
|
||||
throw new AgentLoadError(
|
||||
filePath,
|
||||
`Validation failed: ${formatZodError(result.error, 'Agent Definition')}`,
|
||||
`Validation failed: ${formatZodError(result.error, 'Agent Definition', rawFrontmatter)}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -383,17 +366,14 @@ export async function parseAgentMarkdown(
|
||||
];
|
||||
}
|
||||
|
||||
// Local agent validation
|
||||
// Validate tools
|
||||
|
||||
// Construct the local agent definition
|
||||
const agentDef: FrontmatterLocalAgentDefinition = {
|
||||
...frontmatter,
|
||||
kind: 'local',
|
||||
system_prompt: body.trim(),
|
||||
};
|
||||
|
||||
return [agentDef];
|
||||
return [
|
||||
{
|
||||
...frontmatter,
|
||||
kind: 'local',
|
||||
system_prompt: body.trim(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -403,15 +383,9 @@ export async function parseAgentMarkdown(
|
||||
function convertFrontmatterAuthToConfig(
|
||||
frontmatter: FrontmatterAuthConfig,
|
||||
): A2AAuthConfig {
|
||||
const base = {};
|
||||
|
||||
switch (frontmatter.type) {
|
||||
case 'apiKey':
|
||||
if (!frontmatter.key) {
|
||||
throw new Error('Internal error: API key missing after validation.');
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
type: 'apiKey',
|
||||
key: frontmatter.key,
|
||||
name: frontmatter.name,
|
||||
@@ -419,20 +393,13 @@ function convertFrontmatterAuthToConfig(
|
||||
|
||||
case 'google-credentials':
|
||||
return {
|
||||
...base,
|
||||
type: 'google-credentials',
|
||||
scopes: frontmatter.scopes,
|
||||
};
|
||||
|
||||
case 'http': {
|
||||
if (!frontmatter.scheme) {
|
||||
throw new Error(
|
||||
'Internal error: HTTP scheme missing after validation.',
|
||||
);
|
||||
}
|
||||
case 'http':
|
||||
if (frontmatter.value) {
|
||||
return {
|
||||
...base,
|
||||
type: 'http',
|
||||
scheme: frontmatter.scheme,
|
||||
value: frontmatter.value,
|
||||
@@ -440,40 +407,27 @@ function convertFrontmatterAuthToConfig(
|
||||
}
|
||||
switch (frontmatter.scheme) {
|
||||
case 'Bearer':
|
||||
if (!frontmatter.token) {
|
||||
throw new Error(
|
||||
'Internal error: Bearer token missing after validation.',
|
||||
);
|
||||
}
|
||||
// Token is required by schema validation
|
||||
return {
|
||||
...base,
|
||||
type: 'http',
|
||||
scheme: 'Bearer',
|
||||
token: frontmatter.token,
|
||||
|
||||
token: frontmatter.token!,
|
||||
};
|
||||
case 'Basic':
|
||||
if (!frontmatter.username || !frontmatter.password) {
|
||||
throw new Error(
|
||||
'Internal error: Basic auth credentials missing after validation.',
|
||||
);
|
||||
}
|
||||
// Username/password are required by schema validation
|
||||
return {
|
||||
...base,
|
||||
type: 'http',
|
||||
scheme: 'Basic',
|
||||
username: frontmatter.username,
|
||||
password: frontmatter.password,
|
||||
username: frontmatter.username!,
|
||||
password: frontmatter.password!,
|
||||
};
|
||||
default: {
|
||||
// Other IANA schemes without a value should not reach here after validation
|
||||
default:
|
||||
throw new Error(`Unknown HTTP scheme: ${frontmatter.scheme}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case 'oauth':
|
||||
return {
|
||||
...base,
|
||||
type: 'oauth2',
|
||||
client_id: frontmatter.client_id,
|
||||
client_secret: frontmatter.client_secret,
|
||||
@@ -483,8 +437,12 @@ function convertFrontmatterAuthToConfig(
|
||||
};
|
||||
|
||||
default: {
|
||||
const exhaustive: never = frontmatter.type;
|
||||
throw new Error(`Unknown auth type: ${exhaustive}`);
|
||||
const exhaustive: never = frontmatter;
|
||||
const raw: unknown = exhaustive;
|
||||
if (typeof raw === 'object' && raw !== null && 'type' in raw) {
|
||||
throw new Error(`Unknown auth type: ${String(raw['type'])}`);
|
||||
}
|
||||
throw new Error('Unknown auth type');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -515,25 +473,41 @@ export function markdownToAgentDefinition(
|
||||
};
|
||||
|
||||
if (markdown.kind === 'remote') {
|
||||
return {
|
||||
const base: RemoteAgentDefinition = {
|
||||
kind: 'remote',
|
||||
name: markdown.name,
|
||||
description: markdown.description || '',
|
||||
displayName: markdown.display_name,
|
||||
agentCardUrl: markdown.agent_card_url,
|
||||
auth: markdown.auth
|
||||
? convertFrontmatterAuthToConfig(markdown.auth)
|
||||
: undefined,
|
||||
inputConfig,
|
||||
metadata,
|
||||
};
|
||||
|
||||
if (
|
||||
'agent_card_json' in markdown &&
|
||||
markdown.agent_card_json !== undefined
|
||||
) {
|
||||
base.agentCardJson = markdown.agent_card_json;
|
||||
return base;
|
||||
}
|
||||
if ('agent_card_url' in markdown && markdown.agent_card_url !== undefined) {
|
||||
base.agentCardUrl = markdown.agent_card_url;
|
||||
return base;
|
||||
}
|
||||
|
||||
throw new AgentLoadError(
|
||||
metadata?.filePath || 'unknown',
|
||||
'Unexpected state: neither agent_card_json nor agent_card_url present on remote agent',
|
||||
);
|
||||
}
|
||||
|
||||
// If a model is specified, use it. Otherwise, inherit
|
||||
const modelName = markdown.model || 'inherit';
|
||||
|
||||
const mcpServers: Record<string, MCPServerConfig> = {};
|
||||
if (markdown.kind === 'local' && markdown.mcp_servers) {
|
||||
if (markdown.mcp_servers) {
|
||||
for (const [name, config] of Object.entries(markdown.mcp_servers)) {
|
||||
mcpServers[name] = new MCPServerConfig(
|
||||
config.command,
|
||||
@@ -606,15 +580,13 @@ export async function loadAgentsFromDirectory(
|
||||
dirEntries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
// If directory doesn't exist, just return empty
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
||||
return result;
|
||||
}
|
||||
result.errors.push(
|
||||
new AgentLoadError(
|
||||
dir,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
`Could not list directory: ${(error as Error).message}`,
|
||||
`Could not list directory: ${getErrorMessage(error)}`,
|
||||
),
|
||||
);
|
||||
return result;
|
||||
@@ -644,8 +616,7 @@ export async function loadAgentsFromDirectory(
|
||||
result.errors.push(
|
||||
new AgentLoadError(
|
||||
filePath,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
`Unexpected error: ${(error as Error).message}`,
|
||||
`Unexpected error: ${getErrorMessage(error)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -596,7 +596,7 @@ describe('AgentRegistry', () => {
|
||||
});
|
||||
expect(loadAgentSpy).toHaveBeenCalledWith(
|
||||
'RemoteAgentWithAuth',
|
||||
'https://example.com/card',
|
||||
{ type: 'url', url: 'https://example.com/card' },
|
||||
mockHandler,
|
||||
);
|
||||
expect(registry.getDefinition('RemoteAgentWithAuth')).toEqual(
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as crypto from 'node:crypto';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import { CoreEvent, coreEvents } from '../utils/events.js';
|
||||
import type { AgentOverride, Config } from '../config/config.js';
|
||||
import type { AgentDefinition, LocalAgentDefinition } from './types.js';
|
||||
import { getAgentCardLoadOptions, getRemoteAgentTargetUrl } from './types.js';
|
||||
import { loadAgentsFromDirectory } from './agentLoader.js';
|
||||
import { CodebaseInvestigatorAgent } from './codebase-investigator.js';
|
||||
import { CliHelpAgent } from './cli-help-agent.js';
|
||||
@@ -162,7 +164,14 @@ export class AgentRegistry {
|
||||
if (!agent.metadata) {
|
||||
agent.metadata = {};
|
||||
}
|
||||
agent.metadata.hash = agent.agentCardUrl;
|
||||
agent.metadata.hash =
|
||||
agent.agentCardUrl ??
|
||||
(agent.agentCardJson
|
||||
? crypto
|
||||
.createHash('sha256')
|
||||
.update(agent.agentCardJson)
|
||||
.digest('hex')
|
||||
: undefined);
|
||||
}
|
||||
|
||||
if (!agent.metadata?.hash) {
|
||||
@@ -443,12 +452,13 @@ export class AgentRegistry {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const targetUrl = getRemoteAgentTargetUrl(remoteDef);
|
||||
let authHandler: AuthenticationHandler | undefined;
|
||||
if (definition.auth) {
|
||||
const provider = await A2AAuthProviderFactory.create({
|
||||
authConfig: definition.auth,
|
||||
agentName: definition.name,
|
||||
targetUrl: definition.agentCardUrl,
|
||||
targetUrl,
|
||||
agentCardUrl: remoteDef.agentCardUrl,
|
||||
});
|
||||
if (!provider) {
|
||||
@@ -461,7 +471,7 @@ export class AgentRegistry {
|
||||
|
||||
const agentCard = await clientManager.loadAgent(
|
||||
remoteDef.name,
|
||||
remoteDef.agentCardUrl,
|
||||
getAgentCardLoadOptions(remoteDef),
|
||||
authHandler,
|
||||
);
|
||||
|
||||
@@ -515,7 +525,7 @@ export class AgentRegistry {
|
||||
|
||||
if (this.config.getDebugMode()) {
|
||||
debugLogger.log(
|
||||
`[AgentRegistry] Registered remote agent '${definition.name}' with card: ${definition.agentCardUrl}`,
|
||||
`[AgentRegistry] Registered remote agent '${definition.name}' with card: ${definition.agentCardUrl ?? 'inline JSON'}`,
|
||||
);
|
||||
}
|
||||
this.agents.set(definition.name, definition);
|
||||
|
||||
@@ -189,7 +189,7 @@ describe('RemoteAgentInvocation', () => {
|
||||
|
||||
expect(mockClientManager.loadAgent).toHaveBeenCalledWith(
|
||||
'test-agent',
|
||||
'http://test-agent/card',
|
||||
{ type: 'url', url: 'http://test-agent/card' },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -240,7 +240,7 @@ describe('RemoteAgentInvocation', () => {
|
||||
});
|
||||
expect(mockClientManager.loadAgent).toHaveBeenCalledWith(
|
||||
'test-agent',
|
||||
'http://test-agent/card',
|
||||
{ type: 'url', url: 'http://test-agent/card' },
|
||||
mockHandler,
|
||||
);
|
||||
});
|
||||
@@ -266,11 +266,10 @@ describe('RemoteAgentInvocation', () => {
|
||||
);
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.returnDisplay).toMatchObject({
|
||||
result: expect.stringContaining(
|
||||
"Failed to create auth provider for agent 'test-agent'",
|
||||
),
|
||||
});
|
||||
expect(result.returnDisplay).toMatchObject({ state: 'error' });
|
||||
expect((result.returnDisplay as SubagentProgress).result).toContain(
|
||||
"Failed to create auth provider for agent 'test-agent'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should not load the agent if already present', async () => {
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
type RemoteAgentDefinition,
|
||||
type AgentInputs,
|
||||
type SubagentProgress,
|
||||
getAgentCardLoadOptions,
|
||||
getRemoteAgentTargetUrl,
|
||||
} from './types.js';
|
||||
import { type AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
@@ -92,10 +94,11 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
|
||||
}
|
||||
|
||||
if (this.definition.auth) {
|
||||
const targetUrl = getRemoteAgentTargetUrl(this.definition);
|
||||
const provider = await A2AAuthProviderFactory.create({
|
||||
authConfig: this.definition.auth,
|
||||
agentName: this.definition.name,
|
||||
targetUrl: this.definition.agentCardUrl,
|
||||
targetUrl,
|
||||
agentCardUrl: this.definition.agentCardUrl,
|
||||
});
|
||||
if (!provider) {
|
||||
@@ -162,7 +165,7 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
|
||||
if (!this.clientManager.getClient(this.definition.name)) {
|
||||
await this.clientManager.loadAgent(
|
||||
this.definition.name,
|
||||
this.definition.agentCardUrl,
|
||||
getAgentCardLoadOptions(this.definition),
|
||||
authHandler,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { AnyDeclarativeTool } from '../tools/tools.js';
|
||||
import { type z } from 'zod';
|
||||
import type { ModelConfig } from '../services/modelConfigService.js';
|
||||
import type { AnySchema } from 'ajv';
|
||||
import type { AgentCard } from '@a2a-js/sdk';
|
||||
import type { A2AAuthConfig } from './auth-provider/types.js';
|
||||
import type { MCPServerConfig } from '../config/config.js';
|
||||
|
||||
@@ -128,6 +129,62 @@ export function isToolActivityError(data: unknown): boolean {
|
||||
* The base definition for an agent.
|
||||
* @template TOutput The specific Zod schema for the agent's final output object.
|
||||
*/
|
||||
export type AgentCardLoadOptions =
|
||||
| { type: 'url'; url: string }
|
||||
| { type: 'json'; json: string };
|
||||
|
||||
/** Minimal shape needed by helper functions, avoids generic TOutput constraints. */
|
||||
interface RemoteAgentRef {
|
||||
name: string;
|
||||
agentCardUrl?: string;
|
||||
agentCardJson?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the AgentCardLoadOptions from a RemoteAgentDefinition.
|
||||
* Throws if neither agentCardUrl nor agentCardJson is present.
|
||||
*/
|
||||
export function getAgentCardLoadOptions(
|
||||
def: RemoteAgentRef,
|
||||
): AgentCardLoadOptions {
|
||||
if (def.agentCardJson) {
|
||||
return { type: 'json', json: def.agentCardJson };
|
||||
}
|
||||
if (def.agentCardUrl) {
|
||||
return { type: 'url', url: def.agentCardUrl };
|
||||
}
|
||||
throw new Error(
|
||||
`Remote agent '${def.name}' has neither agentCardUrl nor agentCardJson`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a target URL for auth providers from a RemoteAgentDefinition.
|
||||
* For URL-based agents, returns the agentCardUrl.
|
||||
* For JSON-based agents, attempts to parse the URL from the inline card JSON.
|
||||
* Returns undefined if no URL can be determined.
|
||||
*/
|
||||
export function getRemoteAgentTargetUrl(
|
||||
def: RemoteAgentRef,
|
||||
): string | undefined {
|
||||
if (def.agentCardUrl) {
|
||||
return def.agentCardUrl;
|
||||
}
|
||||
if (def.agentCardJson) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(def.agentCardJson);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const card = parsed as AgentCard;
|
||||
if (card.url) {
|
||||
return card.url;
|
||||
}
|
||||
} catch {
|
||||
// JSON parse will fail properly later in loadAgent
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface BaseAgentDefinition<
|
||||
TOutput extends z.ZodTypeAny = z.ZodUnknown,
|
||||
> {
|
||||
@@ -172,11 +229,10 @@ export interface LocalAgentDefinition<
|
||||
processOutput?: (output: z.infer<TOutput>) => string;
|
||||
}
|
||||
|
||||
export interface RemoteAgentDefinition<
|
||||
export interface BaseRemoteAgentDefinition<
|
||||
TOutput extends z.ZodTypeAny = z.ZodUnknown,
|
||||
> extends BaseAgentDefinition<TOutput> {
|
||||
kind: 'remote';
|
||||
agentCardUrl: string;
|
||||
/** The user-provided description, before any remote card merging. */
|
||||
originalDescription?: string;
|
||||
/**
|
||||
@@ -187,6 +243,13 @@ export interface RemoteAgentDefinition<
|
||||
auth?: A2AAuthConfig;
|
||||
}
|
||||
|
||||
export interface RemoteAgentDefinition<
|
||||
TOutput extends z.ZodTypeAny = z.ZodUnknown,
|
||||
> extends BaseRemoteAgentDefinition<TOutput> {
|
||||
agentCardUrl?: string;
|
||||
agentCardJson?: string;
|
||||
}
|
||||
|
||||
export type AgentDefinition<TOutput extends z.ZodTypeAny = z.ZodUnknown> =
|
||||
| LocalAgentDefinition<TOutput>
|
||||
| RemoteAgentDefinition<TOutput>;
|
||||
|
||||
@@ -1027,7 +1027,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.model = params.model;
|
||||
this.disableLoopDetection = params.disableLoopDetection ?? false;
|
||||
this._activeModel = params.model;
|
||||
this.enableAgents = params.enableAgents ?? false;
|
||||
this.enableAgents = params.enableAgents ?? true;
|
||||
this.agents = params.agents ?? {};
|
||||
this.disableLLMCorrection = params.disableLLMCorrection ?? true;
|
||||
this.planEnabled = params.plan ?? true;
|
||||
|
||||
@@ -88,7 +88,11 @@ export * from './utils/approvalModeUtils.js';
|
||||
export * from './utils/fileDiffUtils.js';
|
||||
export * from './utils/retry.js';
|
||||
export * from './utils/shell-utils.js';
|
||||
export { PolicyDecision, ApprovalMode } from './policy/types.js';
|
||||
export {
|
||||
PolicyDecision,
|
||||
ApprovalMode,
|
||||
PRIORITY_YOLO_ALLOW_ALL,
|
||||
} from './policy/types.js';
|
||||
export * from './utils/tool-utils.js';
|
||||
export * from './utils/terminalSerializer.js';
|
||||
export * from './utils/systemEncoding.js';
|
||||
|
||||
@@ -110,6 +110,8 @@ priority = 70
|
||||
modes = ["plan"]
|
||||
|
||||
# Allow write_file and replace for .md files in the plans directory (cross-platform)
|
||||
# We split this into two rules to avoid ReDoS checker issues with nested optional segments.
|
||||
# This rule handles the case where there is a session ID in the plan file path
|
||||
[[rule]]
|
||||
toolName = ["write_file", "replace"]
|
||||
decision = "allow"
|
||||
@@ -117,6 +119,14 @@ priority = 70
|
||||
modes = ["plan"]
|
||||
argsPattern = "\\x00\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+tmp[\\\\/]+[\\w-]+[\\\\/]+[\\w-]+[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"\\x00"
|
||||
|
||||
# This rule handles the case where there isn't a session ID in the plan file path
|
||||
[[rule]]
|
||||
toolName = ["write_file", "replace"]
|
||||
decision = "allow"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
argsPattern = "\\x00\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+tmp[\\\\/]+[\\w-]+[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"\\x00"
|
||||
|
||||
# Explicitly Deny other write operations in Plan mode with a clear message.
|
||||
[[rule]]
|
||||
toolName = ["write_file", "replace"]
|
||||
|
||||
@@ -702,15 +702,6 @@ export class PolicyEngine {
|
||||
}
|
||||
}
|
||||
|
||||
// Sandbox Expansion requests MUST always be confirmed by the user,
|
||||
// even if the base command is otherwise ALLOWED by the policy engine.
|
||||
if (
|
||||
decision === PolicyDecision.ALLOW &&
|
||||
toolCall.args?.['additional_permissions']
|
||||
) {
|
||||
decision = PolicyDecision.ASK_USER;
|
||||
}
|
||||
|
||||
return {
|
||||
decision: this.applyNonInteractiveMode(decision),
|
||||
rule: matchedRule,
|
||||
|
||||
@@ -233,13 +233,19 @@ export {
|
||||
export function getShellDefinition(
|
||||
enableInteractiveShell: boolean,
|
||||
enableEfficiency: boolean,
|
||||
enableToolSandboxing: boolean = false,
|
||||
): ToolDefinition {
|
||||
return {
|
||||
base: getShellDeclaration(enableInteractiveShell, enableEfficiency),
|
||||
base: getShellDeclaration(
|
||||
enableInteractiveShell,
|
||||
enableEfficiency,
|
||||
enableToolSandboxing,
|
||||
),
|
||||
overrides: (modelId) =>
|
||||
getToolSet(modelId).run_shell_command(
|
||||
enableInteractiveShell,
|
||||
enableEfficiency,
|
||||
enableToolSandboxing,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ describe('coreTools snapshots for specific models', () => {
|
||||
{ name: 'list_directory', definition: LS_DEFINITION },
|
||||
{
|
||||
name: 'run_shell_command',
|
||||
definition: getShellDefinition(true, true),
|
||||
definition: getShellDefinition(true, true, true),
|
||||
},
|
||||
{ name: 'replace', definition: EDIT_DEFINITION },
|
||||
{ name: 'google_web_search', definition: WEB_SEARCH_DEFINITION },
|
||||
|
||||
@@ -81,6 +81,7 @@ export function getCommandDescription(): string {
|
||||
export function getShellDeclaration(
|
||||
enableInteractiveShell: boolean,
|
||||
enableEfficiency: boolean,
|
||||
enableToolSandboxing: boolean = false,
|
||||
): FunctionDeclaration {
|
||||
return {
|
||||
name: SHELL_TOOL_NAME,
|
||||
@@ -110,35 +111,39 @@ export function getShellDeclaration(
|
||||
description:
|
||||
'Set to true if this command should be run in the background (e.g. for long-running servers or watchers). The command will be started, allowed to run for a brief moment to check for immediate errors, and then moved to the background.',
|
||||
},
|
||||
[PARAM_ADDITIONAL_PERMISSIONS]: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Sandbox permissions for the command. Use this to request additional sandboxed filesystem or network permissions if a previous command failed with "Operation not permitted".',
|
||||
properties: {
|
||||
network: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Set to true to enable network access for this command.',
|
||||
},
|
||||
fileSystem: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
read: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description:
|
||||
'List of additional absolute paths to allow reading.',
|
||||
},
|
||||
write: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description:
|
||||
'List of additional absolute paths to allow writing.',
|
||||
...(enableToolSandboxing
|
||||
? {
|
||||
[PARAM_ADDITIONAL_PERMISSIONS]: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Sandbox permissions for the command. Use this to request additional sandboxed filesystem or network permissions if a previous command failed with "Operation not permitted".',
|
||||
properties: {
|
||||
network: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Set to true to enable network access for this command.',
|
||||
},
|
||||
fileSystem: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
read: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description:
|
||||
'List of additional absolute paths to allow reading.',
|
||||
},
|
||||
write: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description:
|
||||
'List of additional absolute paths to allow writing.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
required: [SHELL_PARAM_COMMAND],
|
||||
},
|
||||
|
||||
@@ -332,8 +332,16 @@ export const DEFAULT_LEGACY_SET: CoreToolSet = {
|
||||
},
|
||||
},
|
||||
|
||||
run_shell_command: (enableInteractiveShell, enableEfficiency) =>
|
||||
getShellDeclaration(enableInteractiveShell, enableEfficiency),
|
||||
run_shell_command: (
|
||||
enableInteractiveShell,
|
||||
enableEfficiency,
|
||||
enableToolSandboxing,
|
||||
) =>
|
||||
getShellDeclaration(
|
||||
enableInteractiveShell,
|
||||
enableEfficiency,
|
||||
enableToolSandboxing,
|
||||
),
|
||||
|
||||
replace: {
|
||||
name: EDIT_TOOL_NAME,
|
||||
|
||||
@@ -338,8 +338,16 @@ export const GEMINI_3_SET: CoreToolSet = {
|
||||
},
|
||||
},
|
||||
|
||||
run_shell_command: (enableInteractiveShell, enableEfficiency) =>
|
||||
getShellDeclaration(enableInteractiveShell, enableEfficiency),
|
||||
run_shell_command: (
|
||||
enableInteractiveShell,
|
||||
enableEfficiency,
|
||||
enableToolSandboxing,
|
||||
) =>
|
||||
getShellDeclaration(
|
||||
enableInteractiveShell,
|
||||
enableEfficiency,
|
||||
enableToolSandboxing,
|
||||
),
|
||||
|
||||
replace: {
|
||||
name: EDIT_TOOL_NAME,
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface CoreToolSet {
|
||||
run_shell_command: (
|
||||
enableInteractiveShell: boolean,
|
||||
enableEfficiency: boolean,
|
||||
enableToolSandboxing: boolean,
|
||||
) => FunctionDeclaration;
|
||||
replace: FunctionDeclaration;
|
||||
google_web_search: FunctionDeclaration;
|
||||
|
||||
@@ -137,6 +137,7 @@ describe('ShellTool', () => {
|
||||
getShellToolInactivityTimeout: vi.fn().mockReturnValue(1000),
|
||||
getEnableInteractiveShell: vi.fn().mockReturnValue(false),
|
||||
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
|
||||
getSandboxEnabled: vi.fn().mockReturnValue(false),
|
||||
sanitizationConfig: {},
|
||||
sandboxManager: new NoopSandboxManager(),
|
||||
} as unknown as Config;
|
||||
|
||||
@@ -696,6 +696,7 @@ export class ShellTool extends BaseDeclarativeTool<
|
||||
const definition = getShellDefinition(
|
||||
context.config.getEnableInteractiveShell(),
|
||||
context.config.getEnableShellOutputEfficiency(),
|
||||
context.config.getSandboxEnabled(),
|
||||
);
|
||||
super(
|
||||
ShellTool.Name,
|
||||
@@ -745,6 +746,7 @@ export class ShellTool extends BaseDeclarativeTool<
|
||||
const definition = getShellDefinition(
|
||||
this.context.config.getEnableInteractiveShell(),
|
||||
this.context.config.getEnableShellOutputEfficiency(),
|
||||
this.context.config.getSandboxEnabled(),
|
||||
);
|
||||
return resolveToolDeclaration(definition, modelId);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-devtools",
|
||||
"version": "0.36.0-nightly.20260317.2f90b4653",
|
||||
"version": "0.36.0-preview.3",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"main": "dist/src/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-sdk",
|
||||
"version": "0.36.0-nightly.20260317.2f90b4653",
|
||||
"version": "0.36.0-preview.3",
|
||||
"description": "Gemini CLI SDK",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.36.0-nightly.20260317.2f90b4653",
|
||||
"version": "0.36.0-preview.3",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"displayName": "Gemini CLI Companion",
|
||||
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
|
||||
"version": "0.36.0-nightly.20260317.2f90b4653",
|
||||
"version": "0.36.0-preview.3",
|
||||
"publisher": "google",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
|
||||
@@ -2680,8 +2680,8 @@
|
||||
"enableAgents": {
|
||||
"title": "Enable Agents",
|
||||
"description": "Enable local and remote subagents.",
|
||||
"markdownDescription": "Enable local and remote subagents.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `false`",
|
||||
"default": false,
|
||||
"markdownDescription": "Enable local and remote subagents.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `true`",
|
||||
"default": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"worktrees": {
|
||||
|
||||
Reference in New Issue
Block a user