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
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
convertCodeExecutionParts,
extractCodeAndTruncateContent,
File,
getFileContentAsBase64,
} from '../../code_executors/code_execution_utils.js';
import {CodeExecutorContext} from '../../code_executors/code_executor_context.js';
import {createEvent, Event} from '../../events/event.js';
Expand Down Expand Up @@ -505,7 +506,10 @@ async function postProcessCodeExecutionResult(
const version = await invocationContext.artifactService.saveArtifact({
filename: outputFile.name,
artifact: {
inlineData: {data: outputFile.content, mimeType: outputFile.mimeType},
inlineData: {
data: getFileContentAsBase64(outputFile),
mimeType: outputFile.mimeType,
},
},
});

Expand Down
19 changes: 19 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,25 @@ export function getEncodedFileContent(data: string): string {
return isBase64Encoded(data) ? data : base64Encode(data);
}

/**
* Returns an output file's content as base64, the encoding
* `Part.inlineData.data` is defined to carry.
*
* The encoding is read off the file rather than guessed: a file whose
* `contentEncoding` is UTF8 holds raw text and must be encoded, one that is
* already BASE64 is passed through so it is not encoded twice. A file that
* declares no encoding is treated as base64, which is what the executors
* predating `contentEncoding` emit.
*
* @param file The file whose content to encode.
* @return The file content as base64.
*/
export function getFileContentAsBase64(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
148 changes: 145 additions & 3 deletions core/test/agents/processors/code_execution_request_processor_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,35 @@

import {
BaseAgent,
Event,
FileArtifactService,
InMemoryArtifactService,
InvocationContext,
LlmAgent,
LlmRequest,
LlmResponse,
PluginManager,
SessionArtifactService,
createSession,
} from '@google/adk';
import {describe, expect, it} from 'vitest';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import {afterEach, describe, expect, it} from 'vitest';
import {
CODE_EXECUTION_REQUEST_PROCESSOR,
CodeExecutionResponseProcessor,
} from '../../../src/agents/processors/code_execution_request_processor.js';
import {ScopedArtifactService} from '../../../src/artifacts/scoped_artifact_service.js';
import {
BaseCodeExecutor,
ExecuteCodeParams,
} from '../../../src/code_executors/base_code_executor.js';
import {CodeExecutionResult} from '../../../src/code_executors/code_execution_utils.js';
import {
CodeExecutionResult,
File,
FileContentEncoding,
} from '../../../src/code_executors/code_execution_utils.js';

class MockBaseAgent extends BaseAgent {
constructor(name: string) {
Expand All @@ -37,7 +50,20 @@ class TestCodeExecutor extends BaseCodeExecutor {
}
}

function createMockInvocationContext(agent: BaseAgent): InvocationContext {
class OutputFileCodeExecutor extends BaseCodeExecutor {
constructor(private readonly outputFiles: File[]) {
super();
}

async executeCode(_params: ExecuteCodeParams): Promise<CodeExecutionResult> {
return {stdout: 'done', stderr: '', outputFiles: this.outputFiles};
}
}

function createMockInvocationContext(
agent: BaseAgent,
artifactService?: SessionArtifactService,
): InvocationContext {
return new InvocationContext({
invocationId: 'test-invocation',
agent,
Expand All @@ -48,6 +74,7 @@ function createMockInvocationContext(agent: BaseAgent): InvocationContext {
userId: 'test-user',
}),
pluginManager: new PluginManager([]),
artifactService,
});
}

Expand Down Expand Up @@ -218,4 +245,119 @@ describe('CodeExecutionResponseProcessor', () => {
expect(events).toHaveLength(0);
});
});

describe('output file artifacts', () => {
const textFile: File = {
name: 'out.txt',
content: 'hello from script',
contentEncoding: FileContentEncoding.UTF8,
mimeType: 'text/plain',
};
const binaryFile: File = {
name: 'plot.png',
content: 'iVBORw0KGgo=',
contentEncoding: FileContentEncoding.BASE64,
mimeType: 'image/png',
};
const tempRoots: string[] = [];

afterEach(async () => {
await Promise.all(
tempRoots.splice(0).map((root) => fs.rm(root, {recursive: true})),
);
});

async function runExecution(
outputFiles: File[],
artifactService: SessionArtifactService,
): Promise<Event[]> {
const agent = new LlmAgent({
name: 'agent-with-output-files',
model: 'gemini-2.5-flash',
codeExecutor: new OutputFileCodeExecutor(outputFiles),
});
const ctx = createMockInvocationContext(agent, artifactService);
const llmResponse: LlmResponse = {
partial: false,
content: {role: 'model', parts: [{text: '```python\nprint(1)\n```'}]},
};

return collectEvents(responseProcessor.runAsync(ctx, llmResponse));
}

async function createTempFileArtifactService(): Promise<SessionArtifactService> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'adk-artifacts-'));
tempRoots.push(root);

return new ScopedArtifactService(
new FileArtifactService(root),
'test-app',
'test-user',
'test-session',
);
}

it('base64-encodes a utf-8 output file', async () => {
const artifactService = new ScopedArtifactService(
new InMemoryArtifactService(),
'test-app',
'test-user',
'test-session',
);

const events = await runExecution([textFile], artifactService);

const saved = await artifactService.loadArtifact({filename: 'out.txt'});
const data = saved?.inlineData?.data;
if (data === undefined) {
expect.fail('no artifact was saved for out.txt');
}
expect(data).toBe('aGVsbG8gZnJvbSBzY3JpcHQ=');
expect(Buffer.from(data, 'base64').toString('utf-8')).toBe(
'hello from script',
);
expect(saved?.inlineData?.mimeType).toBe('text/plain');
expect(events[events.length - 1].actions.artifactDelta).toEqual({
'out.txt': 0,
});
});

it('passes a base64 output file through without encoding it twice', async () => {
const artifactService = new ScopedArtifactService(
new InMemoryArtifactService(),
'test-app',
'test-user',
'test-session',
);

const events = await runExecution([binaryFile], artifactService);

const saved = await artifactService.loadArtifact({filename: 'plot.png'});
const data = saved?.inlineData?.data;
if (data === undefined) {
expect.fail('no artifact was saved for plot.png');
}
expect(data).toBe('iVBORw0KGgo=');
expect(Buffer.from(data, 'base64')).toHaveLength(8);
expect(saved?.inlineData?.mimeType).toBe('image/png');
expect(events[events.length - 1].actions.artifactDelta).toEqual({
'plot.png': 0,
});
});

it('round-trips a utf-8 output file through FileArtifactService', async () => {
const artifactService = await createTempFileArtifactService();

await runExecution([textFile], artifactService);

const loaded = await artifactService.loadArtifact({filename: 'out.txt'});
const data = loaded?.inlineData?.data;
if (data === undefined) {
expect.fail('no artifact was stored for out.txt');
}
expect(Buffer.from(data, 'base64').toString('utf-8')).toBe(
'hello from script',
);
});
});
});
62 changes: 62 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,
getFileContentAsBase64,
} from '../../src/code_executors/code_execution_utils.js';
import {base64Encode} from '../../src/utils/env_aware_utils.js';

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

// ---------------------------------------------------------------------------
// getFileContentAsBase64
// ---------------------------------------------------------------------------
describe('getFileContentAsBase64', () => {
it('encodes a utf-8 file', () => {
const result = getFileContentAsBase64({
name: 'report.md',
content: '# Notes\n',
contentEncoding: FileContentEncoding.UTF8,
mimeType: 'text/markdown',
});

expect(result).toBe('IyBOb3Rlcwo=');
expect(Buffer.from(result, 'base64').toString('utf-8')).toBe('# Notes\n');
});

it('passes a base64 file through unchanged', () => {
const result = getFileContentAsBase64({
name: 'plot.png',
content: 'iVBORw0KGgo=',
contentEncoding: FileContentEncoding.BASE64,
mimeType: 'image/png',
});

expect(result).toBe('iVBORw0KGgo=');
expect(result).not.toBe(base64Encode('iVBORw0KGgo='));
});

it('treats a missing encoding as base64', () => {
const result = getFileContentAsBase64({
name: 'plot.png',
content: 'iVBORw0KGgo=',
mimeType: 'image/png',
});

expect(result).toBe('iVBORw0KGgo=');
});

it('encodes utf-8 text that is itself valid base64', () => {
const result = getFileContentAsBase64({
name: 'notes.txt',
content: 'data',
contentEncoding: FileContentEncoding.UTF8,
mimeType: 'text/plain',
});

expect(result).toBe('ZGF0YQ==');
});

it('handles empty utf-8 content', () => {
const result = getFileContentAsBase64({
name: 'empty.txt',
content: '',
contentEncoding: FileContentEncoding.UTF8,
mimeType: 'text/plain',
});

expect(result).toBe('');
});
});

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