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 a37f982f06..a1297c34cd 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
@@ -2034,6 +2034,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/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..7ab06aaa62
--- /dev/null
+++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/artifact/DirectArtifactDeliveryTarget.java
@@ -0,0 +1,62 @@
+/*
+ * 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.
+ * 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.
+ * 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.
+ * 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(
+ 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..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
@@ -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,94 @@ 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 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};
diff --git a/docs/v2/en/docs/harness/sandbox.md b/docs/v2/en/docs/harness/sandbox.md
index 1fa129c8bb..f994025e08 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 51c3f7fe32..3169491de7 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 快照**,两层机制各管一事: