diff --git a/core/src/tools/skill/run_skill_inline_script_tool.ts b/core/src/tools/skill/run_skill_inline_script_tool.ts index 1c0ad2ea2..485b0a421 100644 --- a/core/src/tools/skill/run_skill_inline_script_tool.ts +++ b/core/src/tools/skill/run_skill_inline_script_tool.ts @@ -10,6 +10,7 @@ import {isLlmAgent} from '../../agents/llm_agent.js'; import {CodeExecutionLanguage} from '../../code_executors/code_execution_utils.js'; import {experimental} from '../../utils/experimental.js'; import {materializeFiles} from '../../utils/file_utils.js'; +import {truncateMiddle} from '../../utils/truncate_utils.js'; import {BaseTool, RunAsyncToolRequest} from '../base_tool.js'; import {SkillToolset} from './skill_toolset.js'; @@ -140,10 +141,14 @@ export class RunSkillInlineScriptTool extends BaseTool { }, }); - // Final filename could be different if there was a collision, so update the result. - result.outputFiles = await materializeFiles(result.outputFiles); - - return result; + const maxOutputChars = this.toolset.maxOutputChars; + return { + ...result, + stdout: truncateMiddle(result.stdout, maxOutputChars), + stderr: truncateMiddle(result.stderr, maxOutputChars), + // Final filename could be different if there was a collision, so update the result. + outputFiles: await materializeFiles(result.outputFiles), + }; } catch (e: unknown) { return { error: `Failed to execute inline script: ${(e as Error).message}`, diff --git a/core/src/tools/skill/run_skill_script_tool.ts b/core/src/tools/skill/run_skill_script_tool.ts index 87e7b7a8d..62ff72003 100644 --- a/core/src/tools/skill/run_skill_script_tool.ts +++ b/core/src/tools/skill/run_skill_script_tool.ts @@ -18,6 +18,7 @@ import { getScriptLanguageByExtension, } from '../../utils/file_extension_utils.js'; import {materializeFiles} from '../../utils/file_utils.js'; +import {truncateMiddle} from '../../utils/truncate_utils.js'; import {BaseTool, RunAsyncToolRequest} from '../base_tool.js'; import {SkillToolset} from './skill_toolset.js'; @@ -141,10 +142,14 @@ export class RunSkillScriptTool extends BaseTool { }, }); - // Final filename could be different if there was a collision, so update the result. - result.outputFiles = await materializeFiles(result.outputFiles); - - return result; + const maxOutputChars = this.toolset.maxOutputChars; + return { + ...result, + stdout: truncateMiddle(result.stdout, maxOutputChars), + stderr: truncateMiddle(result.stderr, maxOutputChars), + // Final filename could be different if there was a collision, so update the result. + outputFiles: await materializeFiles(result.outputFiles), + }; } catch (e: unknown) { return { error: `Failed to execute script '${scriptPath}': ${(e as Error).message}`, diff --git a/core/src/tools/skill/skill_toolset.ts b/core/src/tools/skill/skill_toolset.ts index e631fc215..e52062b5a 100644 --- a/core/src/tools/skill/skill_toolset.ts +++ b/core/src/tools/skill/skill_toolset.ts @@ -22,6 +22,12 @@ import {RunSkillInlineScriptTool} from './run_skill_inline_script_tool.js'; import {RunSkillScriptTool} from './run_skill_script_tool.js'; import {SearchSkillsTool} from './search_skills_tool.js'; +/** + * Default cap on the number of characters of a single `stdout` / `stderr` + * stream returned to the model per skill script execution. + */ +export const DEFAULT_MAX_OUTPUT_CHARS = 30_000; + const DEFAULT_SKILL_SYSTEM_INSTRUCTION = `You can use specialized 'skills' to help you with complex tasks. You MUST use the skill tools to interact with these skills. Skills are folders of instructions and resources that extend your capabilities for specialized tasks. Each skill folder contains: @@ -45,6 +51,7 @@ export class SkillToolset extends BaseToolset { public additionalTools: Array; public codeExecutor?: BaseCodeExecutor; public registry?: SkillRegistry; + public readonly maxOutputChars: number; private toolCache = new Map(); private fetchedSkillCache = new Map>(); @@ -63,6 +70,13 @@ export class SkillToolset extends BaseToolset { * confirmation. */ allowInlineScripts?: boolean; + /** + * Maximum number of characters of `stdout` / `stderr` returned to the + * model per skill script execution. Each stream is capped + * independently; output beyond the cap is elided from the middle with an + * explicit marker. Defaults to `DEFAULT_MAX_OUTPUT_CHARS` (30,000). + */ + maxOutputChars?: number; } = {}, ) { super([], 'adk_skill_toolset'); @@ -72,6 +86,7 @@ export class SkillToolset extends BaseToolset { this.codeExecutor = options.codeExecutor; this.additionalTools = options.additionalTools || []; this.registry = options.registry; + this.maxOutputChars = options.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS; this.tools = [ new ListSkillsTool(this), diff --git a/core/src/utils/truncate_utils.ts b/core/src/utils/truncate_utils.ts new file mode 100644 index 000000000..b5fa0c593 --- /dev/null +++ b/core/src/utils/truncate_utils.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Truncates `text` to at most `limit` characters of original content by + * dropping the middle, keeping the head and the tail. Returns `text` unchanged + * when it already fits. The elided region is replaced by a marker naming the + * number of characters removed, so a reader can tell content is missing. + * + * Both ends are preserved because the diagnostically useful part of a large + * stream is often at its end (a failure summary, the innermost frames of a + * looping stack trace), which head-only truncation discards. + * + * @param text The text to truncate. + * @param limit Maximum number of original characters to preserve. Negative + * values are clamped to zero. + * @return `text` when it fits, otherwise the head, the marker and the tail. + */ +export function truncateMiddle(text: string, limit: number): string { + const cap = Math.max(0, limit); + if (text.length <= cap) { + return text; + } + const headLength = Math.ceil(cap / 2); + const head = text.slice(0, headLength); + const tail = text.slice(text.length - (cap - headLength)); + return `${head}\n... [truncated ${text.length - cap} characters] ...\n${tail}`; +} diff --git a/core/test/tools/skills/run_skill_inline_script_tool_test.ts b/core/test/tools/skills/run_skill_inline_script_tool_test.ts index 36664b070..297dc1e17 100644 --- a/core/test/tools/skills/run_skill_inline_script_tool_test.ts +++ b/core/test/tools/skills/run_skill_inline_script_tool_test.ts @@ -19,6 +19,7 @@ import { SkillToolset, } from '@google/adk'; import {describe, expect, it, vi} from 'vitest'; +import {DEFAULT_MAX_OUTPUT_CHARS} from '../../../src/tools/skill/skill_toolset.js'; import {ToolConfirmation} from '../../../src/tools/tool_confirmation.js'; import {materializeFiles} from '../../../src/utils/file_utils.js'; @@ -382,6 +383,70 @@ describe('RunSkillInlineScriptTool', () => { }); }); + describe('output truncation', () => { + async function runWith( + mockResult: CodeExecutionResult, + maxOutputChars?: number, + ): Promise { + const mockExecutor = new MockCodeExecutor(); + mockExecutor.mockResult = mockResult; + const toolset = new SkillToolset([], { + codeExecutor: mockExecutor, + allowInlineScripts: true, + maxOutputChars, + }); + const tool = new RunSkillInlineScriptTool(toolset); + + return (await tool.runAsync({ + args: { + script_content: 'console.log("noisy");', + language: CodeExecutionLanguage.JAVASCRIPT, + }, + toolContext: createMockContext('test-agent', undefined, { + toolConfirmation: confirmed(), + }), + })) as CodeExecutionResult; + } + + it('returns stdout below the cap verbatim', async () => { + const stdout = 'a'.repeat(DEFAULT_MAX_OUTPUT_CHARS); + + const result = await runWith({stdout, stderr: '', outputFiles: []}); + + expect(result.stdout).toBe(stdout); + }); + + it('truncates stdout above the cap', async () => { + const stdout = 'a'.repeat(DEFAULT_MAX_OUTPUT_CHARS) + 'bbbbb'; + + const result = await runWith({stdout, stderr: '', outputFiles: []}); + + const half = DEFAULT_MAX_OUTPUT_CHARS / 2; + expect(result.stdout.startsWith('a'.repeat(half))).toBe(true); + expect(result.stdout.endsWith('bbbbb')).toBe(true); + expect(result.stdout).toContain('... [truncated 5 characters] ...'); + }); + + it('truncates stderr above the cap', async () => { + const stderr = 'e'.repeat(DEFAULT_MAX_OUTPUT_CHARS + 7); + + const result = await runWith({stdout: 'ok', stderr, outputFiles: []}); + + expect(result.stdout).toBe('ok'); + expect(result.stderr).toContain('... [truncated 7 characters] ...'); + }); + + it('honours a custom maxOutputChars on the toolset', async () => { + const result = await runWith( + {stdout: '0123456789', stderr: 'abcdefghij', outputFiles: []}, + 4, + ); + + expect(result.stdout).toBe('01\n... [truncated 6 characters] ...\n89'); + expect(result.stderr).toBe('ab\n... [truncated 6 characters] ...\nij'); + }); + }); + describe('error codes', () => { it('exposes stable string values for the error-code enum', () => { // The error-code string values are part of the tool's response contract diff --git a/core/test/tools/skills/run_skill_script_tool_test.ts b/core/test/tools/skills/run_skill_script_tool_test.ts index 15c796b05..7030c46c2 100644 --- a/core/test/tools/skills/run_skill_script_tool_test.ts +++ b/core/test/tools/skills/run_skill_script_tool_test.ts @@ -18,6 +18,7 @@ import { SkillToolset, } from '@google/adk'; import {describe, expect, it, vi} from 'vitest'; +import {DEFAULT_MAX_OUTPUT_CHARS} from '../../../src/tools/skill/skill_toolset.js'; import {materializeFiles} from '../../../src/utils/file_utils.js'; vi.mock('../../../src/utils/file_utils.js', () => ({ @@ -228,4 +229,110 @@ describe('RunSkillScriptTool', () => { expect(materializeFiles).toHaveBeenCalledWith([testFile]); }); + + it('returns execution error when executor throws', async () => { + const mockExecutor = new MockCodeExecutor(); + mockExecutor.shouldThrow = true; + const toolset = new SkillToolset([mockSkill], {codeExecutor: mockExecutor}); + const tool = new RunSkillScriptTool(toolset); + + const result = (await tool.runAsync({ + args: {skill_name: 'test-skill', script_path: 'scripts/setup.js'}, + toolContext: createMockContext(), + })) as ToolErrorResponse; + + expect(result).toEqual({ + error: + "Failed to execute script 'scripts/setup.js': Mock execution failure", + errorCode: 'EXECUTION_ERROR', + }); + }); + + describe('output truncation', () => { + async function runWith( + mockResult: CodeExecutionResult, + maxOutputChars?: number, + ): Promise { + const mockExecutor = new MockCodeExecutor(); + mockExecutor.mockResult = mockResult; + const toolset = new SkillToolset([mockSkill], { + codeExecutor: mockExecutor, + maxOutputChars, + }); + const tool = new RunSkillScriptTool(toolset); + + return (await tool.runAsync({ + args: {skill_name: 'test-skill', script_path: 'scripts/setup.js'}, + toolContext: createMockContext(), + })) as CodeExecutionResult; + } + + it('returns stdout below the cap verbatim', async () => { + const stdout = 'a'.repeat(DEFAULT_MAX_OUTPUT_CHARS); + + const result = await runWith({stdout, stderr: '', outputFiles: []}); + + expect(result.stdout).toBe(stdout); + }); + + it('truncates stdout above the cap', async () => { + const stdout = 'a'.repeat(DEFAULT_MAX_OUTPUT_CHARS) + 'bbb'; + + const result = await runWith({stdout, stderr: '', outputFiles: []}); + + const half = DEFAULT_MAX_OUTPUT_CHARS / 2; + expect(result.stdout.startsWith('a'.repeat(half))).toBe(true); + expect(result.stdout.endsWith('bbb')).toBe(true); + expect(result.stdout).toContain('... [truncated 3 characters] ...'); + }); + + it('truncates stderr above the cap while leaving a short stdout verbatim', async () => { + const stderr = 'e'.repeat(DEFAULT_MAX_OUTPUT_CHARS + 10); + + const result = await runWith({stdout: 'ok', stderr, outputFiles: []}); + + expect(result.stdout).toBe('ok'); + expect(result.stderr).toContain('... [truncated 10 characters] ...'); + }); + + it('caps stdout and stderr independently in one call', async () => { + const result = await runWith( + {stdout: '0123456789', stderr: 'abcdefghij', outputFiles: []}, + 4, + ); + + expect(result.stdout).toBe('01\n... [truncated 6 characters] ...\n89'); + expect(result.stderr).toBe('ab\n... [truncated 6 characters] ...\nij'); + }); + + it('honours a custom maxOutputChars on the toolset', async () => { + const result = await runWith( + {stdout: 'x'.repeat(30), stderr: '', outputFiles: []}, + 10, + ); + + expect(result.stdout).toBe( + `${'x'.repeat(5)}\n... [truncated 20 characters] ...\n${'x'.repeat(5)}`, + ); + }); + + it('does not mutate the executor result', async () => { + const stdout = 'y'.repeat(50); + const mockExecutor = new MockCodeExecutor(); + mockExecutor.mockResult = {stdout, stderr: '', outputFiles: []}; + const toolset = new SkillToolset([mockSkill], { + codeExecutor: mockExecutor, + maxOutputChars: 10, + }); + const tool = new RunSkillScriptTool(toolset); + + const result = (await tool.runAsync({ + args: {skill_name: 'test-skill', script_path: 'scripts/setup.js'}, + toolContext: createMockContext(), + })) as CodeExecutionResult; + + expect(result.stdout).not.toBe(stdout); + expect(mockExecutor.mockResult.stdout).toBe(stdout); + }); + }); }); diff --git a/core/test/tools/skills/skill_toolset_test.ts b/core/test/tools/skills/skill_toolset_test.ts index 8028a9ba2..c98ef9125 100644 --- a/core/test/tools/skills/skill_toolset_test.ts +++ b/core/test/tools/skills/skill_toolset_test.ts @@ -15,6 +15,7 @@ import { SkillToolset, } from '@google/adk'; import {describe, expect, it, vi} from 'vitest'; +import {DEFAULT_MAX_OUTPUT_CHARS} from '../../../src/tools/skill/skill_toolset.js'; describe('skill_toolset', () => { const mockSkill: Skill = { @@ -314,4 +315,18 @@ describe('skill_toolset', () => { expect(mockInnerGetTools).toHaveBeenCalledTimes(1); }); }); + + describe('maxOutputChars', () => { + it('defaults to DEFAULT_MAX_OUTPUT_CHARS when the option is omitted', () => { + expect(DEFAULT_MAX_OUTPUT_CHARS).toBe(30_000); + + const toolset = new SkillToolset([mockSkill]); + expect(toolset.maxOutputChars).toBe(DEFAULT_MAX_OUTPUT_CHARS); + }); + + it('reflects an explicitly passed option', () => { + const toolset = new SkillToolset([mockSkill], {maxOutputChars: 512}); + expect(toolset.maxOutputChars).toBe(512); + }); + }); }); diff --git a/core/test/utils/truncate_utils_test.ts b/core/test/utils/truncate_utils_test.ts new file mode 100644 index 000000000..279ff6751 --- /dev/null +++ b/core/test/utils/truncate_utils_test.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, expect, it} from 'vitest'; +import {truncateMiddle} from '../../src/utils/truncate_utils.js'; + +describe('truncate_utils', () => { + describe('truncateMiddle', () => { + it('returns the input unchanged when it is shorter than the limit', () => { + expect(truncateMiddle('abcde', 10)).toBe('abcde'); + }); + + it('returns the input unchanged when its length equals the limit', () => { + expect(truncateMiddle('abcde', 5)).toBe('abcde'); + }); + + it('truncates when the input is one character over the limit', () => { + expect(truncateMiddle('abcdef', 5)).toBe( + 'abc\n... [truncated 1 characters] ...\nef', + ); + }); + + it('keeps half the limit from each end for an even limit', () => { + const text = 'x'.repeat(50) + 'y'.repeat(50); + + const result = truncateMiddle(text, 20); + + expect(result.startsWith('x'.repeat(10))).toBe(true); + expect(result.endsWith('y'.repeat(10))).toBe(true); + expect(result).toBe( + `${'x'.repeat(10)}\n... [truncated 80 characters] ...\n${'y'.repeat(10)}`, + ); + }); + + it('rounds the head up and the tail down for an odd limit', () => { + const text = 'abcdefghij'.repeat(10); + + const result = truncateMiddle(text, 7); + + expect(result).toBe(`abcd\n... [truncated 93 characters] ...\nhij`); + }); + + it('reports the number of removed characters, not the total', () => { + expect(truncateMiddle('0123456789', 4)).toBe( + '01\n... [truncated 6 characters] ...\n89', + ); + }); + + it('returns marker-only output for a zero limit', () => { + expect(truncateMiddle('abcdef', 0)).toBe( + '\n... [truncated 6 characters] ...\n', + ); + }); + + it('clamps a negative limit to zero', () => { + expect(truncateMiddle('abcdef', -100)).toBe( + '\n... [truncated 6 characters] ...\n', + ); + }); + + it('preserves exactly limit characters of content plus the marker', () => { + const limit = 30_000; + + const atLimit = 'a'.repeat(limit); + expect(truncateMiddle(atLimit, limit)).toBe(atLimit); + + const result = truncateMiddle('a'.repeat(limit + 1), limit); + expect(result).toContain('... [truncated 1 characters] ...'); + expect(result.length).toBe( + limit + '\n... [truncated 1 characters] ...\n'.length, + ); + }); + }); +}); diff --git a/tests/integration/tools/run_skill_script_tool_test.ts b/tests/integration/tools/run_skill_script_tool_test.ts index aa7f0ef6d..cb960d91c 100644 --- a/tests/integration/tools/run_skill_script_tool_test.ts +++ b/tests/integration/tools/run_skill_script_tool_test.ts @@ -158,6 +158,49 @@ describe('RunSkillScriptTool Integration with UnsafeLocalCodeExecutor', () => { }, ); + it('caps stdout and stderr of a real noisy JavaScript skill script', async () => { + const maxOutputChars = 500; + const noisySkill: Skill = { + frontmatter: { + name: 'noisy-skill', + description: 'A skill whose script floods stdout and stderr', + }, + instructions: 'Run scripts.', + resources: { + scripts: { + 'noisy.js': { + src: [ + "process.stdout.write('S'.repeat(600) + 'STDOUT_END');", + "process.stderr.write('E'.repeat(700) + 'STDERR_END');", + ].join('\n'), + }, + }, + }, + }; + const executor = new UnsafeLocalCodeExecutor(); + const toolset = new SkillToolset([noisySkill], { + codeExecutor: executor, + maxOutputChars, + }); + const tool = new RunSkillScriptTool(toolset); + + const result = (await tool.runAsync({ + args: { + skill_name: 'noisy-skill', + script_path: 'scripts/noisy.js', + }, + toolContext: createMockContext(), + })) as CodeExecutionResult; + + expect(result.stdout.startsWith('S'.repeat(maxOutputChars / 2))).toBe(true); + expect(result.stdout).toContain('... [truncated 110 characters] ...'); + expect(result.stdout.endsWith('STDOUT_END')).toBe(true); + + expect(result.stderr.startsWith('E'.repeat(maxOutputChars / 2))).toBe(true); + expect(result.stderr).toContain('... [truncated 210 characters] ...'); + expect(result.stderr.endsWith('STDERR_END')).toBe(true); + }); + it('successfully executes a real Python skill script', async () => { const executor = new UnsafeLocalCodeExecutor(); const toolset = new SkillToolset([testSkill], {codeExecutor: executor});