Skip to content

fix(formatter): make Base64 tool-result temp paths deterministic - #3200

Open
liugy789 wants to merge 2 commits into
agentscope-ai:mainfrom
liugy789:fix/base64-tool-result-temp-path-determinism
Open

liugy789 wants to merge 2 commits into
agentscope-ai:mainfrom
liugy789:fix/base64-tool-result-temp-path-determinism

Conversation

@liugy789

Copy link
Copy Markdown
Contributor

AgentScope-Java Version

2.0.3-SNAPSHOT, based on main@594343c6ef29f26eea3cbbb2d6f92b1e5b9696de

Description

Fixes #3199

Formatting the same unchanged message history could previously produce different tool-result text when Base64-backed media was converted to a temporary-file reference.

The previous implementations in AbstractBaseFormatter and GeminiMessageConverter used Files.createTempFile(...), so every formatting pass embedded a new random path into the provider request.

Impact before this change

  • The serialized prefix of an otherwise unchanged request changed between formatting passes.
  • For providers using continuous prompt or prefix caching, the changed temporary path could break cache reuse from that point onward in the serialized history.
  • Cached input-token counts could decrease while more input tokens were processed as uncached input.
  • Depending on provider behavior, request latency and input-token cost could increase.
  • Repeated formatting created redundant temporary files.
  • Request replay and debugging were less reproducible.

Changes

This change:

  • adds shared content-addressed Base64 materialization to MediaUtils;
  • derives file identity from SHA-256 of the decoded media bytes;
  • stores deterministic targets inside a JVM-unique temporary directory;
  • sanitizes MIME-derived file extensions;
  • publishes complete staging files before exposing the deterministic target;
  • uses atomic publication when supported;
  • validates the target after the non-atomic fallback;
  • protects publication with bounded striped locks;
  • rejects symbolic links, non-regular targets, and mismatched existing content;
  • wraps malformed Base64 payloads as checked materialization failures so existing formatter degradation behavior is preserved;
  • preserves the original publication error when staging cleanup also fails;
  • preserves the existing formatter method signatures and serialized text structure;
  • reuses the shared implementation from AbstractBaseFormatter and GeminiMessageConverter.

Impact after this change

Within the same JVM, identical decoded media content and the same media type reuse the same materialized path. The serialized tool-result text therefore remains byte-stable when the unchanged message history is formatted repeatedly.

This preserves the opportunity for providers with prompt or prefix caching to reuse the same historical prefix. It avoids actively invalidating cache reuse merely because the formatter created a new random temporary path.

The change does not guarantee cache hits. Actual cache behavior remains provider-specific and depends on cache policy, cache boundaries, minimum prefix lengths, expiration, token accounting, and billing rules.

Different decoded media content produces different content-addressed targets. The formatter remains stateless, and existing formatter method signatures and serialized text structure remain unchanged.

The deterministic guarantee is process-local. Cross-process path determinism, temporary-file TTL, global cleanup scheduling, and broader multimodal protocol changes are intentionally outside this PR.

Invalid existing targets fail closed rather than being overwritten.

Compatibility

  • Existing formatter method signatures are unchanged.
  • Existing tool-result serialized text structure is unchanged.
  • URL-backed media handling is unchanged.
  • Provider-specific multimodal protocols are unchanged.
  • No external dependencies were added.
  • Identical media may be shared by multiple formatted results within the same JVM; callers must not modify or delete the returned materialized file.

Verification

  • mvn spotless:check: passed.
  • mvn -DskipTests ... compile for the affected modules and reactor dependencies: passed.
  • git show --check: passed.
  • The final code tree was previously verified with 3010 tests, 0 failures, and 0 errors:
    • MediaUtilsTest: 41/41;
    • DashScopeMultiAgentFormatterTest: 37/37;
    • GeminiMessageConverterTest: 34/34.

A subsequent local rerun was blocked by an environment-level Mockito/Byte Buddy inline-agent attachment failure in the test JVM. The affected failure is in the local test runtime's agent-attachment mechanism; CI verification remains required before claiming the checks are green.

The tests cover:

  • deterministic reuse for identical decoded content;
  • equivalent padded and unpadded Base64 representations;
  • different paths for different content;
  • MIME-derived suffix sanitization;
  • malformed Base64 degradation;
  • corrupt existing-target rejection;
  • symbolic-link and non-regular-target rejection;
  • concurrent publication;
  • explicit non-atomic publication behavior;
  • JVM-unique temporary-directory placement;
  • DashScope and Gemini formatter-level determinism;
  • exact materialized file contents;
  • preservation of successful publication when staging cleanup fails;
  • preservation of the original write error when staging cleanup also fails.

Non-goals

  • temporary-file TTL;
  • global cleanup scheduling;
  • cross-process path determinism;
  • provider-specific prompt-cache control protocols;
  • broader multimodal protocol changes;
  • unrelated provider formatting paths.

Checklist

  • Code uses the repository's Conventional Commit format.
  • Regression tests cover both confirmed provider paths.
  • Spotless check passed.
  • Affected production modules compile.
  • Existing formatter method signatures and serialized text structure are preserved.
  • Prompt-cache and prefix-cache impact is documented.
  • Final CI checks are passing.
  • Maintainer review is complete.

@codecov

codecov Bot commented Sep 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.81818% with 18 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
.../java/io/agentscope/core/formatter/MediaUtils.java 82.47% 10 Missing and 7 partials ⚠️
...entscope/core/formatter/AbstractBaseFormatter.java 0.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Deterministic, content-addressed Base64 media temp files — the previously random Files.createTempFile path made tool-result formatting non-reproducible and spawned a new file per call. The rewrite is correct and carefully hardened: SHA-256 content identity, striped locking, staging file + ATOMIC_MOVE publication with fallback, NOFOLLOW_LINKS checks that reject symlink/irregular targets, MIME subtype sanitisation, and existing-file validation instead of silent overwrite. Error handling preserves the primary failure and suppresses cleanup errors. Shared by both AbstractBaseFormatter and GeminiMessageConverter, so the fix lands consistently across model providers. Test coverage is excellent (content identity incl. padding, concurrency ×8, atomic and non-atomic moves, symlink rejection, cleanup-failure and SecurityException paths, plus an end-to-end DashScope determinism check). CLA signed, CI green on all platforms. Two non-blocking performance/operability notes are pinned inline; neither affects correctness. Approved.


Automated review by github-manager-bot


synchronized (materializationLock(target)) {
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
validateMaterializedFile(target, decodedData);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: on the reuse path the full payload is re-read and compared byte-by-byte on every format call. For large media that is re-formatted repeatedly in an agent loop (image/audio tool results), this turns the "reuse" fast path into a repeated O(size) disk read. Consider caching verified targets in-process (e.g. a small set of already-validated absolute paths, or verifying only when Files.size(target) != decodedData.length differs), keeping the full validation for the publication path where it is genuinely needed.

synchronized (MediaUtils.class) {
directory = materializedMediaDirectory;
if (directory == null) {
directory = Files.createTempDirectory("agentscope_media_");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: the per-process agentscope_media_* directory is never evicted or cleaned (Runtime.deleteOnExit is explicitly out of scope here and would not help for long-running servers). This is no worse than the previous per-call Files.createTempFile behaviour, but for long-lived harness/distribution processes streaming many distinct tool results it is unbounded growth in the OS temp dir. Worth a follow-up issue for a size/count-bounded eviction policy, or documenting that operators should prune java.io.tmpdir/agentscope_media_*.

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.

[Bug]: Base64 tool-result formatting creates nondeterministic temporary paths

2 participants