Skip to content

feat(plugins): add SaveFilesAsArtifactsPlugin, porting adk-python's file-upload offload - #1412

Open
svetanis wants to merge 1 commit into
google:mainfrom
svetanis:feature/save-files-as-artifacts-plugin
Open

feat(plugins): add SaveFilesAsArtifactsPlugin, porting adk-python's file-upload offload#1412
svetanis wants to merge 1 commit into
google:mainfrom
svetanis:feature/save-files-as-artifacts-plugin

Conversation

@svetanis

@svetanis svetanis commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Please ensure you have read the contribution guide before creating a pull request.

Link to Issue or Description of Change

1. Link to an existing issue (if applicable):

2. Or, if no issue exists, describe the change:

Problem:

adk-python deprecated save_input_blobs_as_artifacts in favour of SaveFilesAsArtifactsPlugin.
adk-java has the parameter but not the plugin, so RunConfig.saveInputBlobsAsArtifacts(true) is the
only way to keep uploaded bytes out of every later LLM request — and it loses two things:

  1. The uploaded file name is discarded. Runner names every artifact
    artifact_{invocationId}_{index} and never reads Blob.displayName, so an upload of report.pdf
    is stored under an opaque id.
  2. Nothing is reported to the session. The user event carries a state delta only, so
    EventActions.artifactDelta stays empty and the session's artifact bookkeeping never records the
    upload.

Solution:

A new SaveFilesAsArtifactsPlugin in com.google.adk.plugins, registered like any other plugin. For
each inlineData part of the incoming user message it saves the part to the configured
BaseArtifactService — named from Blob.displayName, falling back to the existing
artifact_{invocationId}_{index} — replaces it with [Uploaded Artifact: "<name>"] in the message
that reaches the model and the session, and reports the saved versions through
EventActions.artifactDelta. A failed save keeps the original part and logs, without failing the
invocation. The failure is reported only through the logger, matching adk-python — so an artifact
service that is misconfigured or unreachable degrades silently to no offload, with each upload
continuing to reach the model inline.

No existing file is modified, and the only new public surface is the plugin class itself — the
hand-off helper is package-private. Registration uses the three surfaces that already exist
(Runner.Builder.plugins, App.Builder.plugins, InMemoryRunner).

Four new files, all in com/google/adk/plugins/:

File Concern
core/src/main/java/…/SaveFilesAsArtifactsPlugin.java the three hooks: save, name, replace, clean up
core/src/main/java/…/PendingArtifactDelta.java package-private onUserMessageCallbackbeforeAgentCallback hand-off
core/src/test/java/…/SaveFilesAsArtifactsPluginTest.java 26 tests
core/src/test/java/…/PendingArtifactDeltaTest.java 12 tests

PendingArtifactDelta is package-private and tested on its own — no new package, no new public type.

Four deliberate divergences from adk-python:

  • The pending-delta stash key is invocation-scoped (temp:{plugin}:pending_delta:{invocationId}).
    Python keys on the plugin name alone, so a stash stranded by a failed invocation is drained by the
    next invocation in that session, reporting artifacts it never saved. The invocation id makes a
    stranded entry inert.
  • The stash is cleared by overwriting with an empty map, never State.remove. remove writes the
    State.REMOVED sentinel into the event's state delta, which does not survive a JSON round trip.
  • With no artifact service configured the hook returns Maybe.empty(), where Python returns the
    unmodified message. Same outcome for this plugin, but PluginManager early-exits on the first
    non-empty return — so Python's form also suppresses every later plugin's
    on_user_message_callback whenever this one is disabled. An empty Maybe cannot.
  • adk-python's 20 MB inline-data cap is not ported. Upstream rejects an over-cap upload before
    saving it, so the file is dropped rather than offloaded. The cap guards the inline-request limit,
    but the blob has already been replaced by a placeholder here, so it never reaches the model either
    way. This port saves whatever it is handed and lets the artifact service decide what it can hold.

attach_file_reference is deliberately not ported. It needs
get_artifact_version(...).canonical_uri, which has no equivalent on BaseArtifactService — adding
it means changing the artifact-service SPI and every implementation. That is a separate, larger,
separately reviewable change. This port is placeholder-only, which is behaviourally what
RunConfig.saveInputBlobsAsArtifacts already does.

Testing Plan

Unit Tests:

  • I have added or updated unit tests for my change.
  • All unit tests pass locally.

38 tests across two classes, mirroring GlobalInstructionPluginTest. Both contexts are mocked;
State, Session and InMemoryArtifactService are real, so the temp: state hand-off between the
hooks is exercised rather than stubbed.

[INFO] Running com.google.adk.plugins.PendingArtifactDeltaTest
[INFO] Tests run: 12, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.749 s
[INFO] Running com.google.adk.plugins.SaveFilesAsArtifactsPluginTest
[INFO] Tests run: 26, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.552 s
[INFO]
[INFO] Results:
[INFO] Tests run: 38, Failures: 0, Errors: 0, Skipped: 0

SaveFilesAsArtifactsPluginTest (26) covers the display-name vs generated-name choice, the
placeholder swap, part order, multiple and duplicate uploads, the artifact delta and its drain, the
afterRunCallback cleanup, the failed-save and partial-failure paths, both constructors, and that the caller's Content comes back untouched, for all three ways genai lets you build one.

PendingArtifactDeltaTest (12) covers the hand-off's own rules: the temp: key convention,
invocation and plugin scoping, clearing by overwrite rather than State.remove, reading back state
that has lost its type information, and that a second drain writes nothing — a write would set the
state delta, and BaseAgent emits an event for any before-agent callback that leaves one.

Manual End-to-End (E2E) Tests:

  • Verified end-to-end, against the offline stub and against gemini-3.5-flash.

A six-row harness builds a Runner explicitly (an InMemoryRunner cannot substitute a failing
artifact service) and registers this PR's plugin class, so every row below exercises the code in
this diff rather than a copy of it. Below, flag means
RunConfig.saveInputBlobsAsArtifacts(true) and plugin means SaveFilesAsArtifactsPlugin.

Row Mechanism Upload Result
A flag named blob stored as artifact_<invocationId>_1, artifactDelta (none)
B plugin named blob stored as report.pdf, artifactDelta {report.pdf=0}
C plugin unnamed blob falls back to artifact_<invocationId>_1 — parity with the flag
D plugin text only passes straight through
E plugin, failing store named blob original part kept, invocation still completes
F plugin + flag named blob plugin wins; one artifact, no double save, no crash

A and B are the change, side by side:

--- Run A: RunConfig.saveInputBlobsAsArtifacts(true) ---
  artifacts stored in the session   : artifact_e-eb41628a-9812-43a0-88d0-7b121706190b_1
  EventActions.artifactDelta        : (none)
  message appended to the session   : "Uploaded file: artifact_e-eb41628a-…_1. It has been saved to the artifacts"

--- Run B: SaveFilesAsArtifactsPlugin ---
  artifacts stored in the session   : report.pdf
  EventActions.artifactDelta        : {report.pdf=0}
  message appended to the session   : "[Uploaded Artifact: "report.pdf"]"

Both offload the payload losslessly and both show the model a placeholder — the change is the file
name and the bookkeeping, not the offload.

Row F is the migration row: the plugin strips every inlineData part before the runner's flag loop
sees the message, so enabling both is safe and there is no double save.

Checklist

  • I have read the CONTRIBUTING.md document.
  • My pull request contains a single commit.
  • I have performed a self-review of my own code.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.
  • I have manually tested my changes end-to-end.
  • Any dependent changes have been merged and published in downstream modules.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Port SaveFilesAsArtifactsPlugin from adk-python

1 participant