mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-26 09:40:58 -07:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b901a7ce99 | |||
| 7a54e166b9 | |||
| 8e2629759d | |||
| 146442f2a2 | |||
| a3a3e66922 | |||
| d4555a473e | |||
| cdac58fe82 | |||
| 2421a073c8 | |||
| 4a95ab62d4 | |||
| 69e95a59d3 | |||
| 0223181cf4 | |||
| 6ecb6c5273 | |||
| e48a058d63 | |||
| 236249604e | |||
| 0cfe304506 |
@@ -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",
|
||||
|
||||
+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",
|
||||
|
||||
@@ -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
|
||||
@@ -465,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', () => {
|
||||
@@ -857,4 +963,83 @@ auth:
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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';
|
||||
@@ -171,17 +172,42 @@ const authConfigSchema = z
|
||||
|
||||
type FrontmatterAuthConfig = z.infer<typeof authConfigSchema>;
|
||||
|
||||
const remoteAgentSchema = z
|
||||
.object({
|
||||
kind: z.literal('remote').optional().default('remote'),
|
||||
name: nameSchema,
|
||||
description: z.string().optional(),
|
||||
display_name: z.string().optional(),
|
||||
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();
|
||||
|
||||
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 =
|
||||
@@ -189,15 +215,17 @@ type FrontmatterAgentDefinition =
|
||||
| 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 guessIntendedKind(rawInput: unknown): 'local' | 'remote' | undefined {
|
||||
@@ -215,7 +243,8 @@ function guessIntendedKind(rawInput: unknown): 'local' | 'remote' | undefined {
|
||||
'temperature' in input ||
|
||||
'max_turns' in input ||
|
||||
'timeout_mins' in input;
|
||||
const hasRemoteKeys = 'agent_card_url' in input || 'auth' 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';
|
||||
@@ -230,35 +259,29 @@ function formatZodError(
|
||||
): string {
|
||||
const intendedKind = rawInput ? guessIntendedKind(rawInput) : undefined;
|
||||
|
||||
const issues = error.issues
|
||||
.map((i) => {
|
||||
const formatIssues = (issues: z.ZodIssue[], unionPrefix?: string): string[] =>
|
||||
issues.flatMap((i) => {
|
||||
// Handle union errors specifically to give better context
|
||||
if (i.code === z.ZodIssueCode.invalid_union) {
|
||||
return i.unionErrors
|
||||
.map((unionError, index) => {
|
||||
const label =
|
||||
agentUnionOptions[index]?.label ?? `Agent type #${index + 1}`;
|
||||
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 null;
|
||||
if (intendedKind === 'remote' && label === 'Local Agent')
|
||||
return null;
|
||||
if (intendedKind === 'local' && label === 'Remote Agent') return [];
|
||||
if (intendedKind === 'remote' && label === 'Local Agent') return [];
|
||||
|
||||
const unionIssues = unionError.issues
|
||||
.map((u) => {
|
||||
const pathStr = u.path.join('.');
|
||||
return pathStr ? `${pathStr}: ${u.message}` : u.message;
|
||||
})
|
||||
.join(', ');
|
||||
return `(${label}) ${unionIssues}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
return formatIssues(unionError.issues, label);
|
||||
});
|
||||
}
|
||||
const pathStr = i.path.join('.');
|
||||
return pathStr ? `${pathStr}: ${i.message}` : 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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -397,9 +420,7 @@ function convertFrontmatterAuthToConfig(
|
||||
return {
|
||||
type: 'http',
|
||||
scheme: 'Basic',
|
||||
|
||||
username: frontmatter.username!,
|
||||
|
||||
password: frontmatter.password!,
|
||||
};
|
||||
default:
|
||||
@@ -453,18 +474,34 @@ 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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user