Feat: Save skill script output files as artifacts (opt-in saveOutputsAsArtifacts) - #517
Open
AmaadMartin wants to merge 5 commits into
Open
Feat: Save skill script output files as artifacts (opt-in saveOutputsAsArtifacts)#517AmaadMartin wants to merge 5 commits into
AmaadMartin wants to merge 5 commits into
Conversation
added 5 commits
August 2, 2026 03:54
Persists a batch of executor output files to the session artifact service, reporting per-file success and failure instead of throwing so a caller can treat artifact persistence as best-effort. Filenames in the 'user:' namespace are refused because they widen an artifact beyond the session that produced it.
Both skill script tools can now persist the files a script produced to the session artifact service, reporting them as savedArtifacts and artifactSaveErrors on the tool result. The option defaults to false, so existing callers are unaffected, and a missing artifact service degrades to a warning rather than failing a script that already ran.
Runs a real script through UnsafeLocalCodeExecutor against a real InMemoryArtifactService and asserts the produced file can be loaded back out of the artifact service with its original bytes.
On Windows the executor also reports a skill's own input resources as
outputs, because it skips them by comparing inputFiles[].name
('scripts/x.js') against a readdir path ('scripts\\x.js'). Assert the
expected artifact is present rather than pinning the whole list, which
is not what this test is for.
- Give core/src/artifacts/base_artifact_service.ts sole ownership of
USER_NAMESPACE_PREFIX and fileHasUserNamespace, and import it in the
four places that previously copied the string (one of which used a
magic substring(5)), instead of adding a fifth copy.
- Reuse the exported SaveFilesAsArtifactsResult in the three tests that
were re-declaring its shape.
- Drop a beforeEach that re-installed the module mock factory's own
default behaviour.
- Collapse 'artifacts ? {...result, ...artifacts} : result' in both
tools: spreading undefined is already a no-op.
This was referenced Aug 2, 2026
Open
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
No existing issue.
Problem:
RunSkillScriptToolandRunSkillInlineScriptTooldeliver the files a script produced by writing them onto the host filesystem and returning their paths (run_skill_script_tool.ts:145,run_skill_inline_script_tool.ts:144). ADK already has a first-class, storage-backed channel for handing produced files back to a session —Context.saveArtifact()(core/src/agents/context.ts:103), backed by the configuredSessionArtifactService— but the skill script tools never used it. Outputs were therefore invisible toload_artifacts, to the artifact REST surface, and to later turns of the session.Solution: a new opt-in
SkillToolsetoption,saveOutputsAsArtifacts, that additionally saves each output file to the artifact service and reports the result.core/src/utils/artifact_utils.tsexportssaveFilesAsArtifacts(context, files). It saves each file withcontext.saveArtifact(name, {inlineData: {data, mimeType}})— the same API and the samePartshape the built-in code-execution flow uses, soeventActions.artifactDelta[name] = versionis recorded and the artifact is announced on the tool event like every other ADK artifact write. It is placed incore/src/utils/and named generically because it operates on code-execution output files; nothing in it mentions skills.materializeFiles(), so the two cannot drift. Saving happens after materialization, so a collision-renamedoutput_2.txtis stored under the name the tool actually reports.savedArtifacts: [{filename, version}]andartifactSaveErrors: [{filename, error}]. The two arrays together account for every entry ofoutputFiles, so no file can go silently missing from both.Design decisions worth calling out for review:
saveOutputsAsArtifactsdefaults tofalse, so every existing caller's result object is unchanged key-for-key and no artifact API is touched.invocationContext.artifactServiceand returnsundefined, and the tools then return the plain result. The equivalent adk-python code path (_code_execution.py:477-488) raises here; that is correct for a mandatory flow but wrong for an opt-in one, where failing a script that already ran would discard more information than it protects. This mirrors the graceful-degradation idiom in adk-python'ssave_files_as_artifacts_plugin.py. The service is pre-checked rather than string-matching the'Artifact service is not initialized.'errorContext.saveArtifactthrows.artifactSaveErrors; remaining files are still attempted andstdout/stderr/outputFilesare still returned.user:filenames are refused. A leadinguser:escalates an artifact from session scope to cross-session user scope (in_memory_artifact_service.ts:229). Output filenames originate from executed script content, i.e. a model-influenced trust boundary, so such a name is refused and recorded as anArtifactSaveErrorinstead of being saved.Buffer.from(content, contentEncoding)idiom asfile_utils.ts:62-63, so the artifact carries the same bytes that were written to disk.Explicitly out of scope: this saves artifacts in addition to materializing files. It does not change where files are written, and does not delete them afterwards. Running skill scripts without accumulating files on the host is separate work and is not claimed here.
Collision check (required before implementation): I scanned all 415 open PRs on the fork (
gh pr list --repo AmaadMartin/adk-js --state open --limit 1000) and diffed every plausibly adjacent one. The relevant finding is #410 "persist skill script output files to the artifact service instead of the agent process cwd" (open, unmerged), which replaces the same two call sites and saves outputs with the samesaveArtifactmechanism. It is not a duplicate of this PR and the two are not stackable: #410 makes saving unconditional and a replacement for host materialization (it removesmaterializeFilesfrom both tools and drops itsprocess.cwd()default), whereas this PR is an opt-in flag defaulting tofalsethat saves in addition to materialization. Gating #410's behaviour behind a default-falseflag would leave outputs neither materialized nor saved. Whichever design is preferred, two pieces here are absent from #410 and worth keeping: theuser:namespace guard, and structured per-fileartifactSaveErrorsin place of a single prose warning string. #437/#353/#298 only make the materialization output directory explicit and do not overlap; #130/#91/#95/#89/#88/#83 port the runner-levelSaveFilesAsArtifactsPlugin(input blobs / code-execution flow), not the skill script tools.Note on one flagged pattern in the diff:
as unknown as InvocationContextappears in the new test's context fixture. That is the established idiom for faking anInvocationContextin this repo — 22 existing files incore/test/use it, including all four skill test files touched here. Noany, no@ts-expect-error, no@ts-ignore, and noeslint-disablewere added anywhere in this change.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.
Added
core/test/utils/artifact_utils_test.ts(7 cases: per-file save with the exactPartasserted; utf-8 encoded exactly once; base64 passed through unchanged;artifactDeltarecorded; no artifact service →undefined+ warning; one save rejects → reported per file while the rest still save;user:filename refused without asaveArtifactcall). Added four cases each torun_skill_script_tool_test.tsandrun_skill_inline_script_tool_test.ts(option off; option on; option on with no artifact service; option on with a rejecting save), and two toskill_toolset_test.tsfor the option default and override. Existing tests were not modified beyond adding an optionalartifactServiceparameter to each file'screateMockContexthelper; no test was deleted, skipped, or weakened.Commands run on the pushed commit:
Coverage of the new code (
--coverage.include='core/src/utils/artifact_utils.ts'): 100% statements, 100% branches, 100% functions, 100% lines. Every statement added to the two tools is hit, and both sides of both new branches (the option gate and theartifacts ? ... : resultternary) are exercised.Proof the tests can fail. Each new test was run against mutated source and confirmed to FAIL:
catchbody replaced with a no-op → 3 tests fail, e.g.surfaces a failed save without failing the script:AssertionError: expected [] to deeply equal [ { filename: 'chart.png', …(1) } ].if (file.name.startsWith(USER_NAMESPACE_PREFIX))→if (false)→refuses a filename in the user namespace without calling saveArtifactfails:AssertionError: expected [ { …(2) }, …(1) ] to deeply equal [ { filename: 'allowed.txt', …(1) } ].if (!this.toolset.saveOutputsAsArtifacts) return result;deleted → 2 tests fail, incl.AssertionError: expected "saveArtifact" to not be called at all, but actually been called 1 times.return undefinedtothrow→ 3 tests fail across all three test files.Buffer.from(file.content, 'utf-8')(a genuine double-encode) → 2 tests fail:AssertionError: expected 'WW1sdVlYSjVJR0o1ZEdWeg==' to be 'YmluYXJ5IGJ5dGVz'. Noted for honesty: a first attempt at this mutation (Buffer.from(content, file.contentEncoding)for both branches) was not caught, becauseBuffer.from(x, 'base64').toString('base64')is the identity on canonical base64 — that mutant is semantically equivalent, not a coverage gap, which is why the mutation above was used instead.Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.
An automated round trip against real components (no mocks) is included in
tests/integration/tools/run_skill_script_tool_test.ts: it runs the existingcreate_file.jsskill script through a realUnsafeLocalCodeExecutorwith a realInMemoryArtifactService, assertssavedArtifactsreportsoutput_from_script.txtat version 0, and loads that artifact back out of the service to confirm it decodes tohello from script file. It unlinks the materialized file so no stray file is left in the repo.To verify by hand:
InMemoryArtifactServiceand an agent whoseSkillToolsetis constructed with{codeExecutor, saveOutputsAsArtifacts: true}.savedArtifacts, and that the file is listed by theload_artifactsflow / the artifact tab of the dev UI.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.
CI note (Windows)
The first CI run failed only on
run-tests (windows-latest), in the new integration test. The cause is a pre-existing defect inUnsafeLocalCodeExecutor, not in this change: it skips a skill's own input resources when collecting outputs by comparinginputFiles[].name(scripts/create_file.js) with a path fromfs.readdir(scripts\create_file.json Windows). The separator mismatch means every input resource is reported as an output — and then re-materialized intoprocess.cwd(), which is where the_2…_11suffixes in the failure output came from. The executor is untouched by this branch and the behaviour is present onmaintoday; the surrounding tests never noticed because they match with.find()rather than asserting the whole list.The integration test now matches the expected artifact with
toContainEqualinstead of pinning the entire list, since the executor's output-detection behaviour is not what it is testing. The exact byte round-trip assertion is unchanged, and the test was re-verified to still fail when the feature is broken (making the tool returnresultwithout spreading the artifact fields fails it). All fourrun-testsjobs (ubuntu, macos, windows, and the aggregate) pass on the pushed commit. The underlying executor bug is filed separately rather than fixed here, as it is independent of this change.Revision after complexity review
user:is now owned in one place. Rather than adding a fifth copy of the prefix,core/src/artifacts/base_artifact_service.tsnow exportsUSER_NAMESPACE_PREFIXandfileHasUserNamespace(), and the four places that previously hardcoded the string import it:file_artifact_service.ts(a private const of the same name),in_memory_artifact_service.ts(a private copy of the helper, now deleted), andgcs_artifact_service.ts(two bare'user:'literals, one of which paired with a magicfilename.substring(5)that is nowsubstring(USER_NAMESPACE_PREFIX.length)). All 99 pre-existing artifact-service tests (core/test/artifacts/) pass unchanged, which is what pins that this refactor is behaviour-preserving.SaveFilesAsArtifactsResult, whichartifact_utils.tsalready exports (type ArtifactAugmentedResult = CodeExecutionResult & SaveFilesAsArtifactsResult); the integration test no longer re-spells the field types inline. Type declarations only — no test case changed.beforeEachinrun_skill_inline_script_tool_test.tsthat re-installed the behaviour the module mock factory already declares. (The equivalent hook inrun_skill_script_tool_test.tsis kept: that file's factory is a barevi.fn(), so there it is load-bearing.)artifacts ? {...result, ...artifacts} : resultto{...result, ...(await saveFilesAsArtifacts(...))}in both tools — spreadingundefinedis already a no-op, so the ternary was a branch with no behaviour behind it. The "no artifact service" test at each call site still passes unchanged.as unknown as InvocationContextfixture cast, which the review flagged as non-blocking (it is the established idiom, with 40 pre-existing occurrences undercore/test, and noInvocationContexttest factory exists to use instead).Re-verified after the revision: 206 unit tests pass (
core/test/utils/artifact_utils_test.ts,core/test/tools/skills/,core/test/artifacts/), the integration test passes,npm run buildandnpm run lintare clean,tscis unchanged at the 281 pre-existing errors, and theuser:-guard and swallowed-failure mutations were re-run against the refactored code and still fail the tests they are supposed to fail.Related inconsistency, not fixed here. The sibling path at
core/src/agents/processors/code_execution_request_processor.ts:504-509saves code-executor output files withsaveArtifact({filename: outputFile.name, ...})and nouser:guard, so the same trust boundary is enforced in this change but not there. It is pre-existing and independent of this PR, so it is not bundled into this diff.