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
14 changes: 14 additions & 0 deletions core/src/code_executors/code_execution_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,20 @@ export function getEncodedFileContent(data: string): string {
return isBase64Encoded(data) ? data : base64Encode(data);
}

/**
* Returns the file content as base64, honoring the file's declared
* `contentEncoding`. Content with no declared encoding is assumed to already be
* base64, which is what `AgentEngineSandboxCodeExecutor` produces.
*
* @param file The file whose content to encode.
* @return The file content as base64-encoded bytes.
*/
export function toBase64Content(file: File): string {
return file.contentEncoding === FileContentEncoding.UTF8
? base64Encode(file.content)
: file.content;
}

// Type to be used for regex matching of code blocks.
interface CodeGroupMatch {
groups?: {prefix?: string; codeStr?: string};
Expand Down
5 changes: 5 additions & 0 deletions core/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export type {
SaveArtifactRequest,
} from './artifacts/base_artifact_service.js';
export {InMemoryArtifactService} from './artifacts/in_memory_artifact_service.js';
export {ScopedArtifactService} from './artifacts/scoped_artifact_service.js';
export type {
SessionArtifactService,
SessionLoadArtifactRequest,
Expand Down Expand Up @@ -302,6 +303,10 @@ export type {SkillRegistry} from './skills/skill_registry.js';
export {ListSkillsTool} from './tools/skill/list_skills_tool.js';
export {LoadSkillResourceTool} from './tools/skill/load_skill_resource_tool.js';
export {LoadSkillTool} from './tools/skill/load_skill_tool.js';
export type {
SavedOutputFile,
SkillScriptResponse,
} from './tools/skill/script_output_utils.js';
export {SearchSkillsTool} from './tools/skill/search_skills_tool.js';
export {SkillToolset} from './tools/skill/skill_toolset.js';

Expand Down
7 changes: 2 additions & 5 deletions core/src/tools/skill/run_skill_inline_script_tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import {Context} from '../../agents/context.js';
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 {BaseTool, RunAsyncToolRequest} from '../base_tool.js';
import {saveScriptOutputs} from './script_output_utils.js';
import {SkillToolset} from './skill_toolset.js';

/**
Expand Down Expand Up @@ -140,10 +140,7 @@ 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;
return saveScriptOutputs(toolContext, result);
} catch (e: unknown) {
return {
error: `Failed to execute inline script: ${(e as Error).message}`,
Expand Down
7 changes: 2 additions & 5 deletions core/src/tools/skill/run_skill_script_tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ import {
getMimeTypeAndEncoding,
getScriptLanguageByExtension,
} from '../../utils/file_extension_utils.js';
import {materializeFiles} from '../../utils/file_utils.js';
import {BaseTool, RunAsyncToolRequest} from '../base_tool.js';
import {saveScriptOutputs} from './script_output_utils.js';
import {SkillToolset} from './skill_toolset.js';

@experimental
Expand Down Expand Up @@ -141,10 +141,7 @@ 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;
return saveScriptOutputs(toolContext, result);
} catch (e: unknown) {
return {
error: `Failed to execute script '${scriptPath}': ${(e as Error).message}`,
Expand Down
100 changes: 100 additions & 0 deletions core/src/tools/skill/script_output_utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {Context} from '../../agents/context.js';
import {
CodeExecutionResult,
toBase64Content,
} from '../../code_executors/code_execution_utils.js';
import {logger} from '../../utils/logger.js';

/**
* An output file produced by a skill script and saved to the artifact service.
*/
export interface SavedOutputFile {
/** Artifact filename the file was saved under. */
name: string;
mimeType: string;
}

/** Response returned by the skill script execution tools. */
export interface SkillScriptResponse {
stdout: string;
stderr: string;
/**
* Output files produced by the script. File bytes are deliberately omitted;
* load them from the artifact service (e.g. the `load_artifacts` tool).
*/
outputFiles: SavedOutputFile[];
/** Set when output files could not be persisted. */
warning?: string;
}

/**
* Saves the output files of a skill script execution to the artifact service
* and returns a model-facing summary that never contains file bytes.
*
* When no artifact service is configured, the files cannot be persisted; the
* produced filenames are still reported alongside an explicit warning so the
* loss is never silent.
*
* @param toolContext The tool context owning the session's artifact service.
* @param result The result returned by the code executor.
* @return The model-facing response for the skill script tools.
*/
export async function saveScriptOutputs(
toolContext: Context,
{stdout, stderr, outputFiles}: CodeExecutionResult,
): Promise<SkillScriptResponse> {
const names = outputFiles.map(({name, mimeType}) => ({name, mimeType}));

if (
outputFiles.length > 0 &&
!toolContext.invocationContext.artifactService
) {
const warning =
`No artifact service is configured; ${outputFiles.length} output ` +
`file(s) produced by the script were discarded.`;
logger.warn(warning);
return {stdout, stderr, outputFiles: names, warning};
}

const outcomes = await Promise.allSettled(
outputFiles.map((file) =>
toolContext.saveArtifact(file.name, {
inlineData: {data: toBase64Content(file), mimeType: file.mimeType},
}),
),
);

const saved: SavedOutputFile[] = [];
const failed: string[] = [];
outcomes.forEach((outcome, index) => {
if (outcome.status === 'fulfilled') {
saved.push(names[index]);
return;
}
const {name} = names[index];
failed.push(name);
logger.warn(
`Failed to save output file '${name}' to the artifact service.`,
outcome.reason,
);
});

if (failed.length === 0) {
return {stdout, stderr, outputFiles: saved};
}

return {
stdout,
stderr,
outputFiles: saved,
warning:
`Failed to save ${failed.length} of ${outputFiles.length} output ` +
`file(s) to the artifact service: ${failed.join(', ')}.`,
};
}
23 changes: 11 additions & 12 deletions core/src/utils/file_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,21 @@ import * as path from 'node:path';
import {File} from '../code_executors/code_execution_utils.js';

/**
* Creates files with the given paths in the current working directory.
* @param files The files to materialize.
* Creates files with the given paths under `dir`.
*
* A name that collides with an existing file is written with a `_2`, `_3`, …
* suffix, and `file.name` is updated in place to the name actually used.
*
* @param files The files to materialize. Mutated: see above.
* @param dir The directory to create the files in. Required: an implicit
* default would write to whichever directory the host process happens to
* be running in.
*/
export async function materializeFiles(
files: File[],
dir = process.cwd(),
): Promise<File[]> {
dir: string,
): Promise<void> {
const resolvedBaseDir = path.resolve(dir);
const createdFiles: File[] = [];
for (const file of files) {
const fullPath = path.resolve(dir, file.name);

Expand Down Expand Up @@ -62,14 +68,7 @@ export async function materializeFiles(
finalPath,
Buffer.from(file.content, file.contentEncoding),
);

createdFiles.push({
...file,
name: path.relative(dir, finalPath),
});
}

return createdFiles;
}

export const EXTENSION_TO_MIME_TYPE: Record<string, string> = {
Expand Down
41 changes: 41 additions & 0 deletions core/test/code_executors/code_execution_utils_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
convertCodeExecutionParts,
extractCodeAndTruncateContent,
getEncodedFileContent,
toBase64Content,
} from '../../src/code_executors/code_execution_utils.js';
import {base64Encode} from '../../src/utils/env_aware_utils.js';

Expand All @@ -39,6 +40,46 @@ describe('getEncodedFileContent', () => {
});
});

// ---------------------------------------------------------------------------
// toBase64Content
// ---------------------------------------------------------------------------
describe('toBase64Content', () => {
it('base64-encodes content declared as utf-8', () => {
expect(
toBase64Content({
name: 'out.txt',
content: 'hello',
contentEncoding: FileContentEncoding.UTF8,
mimeType: 'text/plain',
}),
).toBe(base64Encode('hello'));
});

it('returns content declared as base64 unchanged', () => {
expect(
toBase64Content({
name: 'out.png',
content: 'aGVsbG8=',
contentEncoding: FileContentEncoding.BASE64,
mimeType: 'image/png',
}),
).toBe('aGVsbG8=');
});

it('treats content with no declared encoding as base64', () => {
// AgentEngineSandboxCodeExecutor omits contentEncoding on already-base64
// content; unlike getEncodedFileContent this must not sniff the payload,
// because plain text such as 'hello' is itself valid base64.
expect(
toBase64Content({
name: 'out.bin',
content: 'aGVsbG8=',
mimeType: 'application/octet-stream',
}),
).toBe('aGVsbG8=');
});
});

// ---------------------------------------------------------------------------
// buildExecutableCodePart
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading