From 2c612192685d88597ede1de040cc1bbec66807a3 Mon Sep 17 00:00:00 2001 From: qiuyangyang Date: Tue, 8 Sep 2026 11:04:10 +0800 Subject: [PATCH 1/2] feat(harness): allow direct artifact delivery without host buffering --- .../artifact/ArtifactDeliverySource.java | 27 +++++++ .../artifact/ArtifactDeliveryTarget.java | 3 + .../DirectArtifactDeliveryTarget.java | 57 ++++++++++++++ .../agent/tool/ArtifactDeliveryTool.java | 56 ++++++++------ .../agent/tool/ArtifactDeliveryToolTest.java | 76 +++++++++++++++++++ docs/v2/en/docs/harness/sandbox.md | 18 +++++ docs/v2/zh/docs/harness/sandbox.md | 18 +++++ 7 files changed, 233 insertions(+), 22 deletions(-) create mode 100644 agentscope-harness/src/main/java/io/agentscope/harness/agent/artifact/ArtifactDeliverySource.java create mode 100644 agentscope-harness/src/main/java/io/agentscope/harness/agent/artifact/DirectArtifactDeliveryTarget.java diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/artifact/ArtifactDeliverySource.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/artifact/ArtifactDeliverySource.java new file mode 100644 index 0000000000..6d944ef848 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/artifact/ArtifactDeliverySource.java @@ -0,0 +1,27 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.harness.agent.artifact; + +/** + * Metadata for an artifact delivered without downloading its bytes into the host JVM. + * + * @param filePath normalized path in the supplied agent filesystem, not a host path + * @param fileName validated plain destination file name + * @param description optional artifact description + * @param force whether an existing artifact may be overwritten + */ +public record ArtifactDeliverySource( + String filePath, String fileName, String description, boolean force) {} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/artifact/ArtifactDeliveryTarget.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/artifact/ArtifactDeliveryTarget.java index b291cb7704..7754014dd8 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/artifact/ArtifactDeliveryTarget.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/artifact/ArtifactDeliveryTarget.java @@ -25,6 +25,9 @@ * file bytes from the filesystem and delegates the actual transport to the configured * {@code ArtifactDeliveryTarget}, which the application implements. * + *

For sandbox-side upload without downloading bytes into the host JVM, implement + * {@link DirectArtifactDeliveryTarget} instead. + * *

Configure on {@link * io.agentscope.harness.agent.HarnessAgent.Builder#artifactDeliveryTarget(ArtifactDeliveryTarget)}. * When set, the {@code deliver_artifact} tool is registered and the sandbox system prompt instructs diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/artifact/DirectArtifactDeliveryTarget.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/artifact/DirectArtifactDeliveryTarget.java new file mode 100644 index 0000000000..5c91eebc4f --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/artifact/DirectArtifactDeliveryTarget.java @@ -0,0 +1,57 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.harness.agent.artifact; + +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.harness.agent.filesystem.AbstractFilesystem; + +/** + * Opt-in delivery SPI that receives a source path instead of materialized file bytes. + * + *

Configure through the existing {@code artifactDeliveryTarget} builder method. The tool + * invokes {@link #deliverFromFilesystem} without calling {@code downloadFiles}. Implementations + * can upload within the sandbox using a backend SDK or shell, or stream from their backing store. + * Existing byte-based targets continue to use {@link ArtifactDeliveryTarget} unchanged. + * + *

The target must resolve the source in the supplied filesystem and runtime context, respecting + * its routing and access policy; a normalized path is not necessarily a native sandbox path. + * It must check source existence and report upload failures and destination conflicts. Do not + * interpolate untrusted paths into shell commands or expose upload credentials in tool results. + * A failure never triggers an automatic byte-download fallback. + */ +@FunctionalInterface +public interface DirectArtifactDeliveryTarget extends ArtifactDeliveryTarget { + + /** + * Delivers a file directly from its backing environment. + * + * @param runtimeContext per-call runtime, possibly {@code null}; use it for sandbox resolution + * @param filesystem the active agent filesystem, potentially an overlay or routed filesystem + * @param source validated destination metadata and normalized source path; contains no bytes + * @return delivery result, never {@code null} + */ + ArtifactDeliveryResult deliverFromFilesystem( + RuntimeContext runtimeContext, + AbstractFilesystem filesystem, + ArtifactDeliverySource source); + + /** Direct targets require the source filesystem rather than a byte-based request. */ + @Override + default ArtifactDeliveryResult deliver( + RuntimeContext runtimeContext, ArtifactDeliveryRequest request) { + return ArtifactDeliveryResult.fail("Direct artifact delivery requires a source filesystem"); + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/ArtifactDeliveryTool.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/ArtifactDeliveryTool.java index 1b0c53514b..8121a069ae 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/ArtifactDeliveryTool.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/ArtifactDeliveryTool.java @@ -20,15 +20,17 @@ import io.agentscope.core.tool.ToolParam; import io.agentscope.harness.agent.artifact.ArtifactDeliveryRequest; import io.agentscope.harness.agent.artifact.ArtifactDeliveryResult; +import io.agentscope.harness.agent.artifact.ArtifactDeliverySource; import io.agentscope.harness.agent.artifact.ArtifactDeliveryTarget; +import io.agentscope.harness.agent.artifact.DirectArtifactDeliveryTarget; import io.agentscope.harness.agent.filesystem.AbstractFilesystem; import io.agentscope.harness.agent.filesystem.model.FileDownloadResponse; import io.agentscope.harness.agent.workspace.WorkspacePathNormalizer; import java.util.List; /** - * Agent-callable {@code deliver_artifact} tool: downloads a file from the agent filesystem (e.g. a - * sandbox workspace) and delegates the transport to a configured {@link ArtifactDeliveryTarget}. + * Agent-callable {@code deliver_artifact} tool: delegates delivery of a file from the agent filesystem + * (e.g. a sandbox workspace) to a configured {@link ArtifactDeliveryTarget}. * *

This is the supported way for a sandboxed agent to hand an artifact it produced (report, * document, image, archive) to a destination outside the sandbox. It is only registered when an @@ -122,27 +124,37 @@ public String deliverArtifact( } boolean effectiveForce = Boolean.TRUE.equals(force); - List responses = - filesystem.downloadFiles(runtimeContext, List.of(normalized)); - if (responses.isEmpty()) { - return "Error: no download response for " + filePath; - } - FileDownloadResponse response = responses.get(0); - if (!response.isSuccess()) { - return "Error: failed to read '" - + filePath - + "' from the workspace: " - + response.error(); - } + ArtifactDeliveryResult result; + if (target instanceof DirectArtifactDeliveryTarget directTarget) { + result = + directTarget.deliverFromFilesystem( + runtimeContext, + filesystem, + new ArtifactDeliverySource( + normalized, effectiveFileName, description, effectiveForce)); + } else { + List responses = + filesystem.downloadFiles(runtimeContext, List.of(normalized)); + if (responses.isEmpty()) { + return "Error: no download response for " + filePath; + } + FileDownloadResponse response = responses.get(0); + if (!response.isSuccess()) { + return "Error: failed to read '" + + filePath + + "' from the workspace: " + + response.error(); + } - ArtifactDeliveryRequest request = - new ArtifactDeliveryRequest( - normalized, - response.content(), - effectiveFileName, - description, - effectiveForce); - ArtifactDeliveryResult result = target.deliver(runtimeContext, request); + ArtifactDeliveryRequest request = + new ArtifactDeliveryRequest( + normalized, + response.content(), + effectiveFileName, + description, + effectiveForce); + result = target.deliver(runtimeContext, request); + } if (result == null) { return "Error: artifact delivery target returned no result"; } diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/ArtifactDeliveryToolTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/ArtifactDeliveryToolTest.java index 2bbd6c47ae..6d0ec54888 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/ArtifactDeliveryToolTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/ArtifactDeliveryToolTest.java @@ -19,17 +19,20 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import io.agentscope.core.agent.RuntimeContext; import io.agentscope.harness.agent.artifact.ArtifactDeliveryRequest; import io.agentscope.harness.agent.artifact.ArtifactDeliveryResult; import io.agentscope.harness.agent.artifact.ArtifactDeliveryTarget; +import io.agentscope.harness.agent.artifact.DirectArtifactDeliveryTarget; import io.agentscope.harness.agent.filesystem.AbstractFilesystem; import io.agentscope.harness.agent.filesystem.model.FileDownloadResponse; import io.agentscope.harness.agent.workspace.WorkspacePathNormalizer; @@ -61,6 +64,79 @@ private ArtifactDeliveryRequest deliverRequest() { return captor.getValue(); } + @Test + void directDelivery_receivesNormalizedSourceWithoutDownloading() { + DirectArtifactDeliveryTarget direct = + (context, sourceFilesystem, source) -> { + assertSame(RT, context); + assertSame(filesystem, sourceFilesystem); + assertEquals("outputs/report.pdf", source.filePath()); + assertEquals("weekly.pdf", source.fileName()); + assertEquals("Weekly report", source.description()); + assertTrue(source.force()); + return ArtifactDeliveryResult.success("uploaded from sandbox"); + }; + tool = + new ArtifactDeliveryTool( + filesystem, WorkspacePathNormalizer.of("/workspace"), direct); + + String result = + tool.deliverArtifact( + RT, "/workspace/outputs/report.pdf", "weekly.pdf", "Weekly report", true); + + assertTrue(result.contains("uploaded from sandbox")); + verifyNoInteractions(filesystem); + } + + @Test + void directDelivery_preservesDefaultsAndNullableContext() { + DirectArtifactDeliveryTarget direct = + (context, sourceFilesystem, source) -> { + assertNull(context); + assertEquals("report.pdf", source.fileName()); + assertNull(source.description()); + assertFalse(source.force()); + return ArtifactDeliveryResult.success(); + }; + tool = new ArtifactDeliveryTool(filesystem, direct); + assertTrue( + tool.deliverArtifact(null, "outputs/report.pdf", null, null, null) + .startsWith("Delivered ")); + verifyNoInteractions(filesystem); + } + + @Test + void directDelivery_failureConflictAndNullNeverFallBackToDownloading() { + for (ArtifactDeliveryResult outcome : + new ArtifactDeliveryResult[] { + ArtifactDeliveryResult.fail("source missing"), + ArtifactDeliveryResult.conflict("name exists"), + null + }) { + DirectArtifactDeliveryTarget direct = (context, sourceFilesystem, source) -> outcome; + tool = new ArtifactDeliveryTool(filesystem, direct); + String result = tool.deliverArtifact(RT, "report.pdf", null, null, null); + if (outcome != null && outcome.conflict()) { + assertTrue(result.contains("already exists")); + assertTrue(result.contains("force=true")); + } else { + assertTrue(result.startsWith("Error:")); + } + } + verifyNoInteractions(filesystem); + } + + @Test + void directDelivery_invalidInputDoesNotInvokeTarget() { + DirectArtifactDeliveryTarget direct = mock(DirectArtifactDeliveryTarget.class); + tool = new ArtifactDeliveryTool(filesystem, direct); + assertTrue(tool.deliverArtifact(RT, " ", null, null, null).startsWith("Error:")); + assertTrue( + tool.deliverArtifact(RT, "report.pdf", "../report.pdf", null, null) + .startsWith("Error:")); + verifyNoInteractions(filesystem, direct); + } + @Test void deliverArtifact_downloadsBytesAndForwardsToTarget_withDefaults() { byte[] content = new byte[] {1, 2, 3}; diff --git a/docs/v2/en/docs/harness/sandbox.md b/docs/v2/en/docs/harness/sandbox.md index 5ecc927a51..7fa4fd2e51 100644 --- a/docs/v2/en/docs/harness/sandbox.md +++ b/docs/v2/en/docs/harness/sandbox.md @@ -236,6 +236,24 @@ The `Sandbox` abstraction's primary data-plane entry point is `exec(command)`. T **Crossing the boundary from the model's point of view.** The file API (upload/download) above is an internal mechanism — invisible to the LLM, and `FilesystemTool` exposes no transfer tools. The supported way for a sandboxed agent to hand an artifact it produced to a destination outside the sandbox is the generic **`deliver_artifact`** tool. It is registered only when you configure an `ArtifactDeliveryTarget` via `HarnessAgent.builder().artifactDeliveryTarget(...)`. The SPI stays business-agnostic — `deliver(RuntimeContext, ArtifactDeliveryRequest) -> ArtifactDeliveryResult` — so destination logic (e.g. WebDAV upload) lives in your application. The tool downloads the file bytes from the sandbox workspace and delegates the transport to the target. Without a configured target, the sandbox workspace prompt states plainly that files cannot leave the container. +For large artifacts, configure a `DirectArtifactDeliveryTarget` to bypass the host JVM byte download: + +```java +DirectArtifactDeliveryTarget target = (runtimeContext, filesystem, source) -> { + // Application-owned uploader: resolve the source in this filesystem/runtime, + // then upload inside the sandbox using its SDK or shell and return the result. + return sandboxUploader.upload(runtimeContext, filesystem, source.filePath(), + source.fileName(), source.description(), source.force()); +}; +HarnessAgent agent = HarnessAgent.builder() + // ... model and workspace configuration ... + .artifactDeliveryTarget(target) + .build(); +``` + +`sandboxUploader` above is application code, not a built-in uploader. Direct targets receive metadata only: the tool never calls `downloadFiles`, including after a failure. The target must check source existence, enforce `force`/conflict behavior, and report upload errors. Resolve the normalized path against the supplied filesystem and runtime context; overlays/routed filesystems may not map it directly to a sandbox-native path. For shell-based uploads, quote paths safely and keep credentials out of tool output. Existing `ArtifactDeliveryTarget` lambdas keep the byte-based behavior. + + ## Kubernetes state persistence: PVC is the first layer The Kubernetes store is fully based on agent-sandbox: sandbox pods are managed by the agent-sandbox controller, and image, resources, and storage are all declared cluster-side in a `SandboxTemplate` / `SandboxWarmPool` — the Java side only claims instances (`SandboxClaim`) and connects. This makes it different from other stores in one important way: **workspace data persistence is primarily the PVC's job, not the Harness snapshot's**. The two layers each own one thing: diff --git a/docs/v2/zh/docs/harness/sandbox.md b/docs/v2/zh/docs/harness/sandbox.md index 67d7b8f840..8d4b18978a 100644 --- a/docs/v2/zh/docs/harness/sandbox.md +++ b/docs/v2/zh/docs/harness/sandbox.md @@ -235,6 +235,24 @@ agent-sandbox 后端不通过 `kubectl exec` 进容器,而是访问运行时 **从模型视角跨越边界。** 上面的文件 API(upload/download)是内部机制——对 LLM 不可见,`FilesystemTool` 不暴露任何传输工具。沙箱中的 agent 把自己产出的产物交给沙箱外目标的受支持方式是通用 **`deliver_artifact`** 工具。只有当你通过 `HarnessAgent.builder().artifactDeliveryTarget(...)` 配置了 `ArtifactDeliveryTarget` 时它才会被注册。该 SPI 保持业务无关——`deliver(RuntimeContext, ArtifactDeliveryRequest) -> ArtifactDeliveryResult`——目标逻辑(例如 WebDAV 上传)由你的应用实现。工具会从沙箱工作区下载文件字节,并把传输委托给 target。未配置 target 时,沙箱工作区提示语会明确说明文件无法离开容器。 +对于大型产物,可以配置 `DirectArtifactDeliveryTarget`,跳过宿主 JVM 的文件字节下载: + +```java +DirectArtifactDeliveryTarget target = (runtimeContext, filesystem, source) -> { + // Application-owned uploader: resolve the source in this filesystem/runtime, + // then upload inside the sandbox using its SDK or shell and return the result. + return sandboxUploader.upload(runtimeContext, filesystem, source.filePath(), + source.fileName(), source.description(), source.force()); +}; +HarnessAgent agent = HarnessAgent.builder() + // ... model and workspace configuration ... + .artifactDeliveryTarget(target) + .build(); +``` + +`sandboxUploader` 是应用自行实现的上传器,并非内置 API。直传 target 只接收元数据:工具不会调用 `downloadFiles`,失败后也不会自动回退下载。target 负责检查源文件、实现 `force`/重名冲突语义并返回上传错误。请结合传入的文件系统和运行时上下文解析规范化路径;overlay/路由文件系统中的路径不一定是沙箱原生路径。通过 shell 上传时,必须安全引用路径,避免在工具输出中泄露凭据。现有 `ArtifactDeliveryTarget` lambda 仍使用字节下载方式。 + + ## Kubernetes 后端的状态保存:PVC 是第一层 Kubernetes 后端完全基于 agent-sandbox:沙箱 pod 由 agent-sandbox 控制器管理,镜像、资源、存储都声明在集群侧的 `SandboxTemplate` / `SandboxWarmPool` 里,Java 侧只负责领取(`SandboxClaim`)和连接。这带来一个和其他后端不同的点——**工作区数据的持久化主要靠 PVC,而不是 Harness 快照**,两层机制各管一事: From 399986ca94425e0fdd3fd9a27300e9542d2bd269 Mon Sep 17 00:00:00 2001 From: qiuyangyang Date: Fri, 11 Sep 2026 18:09:26 +0800 Subject: [PATCH 2/2] docs(harness): clarify direct artifact delivery contract --- .../io/agentscope/harness/agent/HarnessAgent.java | 3 +++ .../artifact/DirectArtifactDeliveryTarget.java | 7 ++++++- .../agent/tool/ArtifactDeliveryToolTest.java | 15 +++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java index 7f2e23ed20..c611adcb6f 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java @@ -2022,6 +2022,9 @@ public Builder enableAgentTracingLog(boolean enabled) { * *

The tool is exposed only to the main agent — it is not propagated to automatically * constructed subagents, which return plain text results for the main agent to deliver. + * {@link io.agentscope.harness.agent.artifact.DirectArtifactDeliveryTarget} implementations + * are supported only by the registered {@code deliver_artifact} tool because their inherited + * byte-based {@link ArtifactDeliveryTarget#deliver} method has no source filesystem. * *

Note: the tool reads files from the agent filesystem, so it is also suppressed when * {@link #disableFilesystemTools()} is used. Combining both leaves the tool diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/artifact/DirectArtifactDeliveryTarget.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/artifact/DirectArtifactDeliveryTarget.java index 5c91eebc4f..7ab06aaa62 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/artifact/DirectArtifactDeliveryTarget.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/artifact/DirectArtifactDeliveryTarget.java @@ -25,6 +25,9 @@ * invokes {@link #deliverFromFilesystem} without calling {@code downloadFiles}. Implementations * can upload within the sandbox using a backend SDK or shell, or stream from their backing store. * Existing byte-based targets continue to use {@link ArtifactDeliveryTarget} unchanged. + * Direct targets are supported only by the framework's {@code deliver_artifact} tool; callers that + * invoke them solely through the base {@link ArtifactDeliveryTarget#deliver} method receive a + * failure result because that method has no source filesystem. * *

The target must resolve the source in the supplied filesystem and runtime context, respecting * its routing and access policy; a normalized path is not necessarily a native sandbox path. @@ -40,7 +43,9 @@ public interface DirectArtifactDeliveryTarget extends ArtifactDeliveryTarget { * * @param runtimeContext per-call runtime, possibly {@code null}; use it for sandbox resolution * @param filesystem the active agent filesystem, potentially an overlay or routed filesystem - * @param source validated destination metadata and normalized source path; contains no bytes + * @param source validated destination metadata and normalized source path; contains no bytes. + * The source path is not guaranteed to exist. The implementation must check existence and + * any size limit, and enforce the requested overwrite/conflict behavior. * @return delivery result, never {@code null} */ ArtifactDeliveryResult deliverFromFilesystem( diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/ArtifactDeliveryToolTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/ArtifactDeliveryToolTest.java index 6d0ec54888..6e79b43a70 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/ArtifactDeliveryToolTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/ArtifactDeliveryToolTest.java @@ -137,6 +137,21 @@ void directDelivery_invalidInputDoesNotInvokeTarget() { verifyNoInteractions(filesystem, direct); } + @Test + void directTarget_byteBasedEntryPointReturnsFailure() { + DirectArtifactDeliveryTarget direct = + (context, sourceFilesystem, source) -> ArtifactDeliveryResult.success(); + + ArtifactDeliveryResult result = + direct.deliver( + RT, + new ArtifactDeliveryRequest( + "report.pdf", new byte[] {1}, "report.pdf", null, false)); + + assertFalse(result.successful()); + assertTrue(result.error().contains("requires a source filesystem")); + } + @Test void deliverArtifact_downloadsBytesAndForwardsToTarget_withDefaults() { byte[] content = new byte[] {1, 2, 3};