Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions core/src/tools/skill/run_skill_inline_script_tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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}`,
Expand Down
13 changes: 9 additions & 4 deletions core/src/tools/skill/run_skill_script_tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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}`,
Expand Down
15 changes: 15 additions & 0 deletions core/src/tools/skill/skill_toolset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -45,6 +51,7 @@ export class SkillToolset extends BaseToolset {
public additionalTools: Array<BaseTool | BaseToolset>;
public codeExecutor?: BaseCodeExecutor;
public registry?: SkillRegistry;
public readonly maxOutputChars: number;
private toolCache = new Map<string, BaseTool[]>();
private fetchedSkillCache = new Map<string, Map<string, Skill>>();

Expand All @@ -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');
Expand All @@ -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),
Expand Down
31 changes: 31 additions & 0 deletions core/src/utils/truncate_utils.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
65 changes: 65 additions & 0 deletions core/test/tools/skills/run_skill_inline_script_tool_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -382,6 +383,70 @@ describe('RunSkillInlineScriptTool', () => {
});
});

describe('output truncation', () => {
async function runWith(
mockResult: CodeExecutionResult,
maxOutputChars?: number,
): Promise<CodeExecutionResult> {
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
Expand Down
107 changes: 107 additions & 0 deletions core/test/tools/skills/run_skill_script_tool_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -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<CodeExecutionResult> {
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);
});
});
});
15 changes: 15 additions & 0 deletions core/test/tools/skills/skill_toolset_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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);
});
});
});
Loading
Loading