Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2034,6 +2034,9 @@ public Builder enableAgentTracingLog(boolean enabled) {
*
* <p>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.
*
* <p>Note: the tool reads files from the agent filesystem, so it is also suppressed when
* {@link #disableFilesystemTools()} is used. Combining both leaves the tool
Expand Down
Original file line number Diff line number Diff line change
@@ -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) {}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
* file bytes from the filesystem and delegates the actual transport to the configured
* {@code ArtifactDeliveryTarget}, which the application implements.
*
* <p>For sandbox-side upload without downloading bytes into the host JVM, implement
* {@link DirectArtifactDeliveryTarget} instead.
*
* <p>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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>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);

Comment thread
q827505931 marked this conversation as resolved.
/** 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");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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}.
*
* <p>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
Expand Down Expand Up @@ -122,27 +124,37 @@ public String deliverArtifact(
}
boolean effectiveForce = Boolean.TRUE.equals(force);

List<FileDownloadResponse> 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(
Comment thread
q827505931 marked this conversation as resolved.
runtimeContext,
filesystem,
new ArtifactDeliverySource(
normalized, effectiveFileName, description, effectiveForce));
} else {
List<FileDownloadResponse> 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";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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};
Expand Down
18 changes: 18 additions & 0 deletions docs/v2/en/docs/harness/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
18 changes: 18 additions & 0 deletions docs/v2/zh/docs/harness/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 快照**,两层机制各管一事:
Expand Down
Loading