Compare commits

...

15 Commits

Author SHA1 Message Date
Adam Weidman b901a7ce99 Merge pull request #23690 2026-03-26 12:12:48 -04:00
Adam Weidman 7a54e166b9 Merge branch 'pr-23743' into manual-preview-release 2026-03-26 11:44:17 -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
Adam Weidman 2421a073c8 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
2026-03-24 23:35:28 -04: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
Adam Weidman 69e95a59d3 Merge remote-tracking branch 'origin/main' into fix/agent-loader-error-formatting
# Conflicts:
#	packages/core/src/agents/agentLoader.ts
2026-03-24 16:20:42 -04:00
gemini-cli-robot 0223181cf4 chore(release): v0.36.0-preview.0 2026-03-24 19:57:36 +00:00
Adam Weidman 6ecb6c5273 Merge remote-tracking branch 'origin/main' into fix/agent-loader-error-formatting 2026-03-24 14:45:13 -04:00
Adam Weidman e48a058d63 test(core): add comprehensive UX tests for agent loading error formatting 2026-03-24 14:37:10 -04:00
Adam Weidman 236249604e docs(core): update remote agents documentation for oauth 2026-03-24 11:54:47 -04:00
Adam Weidman 0cfe304506 refactor(core): use inferred types and discriminated unions for agent loader 2026-03-24 11:31:39 -04:00
21 changed files with 602 additions and 180 deletions
+92 -58
View File
@@ -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);
});
});
+9 -9
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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(() => {
+2 -2
View File
@@ -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 -1
View File
@@ -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 () => {
+22 -4
View File
@@ -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();
});
});
});
+79 -42
View File
@@ -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
+1 -1
View File
@@ -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(
+14 -4
View File
@@ -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,
);
}
+65 -2
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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",
+1 -1
View File
@@ -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": {