feat(core): improve subagent result display (#20378)

This commit is contained in:
joshualitt
2026-03-09 12:20:15 -07:00
committed by GitHub
parent d246315cea
commit a17691f0fc
21 changed files with 925 additions and 238 deletions
@@ -17,6 +17,7 @@
import { randomUUID } from 'node:crypto';
import type { Config } from '../../config/config.js';
import { LocalAgentExecutor } from '../local-executor.js';
import { safeJsonToMarkdown } from '../../utils/markdownUtils.js';
import {
BaseToolInvocation,
type ToolResult,
@@ -414,6 +415,8 @@ export class BrowserAgentInvocation extends BaseToolInvocation<
const output = await executor.run(this.params, signal);
const displayResult = safeJsonToMarkdown(output.result);
const resultContent = `Browser agent finished.
Termination Reason: ${output.terminate_reason}
Result:
@@ -425,7 +428,7 @@ Browser Agent Finished
Termination Reason: ${output.terminate_reason}
Result:
${output.result}
${displayResult}
`;
if (updateOutput) {
+4 -1
View File
@@ -6,6 +6,7 @@
import type { Config } from '../config/config.js';
import { LocalAgentExecutor } from './local-executor.js';
import { safeJsonToMarkdown } from '../utils/markdownUtils.js';
import {
BaseToolInvocation,
type ToolResult,
@@ -245,6 +246,8 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
throw cancelError;
}
const displayResult = safeJsonToMarkdown(output.result);
const resultContent = `Subagent '${this.definition.name}' finished.
Termination Reason: ${output.terminate_reason}
Result:
@@ -256,7 +259,7 @@ Subagent ${this.definition.name} Finished
Termination Reason:\n ${output.terminate_reason}
Result:
${output.result}
${displayResult}
`;
return {
@@ -25,6 +25,7 @@ import { extractIdsFromResponse, A2AResultReassembler } from './a2aUtils.js';
import { GoogleAuth } from 'google-auth-library';
import type { AuthenticationHandler } from '@a2a-js/sdk/client';
import { debugLogger } from '../utils/debugLogger.js';
import { safeJsonToMarkdown } from '../utils/markdownUtils.js';
import type { AnsiOutput } from '../utils/terminalSerializer.js';
import { A2AAuthProviderFactory } from './auth-provider/factory.js';
@@ -222,7 +223,7 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
return {
llmContent: [{ text: finalOutput }],
returnDisplay: finalOutput,
returnDisplay: safeJsonToMarkdown(finalOutput),
};
} catch (error: unknown) {
const partialOutput = reassembler.toString();
@@ -13,6 +13,7 @@ import {
KeychainSchema,
KEYCHAIN_TEST_PREFIX,
} from './keychainTypes.js';
import { isRecord } from '../utils/markdownUtils.js';
/**
* Service for interacting with OS-level secure storage (e.g. keytar).
@@ -111,7 +112,7 @@ export class KeychainService {
private async loadKeychainModule(): Promise<Keychain | null> {
const moduleName = 'keytar';
const module: unknown = await import(moduleName);
const potential = (this.isRecord(module) && module['default']) || module;
const potential = (isRecord(module) && module['default']) || module;
const result = KeychainSchema.safeParse(potential);
if (result.success) {
@@ -126,10 +127,6 @@ export class KeychainService {
return null;
}
private isRecord(obj: unknown): obj is Record<string, unknown> {
return typeof obj === 'object' && obj !== null;
}
// Performs a set-get-delete cycle to verify keychain functionality.
private async isKeychainFunctional(keychain: Keychain): Promise<boolean> {
const testAccount = `${KEYCHAIN_TEST_PREFIX}${crypto.randomBytes(8).toString('hex')}`;
@@ -0,0 +1,128 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { jsonToMarkdown, safeJsonToMarkdown } from './markdownUtils.js';
describe('markdownUtils', () => {
describe('jsonToMarkdown', () => {
it('should handle primitives', () => {
expect(jsonToMarkdown('hello')).toBe('hello');
expect(jsonToMarkdown(123)).toBe('123');
expect(jsonToMarkdown(true)).toBe('true');
expect(jsonToMarkdown(null)).toBe('null');
expect(jsonToMarkdown(undefined)).toBe('undefined');
});
it('should handle simple arrays', () => {
const data = ['a', 'b', 'c'];
expect(jsonToMarkdown(data)).toBe('- a\n- b\n- c');
});
it('should handle simple objects and convert camelCase to Space Case', () => {
const data = { userName: 'Alice', userAge: 30 };
expect(jsonToMarkdown(data)).toBe(
'- **User Name**: Alice\n- **User Age**: 30',
);
});
it('should handle empty structures', () => {
expect(jsonToMarkdown([])).toBe('[]');
expect(jsonToMarkdown({})).toBe('{}');
});
it('should handle nested structures with proper indentation', () => {
const data = {
userInfo: {
fullName: 'Bob Smith',
userRoles: ['admin', 'user'],
},
isActive: true,
};
const result = jsonToMarkdown(data);
expect(result).toBe(
'- **User Info**:\n' +
' - **Full Name**: Bob Smith\n' +
' - **User Roles**:\n' +
' - admin\n' +
' - user\n' +
'- **Is Active**: true',
);
});
it('should render tables for arrays of similar objects with Space Case keys', () => {
const data = [
{ userId: 1, userName: 'Item 1' },
{ userId: 2, userName: 'Item 2' },
];
const result = jsonToMarkdown(data);
expect(result).toBe(
'| User Id | User Name |\n| --- | --- |\n| 1 | Item 1 |\n| 2 | Item 2 |',
);
});
it('should handle pipe characters, backslashes, and newlines in table data', () => {
const data = [
{ colInfo: 'val|ue', otherInfo: 'line\nbreak', pathInfo: 'C:\\test' },
];
const result = jsonToMarkdown(data);
expect(result).toBe(
'| Col Info | Other Info | Path Info |\n| --- | --- | --- |\n| val\\|ue | line break | C:\\\\test |',
);
});
it('should fallback to lists for arrays with mixed objects', () => {
const data = [
{ userId: 1, userName: 'Item 1' },
{ userId: 2, somethingElse: 'Item 2' },
];
const result = jsonToMarkdown(data);
expect(result).toContain('- **User Id**: 1');
expect(result).toContain('- **Something Else**: Item 2');
});
it('should properly indent nested tables', () => {
const data = {
items: [
{ id: 1, name: 'A' },
{ id: 2, name: 'B' },
],
};
const result = jsonToMarkdown(data);
const lines = result.split('\n');
expect(lines[0]).toBe('- **Items**:');
expect(lines[1]).toBe(' | Id | Name |');
expect(lines[2]).toBe(' | --- | --- |');
expect(lines[3]).toBe(' | 1 | A |');
expect(lines[4]).toBe(' | 2 | B |');
});
it('should indent subsequent lines of multiline strings', () => {
const data = {
description: 'Line 1\nLine 2\nLine 3',
};
const result = jsonToMarkdown(data);
expect(result).toBe('- **Description**: Line 1\n Line 2\n Line 3');
});
});
describe('safeJsonToMarkdown', () => {
it('should convert valid JSON', () => {
const json = JSON.stringify({ keyName: 'value' });
expect(safeJsonToMarkdown(json)).toBe('- **Key Name**: value');
});
it('should return original string for invalid JSON', () => {
const notJson = 'Not a JSON string';
expect(safeJsonToMarkdown(notJson)).toBe(notJson);
});
it('should handle plain strings that look like numbers or booleans but are valid JSON', () => {
expect(safeJsonToMarkdown('123')).toBe('123');
expect(safeJsonToMarkdown('true')).toBe('true');
});
});
});
+147
View File
@@ -0,0 +1,147 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Converts a camelCase string to a Space Case string.
* e.g., "camelCaseString" -> "Camel Case String"
*/
function camelToSpace(text: string): string {
const result = text.replace(/([A-Z])/g, ' $1');
return result.charAt(0).toUpperCase() + result.slice(1).trim();
}
/**
* Converts a JSON-compatible value into a readable Markdown representation.
*
* @param data The data to convert.
* @param indent The current indentation level (for internal recursion).
* @returns A Markdown string representing the data.
*/
export function jsonToMarkdown(data: unknown, indent = 0): string {
const spacing = ' '.repeat(indent);
if (data === null) {
return 'null';
}
if (data === undefined) {
return 'undefined';
}
if (Array.isArray(data)) {
if (data.length === 0) {
return '[]';
}
if (isArrayOfSimilarObjects(data)) {
return renderTable(data, indent);
}
return data
.map((item) => {
if (
typeof item === 'object' &&
item !== null &&
Object.keys(item).length > 0
) {
const rendered = jsonToMarkdown(item, indent + 1);
return `${spacing}-\n${rendered}`;
}
const rendered = jsonToMarkdown(item, indent + 1).trimStart();
return `${spacing}- ${rendered}`;
})
.join('\n');
}
if (typeof data === 'object') {
const entries = Object.entries(data);
if (entries.length === 0) {
return '{}';
}
return entries
.map(([key, value]) => {
const displayKey = camelToSpace(key);
if (
typeof value === 'object' &&
value !== null &&
Object.keys(value).length > 0
) {
const renderedValue = jsonToMarkdown(value, indent + 1);
return `${spacing}- **${displayKey}**:\n${renderedValue}`;
}
const renderedValue = jsonToMarkdown(value, indent + 1).trimStart();
return `${spacing}- **${displayKey}**: ${renderedValue}`;
})
.join('\n');
}
if (typeof data === 'string') {
return data
.split('\n')
.map((line, i) => (i === 0 ? line : spacing + line))
.join('\n');
}
return String(data);
}
/**
* Safely attempts to parse a string as JSON and convert it to Markdown.
* If parsing fails, returns the original string.
*
* @param text The text to potentially convert.
* @returns The Markdown representation or the original text.
*/
export function safeJsonToMarkdown(text: string): string {
try {
const parsed: unknown = JSON.parse(text);
return jsonToMarkdown(parsed);
} catch {
return text;
}
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isArrayOfSimilarObjects(
data: unknown[],
): data is Array<Record<string, unknown>> {
if (data.length === 0) {
return false;
}
if (!data.every(isRecord)) return false;
const firstKeys = Object.keys(data[0]).sort().join(',');
return data.every((item) => Object.keys(item).sort().join(',') === firstKeys);
}
function renderTable(data: Array<Record<string, unknown>>, indent = 0): string {
const spacing = ' '.repeat(indent);
const keys = Object.keys(data[0]);
const displayKeys = keys.map(camelToSpace);
const header = `${spacing}| ${displayKeys.join(' | ')} |`;
const separator = `${spacing}| ${keys.map(() => '---').join(' | ')} |`;
const rows = data.map(
(item) =>
`${spacing}| ${keys
.map((key) => {
const val = item[key];
if (typeof val === 'object' && val !== null) {
return JSON.stringify(val)
.replace(/\\/g, '\\\\')
.replace(/\|/g, '\\|');
}
return String(val)
.replace(/\\/g, '\\\\')
.replace(/\|/g, '\\|')
.replace(/\n/g, ' ');
})
.join(' | ')} |`,
);
return [header, separator, ...rows].join('\n');
}