Compare commits

...

9 Commits

Author SHA1 Message Date
Adam Weidman 38c706dbb9 Merge remote-tracking branch 'origin/main' into fix/agent-loader-error-formatting
# Conflicts:
#	packages/core/src/agents/agentLoader.ts

# Conflicts:
#	packages/core/src/agents/agentLoader.ts
2026-03-26 14:04:36 -04:00
Adam Weidman 5e18b14c10 feat(core): support inline agentCardJson for remote agents
- Add agent_card_json field as alternative to agent_card_url in remote
  agent markdown frontmatter with Zod schema enforcing mutual exclusivity
- Refactor loadAgent to accept AgentCardLoadOptions discriminated union
- Add AgentCardLoadOptions, getAgentCardLoadOptions, getRemoteAgentTargetUrl
  helpers in types.ts to centralize remote agent card resolution
- Fix google-credentials auth crash when using agentCardJson by extracting
  targetUrl from inline JSON card
- Hash agentCardJson with SHA-256 for metadata.hash instead of raw string
- Add JSON syntax validation via Zod .refine() on agent_card_json field
- Refactor formatZodError with recursive flatMap and deduplication
- Update remote agents documentation for oauth type naming
- Add comprehensive test coverage for all agentCardJson code paths

# Conflicts:
#	packages/core/src/agents/agentLoader.ts
2026-03-26 14:03:52 -04:00
gemini-cli-robot 8e2629759d chore(release): v0.36.0-preview.3 2026-03-25 20:53:49 +00:00
Tommaso Sciortino 146442f2a2 Revert "perf(cli): optimize --version startup time (#23671)"
This reverts commit 139cc7b97c.
2026-03-25 13:22:22 -07:00
Adib234 a3a3e66922 fix(policy): relax write_file argsPattern in plan mode to allow paths without session ID (#23695) 2026-03-25 14:06:21 -04:00
Gal Zahavi d4555a473e fix(core): conditionally expose additional_permissions in shell tool (#23729)
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2026-03-25 10:36:46 -07:00
matt korwel cdac58fe82 fix(test): move flaky ctrl-c-exit test to non-blocking suite (#23732) 2026-03-25 10:35:57 -07:00
gemini-cli-robot 4a95ab62d4 fix(patch): cherry-pick 055ff92 to release/v0.36.0-preview.0-pr-23672 to patch version v0.36.0-preview.0 and create version 0.36.0-preview.1 (#23723)
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
2026-03-24 20:07:02 -04:00
gemini-cli-robot 0223181cf4 chore(release): v0.36.0-preview.0 2026-03-24 19:57:36 +00:00
40 changed files with 881 additions and 392 deletions
+1 -1
View File
@@ -1540,7 +1540,7 @@ their corresponding top-level category object in your `settings.json` file.
- **`experimental.enableAgents`** (boolean): - **`experimental.enableAgents`** (boolean):
- **Description:** Enable local and remote subagents. - **Description:** Enable local and remote subagents.
- **Default:** `false` - **Default:** `true`
- **Requires restart:** Yes - **Requires restart:** Yes
- **`experimental.worktrees`** (boolean): - **`experimental.worktrees`** (boolean):
-6
View File
@@ -63,9 +63,6 @@ describe.skipIf(!chromeAvailable)('browser-policy', () => {
rig.setup('browser-policy-skip-confirmation', { rig.setup('browser-policy-skip-confirmation', {
fakeResponsesPath: join(__dirname, 'browser-policy.responses'), fakeResponsesPath: join(__dirname, 'browser-policy.responses'),
settings: { settings: {
experimental: {
enableAgents: true,
},
agents: { agents: {
overrides: { overrides: {
browser_agent: { browser_agent: {
@@ -183,9 +180,6 @@ priority = 200
rig.setup('browser-session-warning', { rig.setup('browser-session-warning', {
fakeResponsesPath: join(__dirname, 'browser-agent.cleanup.responses'), fakeResponsesPath: join(__dirname, 'browser-agent.cleanup.responses'),
settings: { settings: {
experimental: {
enableAgents: true,
},
general: { general: {
enableAutoUpdateNotification: false, enableAutoUpdateNotification: false,
}, },
+2 -2
View File
@@ -6,9 +6,9 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as os from 'node:os'; 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; let rig: TestRig;
beforeEach(() => { beforeEach(() => {
+92 -58
View File
@@ -4,10 +4,8 @@
* SPDX-License-Identifier: Apache-2.0 * 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 { 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', () => { describe('Plan Mode', () => {
let rig: TestRig; 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({ const result = await rig.run({
approvalMode: 'plan', approvalMode: 'plan',
stdin: args: 'Please list the files in the current directory, and then attempt to create a new file named "denied.txt" using a shell command.',
'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 toolLogs = rig.readToolLogs();
const lsLog = toolLogs.find((l) => l.toolRequest.name === 'list_directory'); const lsLog = toolLogs.find((l) => l.toolRequest.name === 'list_directory');
expect( const shellLog = toolLogs.find(
toolLogs.find((l) => l.toolRequest.name === 'run_shell_command'), (l) => l.toolRequest.name === 'run_shell_command',
).toBeUndefined(); );
expect(lsLog, 'Expected list_directory to be called').toBeDefined();
expect(lsLog?.toolRequest.success).toBe(true); expect(lsLog?.toolRequest.success).toBe(true);
expect(
shellLog,
'Expected run_shell_command to be blocked (not even called)',
).toBeUndefined();
checkModelOutputContent(result, { checkModelOutputContent(result, {
expectedContent: ['Plan Mode', 'read-only'], expectedContent: ['Plan Mode', 'read-only'],
@@ -84,23 +78,11 @@ describe('Plan Mode', () => {
}, },
}); });
// Disable the interactive terminal setup prompt in tests await rig.run({
writeFileSync(
join(rig.homeDir!, GEMINI_DIR, 'state.json'),
JSON.stringify({ terminalSetupPromptShown: true }, null, 2),
);
const run = await rig.runInteractive({
approvalMode: 'plan', 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 toolLogs = rig.readToolLogs();
const planWrite = toolLogs.find( const planWrite = toolLogs.find(
(l) => (l) =>
@@ -108,7 +90,25 @@ describe('Plan Mode', () => {
l.toolRequest.args.includes('plans') && l.toolRequest.args.includes('plans') &&
l.toolRequest.args.includes('plan.md'), 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 () => { 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 await rig.run({
writeFileSync(
join(rig.homeDir!, GEMINI_DIR, 'state.json'),
JSON.stringify({ terminalSetupPromptShown: true }, null, 2),
);
const run = await rig.runInteractive({
approvalMode: 'plan', 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 toolLogs = rig.readToolLogs();
const writeLog = toolLogs.find( const writeLog = toolLogs.find(
(l) => (l) =>
@@ -151,10 +143,11 @@ describe('Plan Mode', () => {
l.toolRequest.args.includes('hello.txt'), 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) { 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({ await rig.run({
approvalMode: 'default', approvalMode: 'default',
stdin: args: 'I want to perform a complex refactoring. Please enter plan mode so we can design it first.',
'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 toolLogs = rig.readToolLogs();
const enterLog = toolLogs.find( const enterLog = toolLogs.find(
(l) => l.toolRequest.name === 'enter_plan_mode', (l) => l.toolRequest.name === 'enter_plan_mode',
); );
expect(enterLog, 'Expected enter_plan_mode to be called').toBeDefined();
expect(enterLog?.toolRequest.success).toBe(true); 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);
});
}); });
+9 -9
View File
@@ -1,12 +1,12 @@
{ {
"name": "@google/gemini-cli", "name": "@google/gemini-cli",
"version": "0.36.0-nightly.20260317.2f90b4653", "version": "0.36.0-preview.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@google/gemini-cli", "name": "@google/gemini-cli",
"version": "0.36.0-nightly.20260317.2f90b4653", "version": "0.36.0-preview.3",
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
], ],
@@ -17413,7 +17413,7 @@
}, },
"packages/a2a-server": { "packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server", "name": "@google/gemini-cli-a2a-server",
"version": "0.36.0-nightly.20260317.2f90b4653", "version": "0.36.0-preview.3",
"dependencies": { "dependencies": {
"@a2a-js/sdk": "0.3.11", "@a2a-js/sdk": "0.3.11",
"@google-cloud/storage": "^7.16.0", "@google-cloud/storage": "^7.16.0",
@@ -17528,7 +17528,7 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@google/gemini-cli", "name": "@google/gemini-cli",
"version": "0.36.0-nightly.20260317.2f90b4653", "version": "0.36.0-preview.3",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "^0.16.1", "@agentclientprotocol/sdk": "^0.16.1",
@@ -17700,7 +17700,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@google/gemini-cli-core", "name": "@google/gemini-cli-core",
"version": "0.36.0-nightly.20260317.2f90b4653", "version": "0.36.0-preview.3",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@a2a-js/sdk": "0.3.11", "@a2a-js/sdk": "0.3.11",
@@ -17966,7 +17966,7 @@
}, },
"packages/devtools": { "packages/devtools": {
"name": "@google/gemini-cli-devtools", "name": "@google/gemini-cli-devtools",
"version": "0.36.0-nightly.20260317.2f90b4653", "version": "0.36.0-preview.3",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"ws": "^8.16.0" "ws": "^8.16.0"
@@ -17981,7 +17981,7 @@
}, },
"packages/sdk": { "packages/sdk": {
"name": "@google/gemini-cli-sdk", "name": "@google/gemini-cli-sdk",
"version": "0.36.0-nightly.20260317.2f90b4653", "version": "0.36.0-preview.3",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@google/gemini-cli-core": "file:../core", "@google/gemini-cli-core": "file:../core",
@@ -17998,7 +17998,7 @@
}, },
"packages/test-utils": { "packages/test-utils": {
"name": "@google/gemini-cli-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", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@google/gemini-cli-core": "file:../core", "@google/gemini-cli-core": "file:../core",
@@ -18015,7 +18015,7 @@
}, },
"packages/vscode-ide-companion": { "packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion", "name": "gemini-cli-vscode-ide-companion",
"version": "0.36.0-nightly.20260317.2f90b4653", "version": "0.36.0-preview.3",
"license": "LICENSE", "license": "LICENSE",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0", "@modelcontextprotocol/sdk": "^1.23.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@google/gemini-cli", "name": "@google/gemini-cli",
"version": "0.36.0-nightly.20260317.2f90b4653", "version": "0.36.0-preview.3",
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
}, },
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git" "url": "git+https://github.com/google-gemini/gemini-cli.git"
}, },
"config": { "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": { "scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js", "start": "cross-env NODE_ENV=development node scripts/start.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@google/gemini-cli-a2a-server", "name": "@google/gemini-cli-a2a-server",
"version": "0.36.0-nightly.20260317.2f90b4653", "version": "0.36.0-preview.3",
"description": "Gemini CLI A2A Server", "description": "Gemini CLI A2A Server",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -29,6 +29,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
await importOriginal<typeof import('@google/gemini-cli-core')>(); await importOriginal<typeof import('@google/gemini-cli-core')>();
return { return {
...actual, ...actual,
PRIORITY_YOLO_ALLOW_ALL: 998,
Config: vi.fn().mockImplementation((params) => { Config: vi.fn().mockImplementation((params) => {
const mockConfig = { const mockConfig = {
...params, ...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); await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith( expect(Config).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
enableAgents: false, enableAgents: true,
}), }),
); );
}); });
+1 -1
View File
@@ -128,7 +128,7 @@ export async function loadConfig(
interactive: !isHeadlessMode(), interactive: !isHeadlessMode(),
enableInteractiveShell: !isHeadlessMode(), enableInteractiveShell: !isHeadlessMode(),
ptyInfo: 'auto', ptyInfo: 'auto',
enableAgents: settings.experimental?.enableAgents ?? false, enableAgents: settings.experimental?.enableAgents ?? true,
}; };
const fileService = new FileDiscoveryService(workspaceDir, { const fileService = new FileDiscoveryService(workspaceDir, {
+5 -21
View File
@@ -6,19 +6,12 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
// --- Fast Path for Version --- import { main } from './src/gemini.js';
// We check for version flags at the very top to avoid loading any heavy dependencies. import { FatalError, writeToStderr } from '@google/gemini-cli-core';
// process.env.CLI_VERSION is defined during the build process by esbuild. import { runExitCleanup } from './src/utils/cleanup.js';
if (process.argv.includes('--version') || process.argv.includes('-v')) {
console.log(process.env['CLI_VERSION'] || 'unknown');
process.exit(0);
}
// --- Global Entry Point --- // --- Global Entry Point ---
let writeToStderrFn: (message: string) => void = (msg) =>
process.stderr.write(msg);
// Suppress known race condition error in node-pty on Windows // Suppress known race condition error in node-pty on Windows
// Tracking bug: https://github.com/microsoft/node-pty/issues/827 // Tracking bug: https://github.com/microsoft/node-pty/issues/827
process.on('uncaughtException', (error) => { 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, // For other errors, we rely on the default behavior, but since we attached a listener,
// we must manually replicate it. // we must manually replicate it.
if (error instanceof Error) { if (error instanceof Error) {
writeToStderrFn(error.stack + '\n'); writeToStderr(error.stack + '\n');
} else { } else {
writeToStderrFn(String(error) + '\n'); writeToStderr(String(error) + '\n');
} }
process.exit(1); 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) => { main().catch(async (error) => {
// Set a timeout to force exit if cleanup hangs // Set a timeout to force exit if cleanup hangs
const cleanupTimeout = setTimeout(() => { const cleanupTimeout = setTimeout(() => {
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@google/gemini-cli", "name": "@google/gemini-cli",
"version": "0.36.0-nightly.20260317.2f90b4653", "version": "0.36.0-preview.3",
"description": "Gemini CLI", "description": "Gemini CLI",
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {
@@ -27,7 +27,7 @@
"dist" "dist"
], ],
"config": { "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": { "dependencies": {
"@agentclientprotocol/sdk": "^0.16.1", "@agentclientprotocol/sdk": "^0.16.1",
@@ -400,7 +400,7 @@ describe('SettingsSchema', () => {
expect(setting).toBeDefined(); expect(setting).toBeDefined();
expect(setting.type).toBe('boolean'); expect(setting.type).toBe('boolean');
expect(setting.category).toBe('Experimental'); expect(setting.category).toBe('Experimental');
expect(setting.default).toBe(false); expect(setting.default).toBe(true);
expect(setting.requiresRestart).toBe(true); expect(setting.requiresRestart).toBe(true);
expect(setting.showInDialog).toBe(false); expect(setting.showInDialog).toBe(false);
expect(setting.description).toBe('Enable local and remote subagents.'); expect(setting.description).toBe('Enable local and remote subagents.');
+1 -1
View File
@@ -1932,7 +1932,7 @@ const SETTINGS_SCHEMA = {
label: 'Enable Agents', label: 'Enable Agents',
category: 'Experimental', category: 'Experimental',
requiresRestart: true, requiresRestart: true,
default: false, default: true,
description: 'Enable local and remote subagents.', description: 'Enable local and remote subagents.',
showInDialog: false, showInDialog: false,
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@google/gemini-cli-core", "name": "@google/gemini-cli-core",
"version": "0.36.0-nightly.20260317.2f90b4653", "version": "0.36.0-preview.3",
"description": "Gemini CLI Core", "description": "Gemini CLI Core",
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {
@@ -128,7 +128,10 @@ describe('A2AClientManager', () => {
describe('getInstance / dispatcher initialization', () => { describe('getInstance / dispatcher initialization', () => {
it('should use UndiciAgent when no proxy is configured', async () => { 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 const resolverOptions = vi.mocked(DefaultAgentCardResolver).mock
.calls[0][0]; .calls[0][0];
@@ -153,7 +156,10 @@ describe('A2AClientManager', () => {
} as Config; } as Config;
manager = new A2AClientManager(mockConfigWithProxy); 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 const resolverOptions = vi.mocked(DefaultAgentCardResolver).mock
.calls[0][0]; .calls[0][0];
@@ -172,28 +178,40 @@ describe('A2AClientManager', () => {
describe('loadAgent', () => { describe('loadAgent', () => {
it('should create and cache an A2AClient', async () => { it('should create and cache an A2AClient', async () => {
const agentCard = await manager.loadAgent( const agentCard = await manager.loadAgent('TestAgent', {
'TestAgent', type: 'url',
'http://test.agent/card', url: 'http://test.agent/card',
); });
expect(manager.getAgentCard('TestAgent')).toBe(agentCard); expect(manager.getAgentCard('TestAgent')).toBe(agentCard);
expect(manager.getClient('TestAgent')).toBeDefined(); expect(manager.getClient('TestAgent')).toBeDefined();
}); });
it('should configure ClientFactory with REST, JSON-RPC, and gRPC transports', async () => { 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(); expect(ClientFactoryOptions.createFrom).toHaveBeenCalled();
}); });
it('should throw an error if an agent with the same name is already loaded', async () => { 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( 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."); ).rejects.toThrow("Agent with name 'TestAgent' is already loaded.");
}); });
it('should use native fetch by default', async () => { 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(); expect(createAuthenticatingFetchWithRetry).not.toHaveBeenCalled();
}); });
@@ -204,7 +222,7 @@ describe('A2AClientManager', () => {
}; };
await manager.loadAgent( await manager.loadAgent(
'TestAgent', 'TestAgent',
'http://test.agent/card', { type: 'url', url: 'http://test.agent/card' },
customAuthHandler as unknown as AuthenticationHandler, customAuthHandler as unknown as AuthenticationHandler,
); );
@@ -221,7 +239,7 @@ describe('A2AClientManager', () => {
}; };
await manager.loadAgent( await manager.loadAgent(
'AuthCardAgent', 'AuthCardAgent',
'http://authcard.agent/card', { type: 'url', url: 'http://authcard.agent/card' },
customAuthHandler as unknown as AuthenticationHandler, customAuthHandler as unknown as AuthenticationHandler,
); );
@@ -252,7 +270,7 @@ describe('A2AClientManager', () => {
await manager.loadAgent( await manager.loadAgent(
'AuthCardAgent401', 'AuthCardAgent401',
'http://authcard.agent/card', { type: 'url', url: 'http://authcard.agent/card' },
customAuthHandler as unknown as AuthenticationHandler, customAuthHandler as unknown as AuthenticationHandler,
); );
@@ -267,19 +285,65 @@ describe('A2AClientManager', () => {
}); });
it('should log a debug message upon loading an agent', async () => { 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(debugLogger.debug).toHaveBeenCalledWith(
expect.stringContaining("Loaded agent 'TestAgent'"), expect.stringContaining("Loaded agent 'TestAgent'"),
); );
}); });
it('should clear the cache', async () => { 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(); manager.clearCache();
expect(manager.getAgentCard('TestAgent')).toBeUndefined(); expect(manager.getAgentCard('TestAgent')).toBeUndefined();
expect(manager.getClient('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 () => { it('should throw if resolveAgentCard fails', async () => {
const resolverInstance = { const resolverInstance = {
resolve: vi.fn().mockRejectedValue(new Error('Resolution failed')), resolve: vi.fn().mockRejectedValue(new Error('Resolution failed')),
@@ -289,7 +353,10 @@ describe('A2AClientManager', () => {
); );
await expect( await expect(
manager.loadAgent('FailAgent', 'http://fail.agent'), manager.loadAgent('FailAgent', {
type: 'url',
url: 'http://fail.agent',
}),
).rejects.toThrow('Resolution failed'); ).rejects.toThrow('Resolution failed');
}); });
@@ -304,7 +371,10 @@ describe('A2AClientManager', () => {
); );
await expect( await expect(
manager.loadAgent('FailAgent', 'http://fail.agent'), manager.loadAgent('FailAgent', {
type: 'url',
url: 'http://fail.agent',
}),
).rejects.toThrow('Factory failed'); ).rejects.toThrow('Factory failed');
}); });
}); });
@@ -318,7 +388,10 @@ describe('A2AClientManager', () => {
describe('sendMessageStream', () => { describe('sendMessageStream', () => {
beforeEach(async () => { 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 () => { it('should send a message and return a stream', async () => {
@@ -433,7 +506,10 @@ describe('A2AClientManager', () => {
describe('getTask', () => { describe('getTask', () => {
beforeEach(async () => { 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 () => { it('should get a task from the correct agent', async () => {
@@ -462,7 +538,10 @@ describe('A2AClientManager', () => {
describe('cancelTask', () => { describe('cancelTask', () => {
beforeEach(async () => { 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 () => { it('should cancel a task on the correct agent', async () => {
+22 -4
View File
@@ -26,6 +26,7 @@ import * as grpc from '@grpc/grpc-js';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { Agent as UndiciAgent, ProxyAgent } from 'undici'; import { Agent as UndiciAgent, ProxyAgent } from 'undici';
import { normalizeAgentCard } from './a2aUtils.js'; import { normalizeAgentCard } from './a2aUtils.js';
import type { AgentCardLoadOptions } from './types.js';
import type { Config } from '../config/config.js'; import type { Config } from '../config/config.js';
import { debugLogger } from '../utils/debugLogger.js'; import { debugLogger } from '../utils/debugLogger.js';
import { classifyAgentError } from './a2a-errors.js'; import { classifyAgentError } from './a2a-errors.js';
@@ -85,7 +86,7 @@ export class A2AClientManager {
*/ */
async loadAgent( async loadAgent(
name: string, name: string,
agentCardUrl: string, options: AgentCardLoadOptions,
authHandler?: AuthenticationHandler, authHandler?: AuthenticationHandler,
): Promise<AgentCard> { ): Promise<AgentCard> {
if (this.clients.has(name) && this.agentCards.has(name)) { if (this.clients.has(name) && this.agentCards.has(name)) {
@@ -119,7 +120,24 @@ export class A2AClientManager {
}; };
const resolver = new DefaultAgentCardResolver({ fetchImpl: cardFetch }); 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 // TODO: Remove normalizeAgentCard once @a2a-js/sdk handles
// proto field name aliases (supportedInterfaces → additionalInterfaces, // proto field name aliases (supportedInterfaces → additionalInterfaces,
// protocolBinding → transport). // protocolBinding → transport).
@@ -153,12 +171,12 @@ export class A2AClientManager {
this.agentCards.set(name, agentCard); this.agentCards.set(name, agentCard);
debugLogger.debug( debugLogger.debug(
`[A2AClientManager] Loaded agent '${name}' from ${agentCardUrl}`, `[A2AClientManager] Loaded agent '${name}' from ${urlIdentifier}`,
); );
return agentCard; return agentCard;
} catch (error: unknown) { } catch (error: unknown) {
throw classifyAgentError(name, agentCardUrl, error); throw classifyAgentError(name, urlIdentifier, error);
} }
} }
@@ -19,6 +19,9 @@ import {
DEFAULT_MAX_TIME_MINUTES, DEFAULT_MAX_TIME_MINUTES,
DEFAULT_MAX_TURNS, DEFAULT_MAX_TURNS,
type LocalAgentDefinition, type LocalAgentDefinition,
type RemoteAgentDefinition,
getAgentCardLoadOptions,
getRemoteAgentTargetUrl,
} from './types.js'; } from './types.js';
describe('loader', () => { 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 () => { it('should throw AgentLoadError if agent name is not a valid slug', async () => {
const filePath = await writeAgentMarkdown(`--- const filePath = await writeAgentMarkdown(`---
name: Invalid Name With Spaces name: Invalid Name With Spaces
@@ -242,6 +314,99 @@ Body`);
/Name must be a valid slug/, /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', () => { 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', () => { 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();
});
}); });
}); });
+165 -194
View File
@@ -12,6 +12,7 @@ import * as crypto from 'node:crypto';
import { z } from 'zod'; import { z } from 'zod';
import { import {
type AgentDefinition, type AgentDefinition,
type RemoteAgentDefinition,
DEFAULT_MAX_TURNS, DEFAULT_MAX_TURNS,
DEFAULT_MAX_TIME_MINUTES, DEFAULT_MAX_TIME_MINUTES,
} from './types.js'; } from './types.js';
@@ -21,79 +22,6 @@ import { isValidToolName } from '../tools/tool-names.js';
import { FRONTMATTER_REGEX } from '../skills/skillLoader.js'; import { FRONTMATTER_REGEX } from '../skills/skillLoader.js';
import { getErrorMessage } from '../utils/errors.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. * Error thrown when an agent definition is invalid or cannot be loaded.
*/ */
@@ -159,15 +87,13 @@ const localAgentSchema = z
}) })
.strict(); .strict();
/** type FrontmatterLocalAgentDefinition = z.infer<typeof localAgentSchema> & {
* Base fields shared by all auth configs. system_prompt: string;
*/ };
// Base fields shared by all auth configs.
const baseAuthFields = {}; const baseAuthFields = {};
/**
* API Key auth schema.
* Supports sending key in header, query parameter, or cookie.
*/
const apiKeyAuthSchema = z.object({ const apiKeyAuthSchema = z.object({
...baseAuthFields, ...baseAuthFields,
type: z.literal('apiKey'), type: z.literal('apiKey'),
@@ -175,11 +101,6 @@ const apiKeyAuthSchema = z.object({
name: z.string().optional(), 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({ const httpAuthSchema = z.object({
...baseAuthFields, ...baseAuthFields,
type: z.literal('http'), type: z.literal('http'),
@@ -190,19 +111,12 @@ const httpAuthSchema = z.object({
value: z.string().min(1).optional(), value: z.string().min(1).optional(),
}); });
/**
* Google Credentials auth schema.
*/
const googleCredentialsAuthSchema = z.object({ const googleCredentialsAuthSchema = z.object({
...baseAuthFields, ...baseAuthFields,
type: z.literal('google-credentials'), type: z.literal('google-credentials'),
scopes: z.array(z.string()).optional(), 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({ const oauth2AuthSchema = z.object({
...baseAuthFields, ...baseAuthFields,
type: z.literal('oauth'), type: z.literal('oauth'),
@@ -222,18 +136,16 @@ const authConfigSchema = z
]) ])
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
if (data.type === 'http') { if (data.type === 'http') {
if (data.value) { if (data.value) return;
// Raw mode - only scheme and value are needed if (data.scheme === 'Bearer') {
return; if (!data.token) {
} ctx.addIssue({
if (data.scheme === 'Bearer' && !data.token) { code: z.ZodIssueCode.custom,
ctx.addIssue({ message: 'Bearer scheme requires "token"',
code: z.ZodIssueCode.custom, path: ['token'],
message: 'Bearer scheme requires "token"', });
path: ['token'], }
}); } else if (data.scheme === 'Basic') {
}
if (data.scheme === 'Basic') {
if (!data.username) { if (!data.username) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
@@ -248,55 +160,127 @@ const authConfigSchema = z
path: ['password'], path: ['password'],
}); });
} }
} else {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `HTTP scheme "${data.scheme}" requires "value"`,
path: ['value'],
});
} }
} }
}); });
const remoteAgentSchema = z type FrontmatterAuthConfig = z.infer<typeof authConfigSchema>;
.object({
kind: z.literal('remote').optional().default('remote'), const baseRemoteAgentSchema = z.object({
name: nameSchema, kind: z.literal('remote').optional().default('remote'),
description: z.string().optional(), name: nameSchema,
display_name: z.string().optional(), description: z.string().optional(),
display_name: z.string().optional(),
auth: authConfigSchema.optional(),
});
const remoteAgentUrlSchema = baseRemoteAgentSchema
.extend({
agent_card_url: z.string().url(), agent_card_url: z.string().url(),
auth: authConfigSchema.optional(), agent_card_json: z.undefined().optional(),
}) })
.strict(); .strict();
// Use a Zod union to automatically discriminate between local and remote const remoteAgentJsonSchema = baseRemoteAgentSchema
// agent types. .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 = [ const agentUnionOptions = [
{ schema: localAgentSchema, label: 'Local Agent' }, { label: 'Local Agent' },
{ schema: remoteAgentSchema, label: 'Remote Agent' }, { label: 'Remote Agent' },
] as const; { label: 'Remote Agent' },
];
const remoteAgentsListSchema = z.array(remoteAgentSchema); const remoteAgentsListSchema = z.array(remoteAgentSchema);
const markdownFrontmatterSchema = z.union([ const markdownFrontmatterSchema = z.union([
agentUnionOptions[0].schema, localAgentSchema,
agentUnionOptions[1].schema, remoteAgentUrlSchema,
remoteAgentJsonSchema,
]); ]);
function formatZodError(error: z.ZodError, context: string): string { function guessIntendedKind(rawInput: unknown): 'local' | 'remote' | undefined {
const issues = error.issues if (typeof rawInput !== 'object' || rawInput === null) return undefined;
.map((i) => { const input = rawInput as Partial<FrontmatterLocalAgentDefinition> &
// Handle union errors specifically to give better context 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) { if (i.code === z.ZodIssueCode.invalid_union) {
return i.unionErrors return i.unionErrors.flatMap((unionError, index) => {
.map((unionError, index) => { const label = unionPrefix
const label = ? unionPrefix
agentUnionOptions[index]?.label ?? `Agent type #${index + 1}`; : ((agentUnionOptions[index] as { label?: string })?.label ??
const unionIssues = unionError.issues `Branch #${index + 1}`);
.map((u) => `${u.path.join('.')}: ${u.message}`)
.join(', '); if (intendedKind === 'local' && label === 'Remote Agent') return [];
return `(${label}) ${unionIssues}`; if (intendedKind === 'remote' && label === 'Local Agent') return [];
})
.join('\n'); return formatIssues(unionError.issues, label);
});
} }
return `${i.path.join('.')}: ${i.message}`; const prefix = unionPrefix ? `(${unionPrefix}) ` : '';
}) const path = i.path.length > 0 ? `${i.path.join('.')}: ` : '';
.join('\n'); return `${prefix}${path}${i.message}`;
return `${context}:\n${issues}`; });
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) { } catch (error) {
throw new AgentLoadError( throw new AgentLoadError(
filePath, filePath,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion `YAML frontmatter parsing failed: ${getErrorMessage(error)}`,
`YAML frontmatter parsing failed: ${(error as Error).message}`,
); );
} }
@@ -368,7 +351,7 @@ export async function parseAgentMarkdown(
if (!result.success) { if (!result.success) {
throw new AgentLoadError( throw new AgentLoadError(
filePath, 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 // Construct the local agent definition
const agentDef: FrontmatterLocalAgentDefinition = { return [
...frontmatter, {
kind: 'local', ...frontmatter,
system_prompt: body.trim(), kind: 'local',
}; system_prompt: body.trim(),
},
return [agentDef]; ];
} }
/** /**
@@ -403,15 +383,9 @@ export async function parseAgentMarkdown(
function convertFrontmatterAuthToConfig( function convertFrontmatterAuthToConfig(
frontmatter: FrontmatterAuthConfig, frontmatter: FrontmatterAuthConfig,
): A2AAuthConfig { ): A2AAuthConfig {
const base = {};
switch (frontmatter.type) { switch (frontmatter.type) {
case 'apiKey': case 'apiKey':
if (!frontmatter.key) {
throw new Error('Internal error: API key missing after validation.');
}
return { return {
...base,
type: 'apiKey', type: 'apiKey',
key: frontmatter.key, key: frontmatter.key,
name: frontmatter.name, name: frontmatter.name,
@@ -419,20 +393,13 @@ function convertFrontmatterAuthToConfig(
case 'google-credentials': case 'google-credentials':
return { return {
...base,
type: 'google-credentials', type: 'google-credentials',
scopes: frontmatter.scopes, scopes: frontmatter.scopes,
}; };
case 'http': { case 'http':
if (!frontmatter.scheme) {
throw new Error(
'Internal error: HTTP scheme missing after validation.',
);
}
if (frontmatter.value) { if (frontmatter.value) {
return { return {
...base,
type: 'http', type: 'http',
scheme: frontmatter.scheme, scheme: frontmatter.scheme,
value: frontmatter.value, value: frontmatter.value,
@@ -440,40 +407,27 @@ function convertFrontmatterAuthToConfig(
} }
switch (frontmatter.scheme) { switch (frontmatter.scheme) {
case 'Bearer': case 'Bearer':
if (!frontmatter.token) { // Token is required by schema validation
throw new Error(
'Internal error: Bearer token missing after validation.',
);
}
return { return {
...base,
type: 'http', type: 'http',
scheme: 'Bearer', scheme: 'Bearer',
token: frontmatter.token,
token: frontmatter.token!,
}; };
case 'Basic': case 'Basic':
if (!frontmatter.username || !frontmatter.password) { // Username/password are required by schema validation
throw new Error(
'Internal error: Basic auth credentials missing after validation.',
);
}
return { return {
...base,
type: 'http', type: 'http',
scheme: 'Basic', scheme: 'Basic',
username: frontmatter.username, username: frontmatter.username!,
password: frontmatter.password, password: frontmatter.password!,
}; };
default: { default:
// Other IANA schemes without a value should not reach here after validation
throw new Error(`Unknown HTTP scheme: ${frontmatter.scheme}`); throw new Error(`Unknown HTTP scheme: ${frontmatter.scheme}`);
}
} }
}
case 'oauth': case 'oauth':
return { return {
...base,
type: 'oauth2', type: 'oauth2',
client_id: frontmatter.client_id, client_id: frontmatter.client_id,
client_secret: frontmatter.client_secret, client_secret: frontmatter.client_secret,
@@ -483,8 +437,12 @@ function convertFrontmatterAuthToConfig(
}; };
default: { default: {
const exhaustive: never = frontmatter.type; const exhaustive: never = frontmatter;
throw new Error(`Unknown auth type: ${exhaustive}`); 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') { if (markdown.kind === 'remote') {
return { const base: RemoteAgentDefinition = {
kind: 'remote', kind: 'remote',
name: markdown.name, name: markdown.name,
description: markdown.description || '', description: markdown.description || '',
displayName: markdown.display_name, displayName: markdown.display_name,
agentCardUrl: markdown.agent_card_url,
auth: markdown.auth auth: markdown.auth
? convertFrontmatterAuthToConfig(markdown.auth) ? convertFrontmatterAuthToConfig(markdown.auth)
: undefined, : undefined,
inputConfig, inputConfig,
metadata, 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 // If a model is specified, use it. Otherwise, inherit
const modelName = markdown.model || 'inherit'; const modelName = markdown.model || 'inherit';
const mcpServers: Record<string, MCPServerConfig> = {}; 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)) { for (const [name, config] of Object.entries(markdown.mcp_servers)) {
mcpServers[name] = new MCPServerConfig( mcpServers[name] = new MCPServerConfig(
config.command, config.command,
@@ -606,15 +580,13 @@ export async function loadAgentsFromDirectory(
dirEntries = await fs.readdir(dir, { withFileTypes: true }); dirEntries = await fs.readdir(dir, { withFileTypes: true });
} catch (error) { } catch (error) {
// If directory doesn't exist, just return empty // If directory doesn't exist, just return empty
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return result; return result;
} }
result.errors.push( result.errors.push(
new AgentLoadError( new AgentLoadError(
dir, dir,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion `Could not list directory: ${getErrorMessage(error)}`,
`Could not list directory: ${(error as Error).message}`,
), ),
); );
return result; return result;
@@ -644,8 +616,7 @@ export async function loadAgentsFromDirectory(
result.errors.push( result.errors.push(
new AgentLoadError( new AgentLoadError(
filePath, filePath,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion `Unexpected error: ${getErrorMessage(error)}`,
`Unexpected error: ${(error as Error).message}`,
), ),
); );
} }
+1 -1
View File
@@ -596,7 +596,7 @@ describe('AgentRegistry', () => {
}); });
expect(loadAgentSpy).toHaveBeenCalledWith( expect(loadAgentSpy).toHaveBeenCalledWith(
'RemoteAgentWithAuth', 'RemoteAgentWithAuth',
'https://example.com/card', { type: 'url', url: 'https://example.com/card' },
mockHandler, mockHandler,
); );
expect(registry.getDefinition('RemoteAgentWithAuth')).toEqual( expect(registry.getDefinition('RemoteAgentWithAuth')).toEqual(
+14 -4
View File
@@ -4,10 +4,12 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import * as crypto from 'node:crypto';
import { Storage } from '../config/storage.js'; import { Storage } from '../config/storage.js';
import { CoreEvent, coreEvents } from '../utils/events.js'; import { CoreEvent, coreEvents } from '../utils/events.js';
import type { AgentOverride, Config } from '../config/config.js'; import type { AgentOverride, Config } from '../config/config.js';
import type { AgentDefinition, LocalAgentDefinition } from './types.js'; import type { AgentDefinition, LocalAgentDefinition } from './types.js';
import { getAgentCardLoadOptions, getRemoteAgentTargetUrl } from './types.js';
import { loadAgentsFromDirectory } from './agentLoader.js'; import { loadAgentsFromDirectory } from './agentLoader.js';
import { CodebaseInvestigatorAgent } from './codebase-investigator.js'; import { CodebaseInvestigatorAgent } from './codebase-investigator.js';
import { CliHelpAgent } from './cli-help-agent.js'; import { CliHelpAgent } from './cli-help-agent.js';
@@ -162,7 +164,14 @@ export class AgentRegistry {
if (!agent.metadata) { if (!agent.metadata) {
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) { if (!agent.metadata?.hash) {
@@ -443,12 +452,13 @@ export class AgentRegistry {
); );
return; return;
} }
const targetUrl = getRemoteAgentTargetUrl(remoteDef);
let authHandler: AuthenticationHandler | undefined; let authHandler: AuthenticationHandler | undefined;
if (definition.auth) { if (definition.auth) {
const provider = await A2AAuthProviderFactory.create({ const provider = await A2AAuthProviderFactory.create({
authConfig: definition.auth, authConfig: definition.auth,
agentName: definition.name, agentName: definition.name,
targetUrl: definition.agentCardUrl, targetUrl,
agentCardUrl: remoteDef.agentCardUrl, agentCardUrl: remoteDef.agentCardUrl,
}); });
if (!provider) { if (!provider) {
@@ -461,7 +471,7 @@ export class AgentRegistry {
const agentCard = await clientManager.loadAgent( const agentCard = await clientManager.loadAgent(
remoteDef.name, remoteDef.name,
remoteDef.agentCardUrl, getAgentCardLoadOptions(remoteDef),
authHandler, authHandler,
); );
@@ -515,7 +525,7 @@ export class AgentRegistry {
if (this.config.getDebugMode()) { if (this.config.getDebugMode()) {
debugLogger.log( 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); this.agents.set(definition.name, definition);
@@ -189,7 +189,7 @@ describe('RemoteAgentInvocation', () => {
expect(mockClientManager.loadAgent).toHaveBeenCalledWith( expect(mockClientManager.loadAgent).toHaveBeenCalledWith(
'test-agent', 'test-agent',
'http://test-agent/card', { type: 'url', url: 'http://test-agent/card' },
undefined, undefined,
); );
}); });
@@ -240,7 +240,7 @@ describe('RemoteAgentInvocation', () => {
}); });
expect(mockClientManager.loadAgent).toHaveBeenCalledWith( expect(mockClientManager.loadAgent).toHaveBeenCalledWith(
'test-agent', 'test-agent',
'http://test-agent/card', { type: 'url', url: 'http://test-agent/card' },
mockHandler, mockHandler,
); );
}); });
@@ -266,11 +266,10 @@ describe('RemoteAgentInvocation', () => {
); );
const result = await invocation.execute(new AbortController().signal); const result = await invocation.execute(new AbortController().signal);
expect(result.returnDisplay).toMatchObject({ expect(result.returnDisplay).toMatchObject({ state: 'error' });
result: expect.stringContaining( expect((result.returnDisplay as SubagentProgress).result).toContain(
"Failed to create auth provider for agent 'test-agent'", "Failed to create auth provider for agent 'test-agent'",
), );
});
}); });
it('should not load the agent if already present', async () => { it('should not load the agent if already present', async () => {
@@ -16,6 +16,8 @@ import {
type RemoteAgentDefinition, type RemoteAgentDefinition,
type AgentInputs, type AgentInputs,
type SubagentProgress, type SubagentProgress,
getAgentCardLoadOptions,
getRemoteAgentTargetUrl,
} from './types.js'; } from './types.js';
import { type AgentLoopContext } from '../config/agent-loop-context.js'; import { type AgentLoopContext } from '../config/agent-loop-context.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js';
@@ -92,10 +94,11 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
} }
if (this.definition.auth) { if (this.definition.auth) {
const targetUrl = getRemoteAgentTargetUrl(this.definition);
const provider = await A2AAuthProviderFactory.create({ const provider = await A2AAuthProviderFactory.create({
authConfig: this.definition.auth, authConfig: this.definition.auth,
agentName: this.definition.name, agentName: this.definition.name,
targetUrl: this.definition.agentCardUrl, targetUrl,
agentCardUrl: this.definition.agentCardUrl, agentCardUrl: this.definition.agentCardUrl,
}); });
if (!provider) { if (!provider) {
@@ -162,7 +165,7 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
if (!this.clientManager.getClient(this.definition.name)) { if (!this.clientManager.getClient(this.definition.name)) {
await this.clientManager.loadAgent( await this.clientManager.loadAgent(
this.definition.name, this.definition.name,
this.definition.agentCardUrl, getAgentCardLoadOptions(this.definition),
authHandler, authHandler,
); );
} }
+65 -2
View File
@@ -13,6 +13,7 @@ import type { AnyDeclarativeTool } from '../tools/tools.js';
import { type z } from 'zod'; import { type z } from 'zod';
import type { ModelConfig } from '../services/modelConfigService.js'; import type { ModelConfig } from '../services/modelConfigService.js';
import type { AnySchema } from 'ajv'; import type { AnySchema } from 'ajv';
import type { AgentCard } from '@a2a-js/sdk';
import type { A2AAuthConfig } from './auth-provider/types.js'; import type { A2AAuthConfig } from './auth-provider/types.js';
import type { MCPServerConfig } from '../config/config.js'; import type { MCPServerConfig } from '../config/config.js';
@@ -128,6 +129,62 @@ export function isToolActivityError(data: unknown): boolean {
* The base definition for an agent. * The base definition for an agent.
* @template TOutput The specific Zod schema for the agent's final output object. * @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< export interface BaseAgentDefinition<
TOutput extends z.ZodTypeAny = z.ZodUnknown, TOutput extends z.ZodTypeAny = z.ZodUnknown,
> { > {
@@ -172,11 +229,10 @@ export interface LocalAgentDefinition<
processOutput?: (output: z.infer<TOutput>) => string; processOutput?: (output: z.infer<TOutput>) => string;
} }
export interface RemoteAgentDefinition< export interface BaseRemoteAgentDefinition<
TOutput extends z.ZodTypeAny = z.ZodUnknown, TOutput extends z.ZodTypeAny = z.ZodUnknown,
> extends BaseAgentDefinition<TOutput> { > extends BaseAgentDefinition<TOutput> {
kind: 'remote'; kind: 'remote';
agentCardUrl: string;
/** The user-provided description, before any remote card merging. */ /** The user-provided description, before any remote card merging. */
originalDescription?: string; originalDescription?: string;
/** /**
@@ -187,6 +243,13 @@ export interface RemoteAgentDefinition<
auth?: A2AAuthConfig; 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> = export type AgentDefinition<TOutput extends z.ZodTypeAny = z.ZodUnknown> =
| LocalAgentDefinition<TOutput> | LocalAgentDefinition<TOutput>
| RemoteAgentDefinition<TOutput>; | RemoteAgentDefinition<TOutput>;
+1 -1
View File
@@ -1027,7 +1027,7 @@ export class Config implements McpContext, AgentLoopContext {
this.model = params.model; this.model = params.model;
this.disableLoopDetection = params.disableLoopDetection ?? false; this.disableLoopDetection = params.disableLoopDetection ?? false;
this._activeModel = params.model; this._activeModel = params.model;
this.enableAgents = params.enableAgents ?? false; this.enableAgents = params.enableAgents ?? true;
this.agents = params.agents ?? {}; this.agents = params.agents ?? {};
this.disableLLMCorrection = params.disableLLMCorrection ?? true; this.disableLLMCorrection = params.disableLLMCorrection ?? true;
this.planEnabled = params.plan ?? true; this.planEnabled = params.plan ?? true;
+5 -1
View File
@@ -88,7 +88,11 @@ export * from './utils/approvalModeUtils.js';
export * from './utils/fileDiffUtils.js'; export * from './utils/fileDiffUtils.js';
export * from './utils/retry.js'; export * from './utils/retry.js';
export * from './utils/shell-utils.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/tool-utils.js';
export * from './utils/terminalSerializer.js'; export * from './utils/terminalSerializer.js';
export * from './utils/systemEncoding.js'; export * from './utils/systemEncoding.js';
@@ -110,6 +110,8 @@ priority = 70
modes = ["plan"] modes = ["plan"]
# Allow write_file and replace for .md files in the plans directory (cross-platform) # 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]] [[rule]]
toolName = ["write_file", "replace"] toolName = ["write_file", "replace"]
decision = "allow" decision = "allow"
@@ -117,6 +119,14 @@ priority = 70
modes = ["plan"] modes = ["plan"]
argsPattern = "\\x00\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+tmp[\\\\/]+[\\w-]+[\\\\/]+[\\w-]+[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"\\x00" 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. # Explicitly Deny other write operations in Plan mode with a clear message.
[[rule]] [[rule]]
toolName = ["write_file", "replace"] 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 { return {
decision: this.applyNonInteractiveMode(decision), decision: this.applyNonInteractiveMode(decision),
rule: matchedRule, rule: matchedRule,
@@ -233,13 +233,19 @@ export {
export function getShellDefinition( export function getShellDefinition(
enableInteractiveShell: boolean, enableInteractiveShell: boolean,
enableEfficiency: boolean, enableEfficiency: boolean,
enableToolSandboxing: boolean = false,
): ToolDefinition { ): ToolDefinition {
return { return {
base: getShellDeclaration(enableInteractiveShell, enableEfficiency), base: getShellDeclaration(
enableInteractiveShell,
enableEfficiency,
enableToolSandboxing,
),
overrides: (modelId) => overrides: (modelId) =>
getToolSet(modelId).run_shell_command( getToolSet(modelId).run_shell_command(
enableInteractiveShell, enableInteractiveShell,
enableEfficiency, enableEfficiency,
enableToolSandboxing,
), ),
}; };
} }
@@ -69,7 +69,7 @@ describe('coreTools snapshots for specific models', () => {
{ name: 'list_directory', definition: LS_DEFINITION }, { name: 'list_directory', definition: LS_DEFINITION },
{ {
name: 'run_shell_command', name: 'run_shell_command',
definition: getShellDefinition(true, true), definition: getShellDefinition(true, true, true),
}, },
{ name: 'replace', definition: EDIT_DEFINITION }, { name: 'replace', definition: EDIT_DEFINITION },
{ name: 'google_web_search', definition: WEB_SEARCH_DEFINITION }, { name: 'google_web_search', definition: WEB_SEARCH_DEFINITION },
@@ -81,6 +81,7 @@ export function getCommandDescription(): string {
export function getShellDeclaration( export function getShellDeclaration(
enableInteractiveShell: boolean, enableInteractiveShell: boolean,
enableEfficiency: boolean, enableEfficiency: boolean,
enableToolSandboxing: boolean = false,
): FunctionDeclaration { ): FunctionDeclaration {
return { return {
name: SHELL_TOOL_NAME, name: SHELL_TOOL_NAME,
@@ -110,35 +111,39 @@ export function getShellDeclaration(
description: 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.', '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]: { ...(enableToolSandboxing
type: 'object', ? {
description: [PARAM_ADDITIONAL_PERMISSIONS]: {
'Sandbox permissions for the command. Use this to request additional sandboxed filesystem or network permissions if a previous command failed with "Operation not permitted".', type: 'object',
properties: { description:
network: { 'Sandbox permissions for the command. Use this to request additional sandboxed filesystem or network permissions if a previous command failed with "Operation not permitted".',
type: 'boolean', properties: {
description: network: {
'Set to true to enable network access for this command.', type: 'boolean',
}, description:
fileSystem: { 'Set to true to enable network access for this command.',
type: 'object', },
properties: { fileSystem: {
read: { type: 'object',
type: 'array', properties: {
items: { type: 'string' }, read: {
description: type: 'array',
'List of additional absolute paths to allow reading.', items: { type: 'string' },
}, description:
write: { 'List of additional absolute paths to allow reading.',
type: 'array', },
items: { type: 'string' }, write: {
description: type: 'array',
'List of additional absolute paths to allow writing.', items: { type: 'string' },
description:
'List of additional absolute paths to allow writing.',
},
},
},
}, },
}, },
}, }
}, : {}),
},
}, },
required: [SHELL_PARAM_COMMAND], required: [SHELL_PARAM_COMMAND],
}, },
@@ -332,8 +332,16 @@ export const DEFAULT_LEGACY_SET: CoreToolSet = {
}, },
}, },
run_shell_command: (enableInteractiveShell, enableEfficiency) => run_shell_command: (
getShellDeclaration(enableInteractiveShell, enableEfficiency), enableInteractiveShell,
enableEfficiency,
enableToolSandboxing,
) =>
getShellDeclaration(
enableInteractiveShell,
enableEfficiency,
enableToolSandboxing,
),
replace: { replace: {
name: EDIT_TOOL_NAME, name: EDIT_TOOL_NAME,
@@ -338,8 +338,16 @@ export const GEMINI_3_SET: CoreToolSet = {
}, },
}, },
run_shell_command: (enableInteractiveShell, enableEfficiency) => run_shell_command: (
getShellDeclaration(enableInteractiveShell, enableEfficiency), enableInteractiveShell,
enableEfficiency,
enableToolSandboxing,
) =>
getShellDeclaration(
enableInteractiveShell,
enableEfficiency,
enableToolSandboxing,
),
replace: { replace: {
name: EDIT_TOOL_NAME, name: EDIT_TOOL_NAME,
@@ -37,6 +37,7 @@ export interface CoreToolSet {
run_shell_command: ( run_shell_command: (
enableInteractiveShell: boolean, enableInteractiveShell: boolean,
enableEfficiency: boolean, enableEfficiency: boolean,
enableToolSandboxing: boolean,
) => FunctionDeclaration; ) => FunctionDeclaration;
replace: FunctionDeclaration; replace: FunctionDeclaration;
google_web_search: FunctionDeclaration; google_web_search: FunctionDeclaration;
+1
View File
@@ -137,6 +137,7 @@ describe('ShellTool', () => {
getShellToolInactivityTimeout: vi.fn().mockReturnValue(1000), getShellToolInactivityTimeout: vi.fn().mockReturnValue(1000),
getEnableInteractiveShell: vi.fn().mockReturnValue(false), getEnableInteractiveShell: vi.fn().mockReturnValue(false),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true), getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
getSandboxEnabled: vi.fn().mockReturnValue(false),
sanitizationConfig: {}, sanitizationConfig: {},
sandboxManager: new NoopSandboxManager(), sandboxManager: new NoopSandboxManager(),
} as unknown as Config; } as unknown as Config;
+2
View File
@@ -696,6 +696,7 @@ export class ShellTool extends BaseDeclarativeTool<
const definition = getShellDefinition( const definition = getShellDefinition(
context.config.getEnableInteractiveShell(), context.config.getEnableInteractiveShell(),
context.config.getEnableShellOutputEfficiency(), context.config.getEnableShellOutputEfficiency(),
context.config.getSandboxEnabled(),
); );
super( super(
ShellTool.Name, ShellTool.Name,
@@ -745,6 +746,7 @@ export class ShellTool extends BaseDeclarativeTool<
const definition = getShellDefinition( const definition = getShellDefinition(
this.context.config.getEnableInteractiveShell(), this.context.config.getEnableInteractiveShell(),
this.context.config.getEnableShellOutputEfficiency(), this.context.config.getEnableShellOutputEfficiency(),
this.context.config.getSandboxEnabled(),
); );
return resolveToolDeclaration(definition, modelId); return resolveToolDeclaration(definition, modelId);
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@google/gemini-cli-devtools", "name": "@google/gemini-cli-devtools",
"version": "0.36.0-nightly.20260317.2f90b4653", "version": "0.36.0-preview.3",
"license": "Apache-2.0", "license": "Apache-2.0",
"type": "module", "type": "module",
"main": "dist/src/index.js", "main": "dist/src/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@google/gemini-cli-sdk", "name": "@google/gemini-cli-sdk",
"version": "0.36.0-nightly.20260317.2f90b4653", "version": "0.36.0-preview.3",
"description": "Gemini CLI SDK", "description": "Gemini CLI SDK",
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@google/gemini-cli-test-utils", "name": "@google/gemini-cli-test-utils",
"version": "0.36.0-nightly.20260317.2f90b4653", "version": "0.36.0-preview.3",
"private": true, "private": true,
"main": "src/index.ts", "main": "src/index.ts",
"license": "Apache-2.0", "license": "Apache-2.0",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "gemini-cli-vscode-ide-companion", "name": "gemini-cli-vscode-ide-companion",
"displayName": "Gemini CLI Companion", "displayName": "Gemini CLI Companion",
"description": "Enable Gemini CLI with direct access to your IDE workspace.", "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", "publisher": "google",
"icon": "assets/icon.png", "icon": "assets/icon.png",
"repository": { "repository": {
+2 -2
View File
@@ -2680,8 +2680,8 @@
"enableAgents": { "enableAgents": {
"title": "Enable Agents", "title": "Enable Agents",
"description": "Enable local and remote subagents.", "description": "Enable local and remote subagents.",
"markdownDescription": "Enable local and remote subagents.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `false`", "markdownDescription": "Enable local and remote subagents.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `true`",
"default": false, "default": true,
"type": "boolean" "type": "boolean"
}, },
"worktrees": { "worktrees": {