Feat: Persist skill script outputs whenever an artifact service is configured - #564
Open
AmaadMartin wants to merge 3 commits into
Open
Conversation
added 3 commits
August 2, 2026 22:22
Three changes to how the skill script tools persist output files: Persistence is no longer gated on an opt-in toolset option. The presence of an artifact service on the invocation is the switch, matching the code-execution processor and Runner.saveArtifacts; a default-off option meant the hosted-deployment case it exists for never happened. The save now runs before materializeFiles rather than after. Artifact services version by filename, and materializeFiles renames on local disk collision (output.txt -> output_2.txt) and mutates file.name in place, so saving afterwards made artifact keys a function of whatever unrelated files happened to be sitting in process.cwd(). The helper returns a filename -> version map and the tools add it to the response only when something was saved, leaving the response byte-identical otherwise. Absent contentEncoding is treated as base64 rather than decoded as utf-8, which is what the one in-repo producer that omits the field actually emits, and encoding goes through the browser-safe base64Encode helper.
Dropping the artifactService guard leaves the tool response identical, because Context.saveArtifact then throws per file into the helper's per-file catch. Assert instead that the supported no-service setup logs no warning, so removing the guard is caught at both levels.
- Build the helper test's context with the real InvocationContext constructor and createSession instead of an `as unknown as` cast, so the compiler sees the fields the context is missing. - Export ScopedArtifactService from common.ts and import it from @google/adk in the integration test, which was the only core/src import in the whole tests/integration suite. - Drop the per-file "Saved ... as an artifact" debug log; the return value and the tools' savedArtifacts field already carry it, and nothing pinned it. The no-service skip log stays: it is the only signal on that path and a test asserts it. - Finish routing file_artifact_service through fileHasUserNamespace() rather than open-coding startsWith(USER_NAMESPACE_PREFIX).
This was referenced Aug 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
Closes: #issue_number
Related: #issue_number
Problem: Skill script output files were the only executor-produced files in
ADK that never reached the artifact service. #517 added the machinery but left
three behaviours that keep it from solving the problem it was written for:
SkillToolset.saveOutputsAsArtifactsoption defaulting tofalse, so thehosted-deployment case — an Agent Engine / Cloud Run container whose local
disk is recycled between requests — still silently loses every generated
file unless the developer discovers and flips a flag.
materializeFiles(), which renames on local collision (output.txt->output_2.txt) and mutatesfile.namein place. Artifact services versionby filename, so this turns a second
output.txtinto a second artifact keyinstead of version 1 — and makes the key a function of whatever unrelated
files happen to be sitting in
process.cwd(), which in a server deploymentis other sessions' leftovers.
contentEncodingis decoded as utf-8.Buffer.from(file.content, undefined)defaults to utf-8, but the one in-repoproducer that omits the field (
AgentEngineSandboxCodeExecutor) emitsbase64, so its bytes were being re-encoded and corrupted.
Solution:
SkillToolset.saveOutputsAsArtifactsentirely. This matches the two existingprecedents in the repo —
code_execution_request_processor.tssaves outputfiles unconditionally, and
Runner.saveArtifacts()returns early when!this.artifactServicerather than requiring a toggle. Artifact persistencein adk-js is opportunistic, never mandatory and never opt-in.
materializeFiles(). Artifacts are keyed by theexecutor-reported name; the disk path keeps the collision-renamed name.
Neither is derived from the other, and the response carries both:
outputFiles[].nameis where the file landed on disk,savedArtifactskeysare the artifact filenames.
savedArtifacts: Record<string, number>, added to the response only whenat least one artifact was actually saved. With no artifact service (or no
output files) the response is byte-identical to before this PR and to before
Feat: Save skill script output files as artifacts (opt-in saveOutputsAsArtifacts) #517, so existing users and the recorded integration goldens are untouched.
FileContentEncoding, not guessed.UTF8 -> base64Encode(content), everything else (includingundefined)passes through as base64, via the browser-safe
base64Encodefromenv_aware_utils.tsrather than a rawBuffer.logger.debugskip,and a single failing
saveArtifactis alogger.warnthat leaves the otherfiles saved and never turns a script that ran, printed and wrote its files
into an
EXECUTION_ERROR.Kept from #517: the local-disk write (removing it would break
tests/integration/skills/script_js/agent_test.ts, which is the productcontract for the CLI scenario), and the guard refusing an executor-produced
filename in the
user:namespace, so generated code cannot widen an artifactbeyond the session that produced it.
Behaviour change to call out: when an artifact service is configured,
skill-script output files now consume artifact storage and appear in
artifactDelta. For a chatty script this is new storage volume. This isdeliberate — it is the same contract the code-execution path has always had.
Tests changed rather than added, and why. Three tests on the base branch
assert the behaviour this PR overturns and cannot compile once the option is
gone:
skill_toolset_test.ts—defaults saveOutputsAsArtifacts to falseandenables saveOutputsAsArtifacts when the option is passed. Removed; theoption no longer exists. Nothing else covered it.
does not touch the artifact service when the option is off.Removed. The surviving guard for existing users is
returns the plain result when no artifact service is configured, in bothfiles, which now also asserts
expect(warnSpy).not.toHaveBeenCalled().Record<string, number>(e.g.surfaces a failed save without failing the scriptbecameomits savedArtifacts and still succeeds when every save fails); no scenariowas dropped.
Deliberate public-API additions. Two, both intentional:
USER_NAMESPACE_PREFIXandfileHasUserNamespace()(added by Feat: Save skill script output files as artifacts (opt-in saveOutputsAsArtifacts) #517 inbase_artifact_service.ts) reach@google/adkthrough the existingexport *incommon.ts. Keeping them public is the right call: theuser:prefix is part of the artifact service contract that every implementation of
BaseArtifactServicehas to honour, so a third-party implementation needsthem. This PR also finishes the sweep Feat: Save skill script output files as artifacts (opt-in saveOutputsAsArtifacts) #517 started —
file_artifact_service.tshad two sites still open-coding
startsWith(USER_NAMESPACE_PREFIX), which nowgo through the helper.
ScopedArtifactServiceis now exported fromcommon.ts. Before this,tests/integration/tools/run_skill_script_tool_test.tswas the only file inthe entire
tests/integrationsuite importing fromcore/srcby relativepath. It is the framework's own bridge from a
BaseArtifactServiceto thesession-scoped service an invocation carries, and
SessionArtifactService—the interface it implements — is already public, so exporting the one concrete
implementation of it is consistent. The alternative (a hand-rolled adapter in
the test) was ~10 lines and worse.
No
any, no@ts-expect-error, noeslint-disable, no coverage suppressionwas added anywhere in this diff. The helper test builds its context with the
real
InvocationContextconstructor andcreateSession()rather than anas unknown as InvocationContextcast.Testing Plan
Please describe the tests that you ran to verify your changes. This is required for all PRs that are not small documentation or typo fixes.
Unit Tests:
[x] I have added or updated unit tests for my change.
[x] All unit tests pass locally.
Coverage of the new/changed module, measured with
npx vitest run --project unit:core --coverage.enabled --coverage.include='core/src/utils/artifact_utils.ts' --coverage.reporter=text core/test/utils/artifact_utils_test.ts:Proof the tests can fail. Each mutation was applied to the source, the
targeted suites re-run, and the source restored:
saveFilesAsArtifactscall back to aftermaterializeFilessaves under the executor-reported name, not the materialized one: expected "saveArtifact" to be called with arguments: [ ObjectContaining{…} ];saves artifacts before materializing the files: expected [ 'materializeFiles', 'saveArtifact' ] to deeply equal [ 'saveArtifact', 'materializeFiles' ]file.contentthrough unencoded instead ofbase64Encode(...)base64-encodes utf-8 content exactly once: expected '\ufffd\ufffde\ufffd…' to be 'hello from script file'; the inline tool's round-trip failed the same wayartifactServiceguard so the helper always callscontext.saveArtifactskips silently when no artifact service is configured: expected "warn" to not be called at all, but actually been called 2 times, plusreturns the plain result when no artifact service is configuredin both tool testssavedArtifactsinstead of only when non-emptyreturns the plain result when no artifact service is configured: expected { stdout: 'script ran', …(3) } to not have property "savedArtifacts"Mutation 3 is worth a note: on the first attempt it killed only one test.
Removing the guard leaves the tool response identical, because
Context.saveArtifactthen throwsArtifact service is not initialized.perfile straight into the helper's per-file
catch. Coverage was already 100% anddid not notice. The no-service tests were strengthened to assert that the
supported no-artifact-service setup emits no warning — a real behavioural
difference between "skipped" and "attempted and failed N times" — which is what
makes mutation 3 fail at both the helper and the tool level.
Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.
Scratch script (not committed): a real
UnsafeLocalCodeExecutorrunning a skillscript that writes
notes.md(text) andchart.png(binary), through a realRunSkillScriptTool+ScopedArtifactService/InMemoryArtifactService, in atemp cwd, invoked twice in the same session. No mocks. Observed:
{ "turn1_savedArtifacts": {"chart.png": 0, "notes.md": 0}, "turn1_disk": ["notes.md", "chart.png"], "turn2_savedArtifacts": {"chart.png": 1, "notes.md": 1}, "turn2_disk": ["notes_2.md", "chart_2.png"], "onDisk": ["chart.png", "chart_2.png", "notes.md", "notes_2.md"], "artifactKeys": ["chart.png", "notes.md"], "notesVersions": [0, 1], "notesRoundTrips": true, "chartByteIdentical": true, "turn2_artifactDelta": {"chart.png": 1, "notes.md": 1} }Four files on disk, two artifact keys with two versions each: the disk
rename did not leak into the artifact namespace.
notes.mdv0 decodes back tothe exact original text (this is the check that catches the base64 mistake) and
chart.pngis byte-identical.artifactDeltais populated on thefunction-response event, so artifact-aware clients see the files.
CI note: this PR targets
feat/skill-script-outputs-as-artifacts, notmain..github/workflows/validation.yamltriggers onpull_request: branches: [main], so no test job will run on this PR. The localresults above were produced on the exact commit pushed.
Checklist
[x] I have read the CONTRIBUTING.md document.
[x] I have performed a self-review of my own code.
[x] I have commented my code, particularly in hard-to-understand areas.
[x] I have added tests that prove my fix is effective or that my feature works.
[x] New and existing unit tests pass locally with my changes.