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 189f1bade..76c86bb27 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
@@ -42,6 +42,7 @@
import io.agentscope.core.tool.Toolkit;
import io.agentscope.harness.agent.filesystem.AbstractFilesystem;
import io.agentscope.harness.agent.filesystem.AbstractSandboxFilesystem;
+import io.agentscope.harness.agent.filesystem.LocalFilesystem;
import io.agentscope.harness.agent.filesystem.LocalFilesystemSpec;
import io.agentscope.harness.agent.filesystem.LocalFilesystemWithShell;
import io.agentscope.harness.agent.filesystem.RemoteFilesystemSpec;
@@ -447,6 +448,7 @@ public static class Builder {
// Harness-specific params
private Path workspace;
+ private Path projectWorkspace;
private String environmentMemory;
private AbstractFilesystem abstractFilesystem;
private Session session;
@@ -591,6 +593,23 @@ public Builder workspace(Path workspace) {
return this;
}
+ /**
+ * Sets the project workspace directory exposed to the built-in file tools.
+ *
+ *
{@link #workspace(Path)} remains the harness workspace used for {@code AGENTS.md},
+ * memory, sessions, skills, and other agent state. When this option is set, built-in file
+ * tools such as {@code read_file}, {@code write_file}, {@code edit_file}, {@code grep_files}
+ * and {@code glob_files} operate against this project directory instead, without applying
+ * the per-user namespace that is used by the harness workspace backend.
+ *
+ *
This is useful for applications that keep agent state in one stable directory but need
+ * the agent to work on arbitrary project folders.
+ */
+ public Builder projectWorkspace(Path projectWorkspace) {
+ this.projectWorkspace = projectWorkspace;
+ return this;
+ }
+
/**
* Sets the workspace directory from a filesystem path string (resolved with
* {@link Path#of(String, String...)}). Equivalent to {@link #workspace(Path)} with
@@ -947,6 +966,8 @@ public HarnessAgent build() {
defaultSandboxContext.getClient(), stateStore, resolvedAgentId);
sandboxLifecycleHook = new SandboxLifecycleHook(sandboxManager, capturedSandboxFs);
}
+ AbstractFilesystem toolFilesystem =
+ resolveProjectFilesystem(projectWorkspace, filesystem);
WorkspaceManager wsManager = new WorkspaceManager(resolvedWorkspace, filesystem);
wsManager.validate();
@@ -991,7 +1012,7 @@ public HarnessAgent build() {
}
if (toolResultEvictionConfig != null) {
- allHooks.add(new ToolResultEvictionHook(filesystem, toolResultEvictionConfig));
+ allHooks.add(new ToolResultEvictionHook(toolFilesystem, toolResultEvictionConfig));
}
SessionPersistenceHook sessionPersistenceHook = new SessionPersistenceHook();
@@ -1015,7 +1036,7 @@ public HarnessAgent build() {
agentToolkit.registerTool(getTool);
agentToolkit.registerTool(new SessionSearchTool(wsManager));
- agentToolkit.registerTool(new FilesystemTool(filesystem));
+ agentToolkit.registerTool(new FilesystemTool(toolFilesystem));
if (filesystem instanceof AbstractSandboxFilesystem sandbox) {
agentToolkit.registerTool(new ShellExecuteTool(sandbox));
@@ -1149,6 +1170,14 @@ private AbstractFilesystem resolveFilesystem(
return new LocalFilesystemWithShell(workspace, nsFactory);
}
+ private AbstractFilesystem resolveProjectFilesystem(
+ Path projectWorkspace, AbstractFilesystem workspaceFilesystem) {
+ if (projectWorkspace == null) {
+ return workspaceFilesystem;
+ }
+ return new LocalFilesystem(projectWorkspace, true, 10);
+ }
+
private void validateDistributedSandboxConfig(
Session effectiveSession, SandboxContext sandboxContext) {
if (sandboxFilesystemSpec.getSandboxStateStore() == null
@@ -1221,6 +1250,7 @@ private SubagentFactory buildGeneralPurposeFactory(
final List capturedHooks = List.copyOf(this.hooks);
final AgentSkillRepository capturedSkillRepo = this.skillRepository;
final boolean capturedUseLegacyXmlWorkspaceContext = this.useLegacyXmlWorkspaceContext;
+ final Path capturedProjectWorkspace = this.projectWorkspace;
return () -> {
Builder sub =
@@ -1238,6 +1268,9 @@ private SubagentFactory buildGeneralPurposeFactory(
if (capturedSkillRepo != null) {
sub.skillRepository(capturedSkillRepo);
}
+ if (capturedProjectWorkspace != null) {
+ sub.projectWorkspace(capturedProjectWorkspace);
+ }
if (capturedBackend != null) {
sub.abstractFilesystem(capturedBackend);
}
diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentTest.java
index f3f3ca386..404e75907 100644
--- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentTest.java
+++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentTest.java
@@ -23,17 +23,24 @@
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+import io.agentscope.core.agent.Agent;
import io.agentscope.core.agent.RuntimeContext;
import io.agentscope.core.message.Msg;
import io.agentscope.core.message.MsgRole;
import io.agentscope.core.message.TextBlock;
+import io.agentscope.core.message.ToolResultBlock;
+import io.agentscope.core.message.ToolUseBlock;
import io.agentscope.core.model.ChatResponse;
import io.agentscope.core.model.Model;
import io.agentscope.core.model.ToolSchema;
import io.agentscope.core.session.Session;
+import io.agentscope.core.tool.ToolCallParam;
+import io.agentscope.core.util.JsonUtils;
import io.agentscope.harness.agent.filesystem.LocalFilesystem;
import io.agentscope.harness.agent.filesystem.RemoteFilesystemSpec;
import io.agentscope.harness.agent.hook.SubagentsHook.SubagentEntry;
+import io.agentscope.harness.agent.sandbox.SandboxDistributedOptions;
+import io.agentscope.harness.agent.sandbox.filesystem.DockerFilesystemSpec;
import io.agentscope.harness.agent.store.InMemoryStore;
import io.agentscope.harness.agent.workspace.WorkspaceConstants;
import java.nio.file.Files;
@@ -217,6 +224,92 @@ void remoteFilesystemSpec_sharesMemoryMdInNonsandboxMode() throws Exception {
store.get(List.of("agents", "agent-a", "users", "_default"), "/MEMORY.md") != null);
}
+ @Test
+ void sandboxFilesystemMode_fileToolsUseSandboxBackendByDefault() throws Exception {
+ Files.createDirectories(workspace);
+ Path localTarget = workspace.resolve("should-not-be-local.txt");
+
+ HarnessAgent agent =
+ HarnessAgent.builder()
+ .name("agent")
+ .model(stubModel("ok"))
+ .workspace(workspace)
+ .filesystem(new DockerFilesystemSpec())
+ .sandboxDistributed(
+ SandboxDistributedOptions.builder()
+ .requireDistributed(false)
+ .build())
+ .build();
+ Map writeInput =
+ Map.of("path", "/should-not-be-local.txt", "content", "sandbox-only");
+
+ ToolResultBlock result =
+ agent.getDelegate()
+ .getToolkit()
+ .callTool(
+ ToolCallParam.builder()
+ .toolUseBlock(
+ ToolUseBlock.builder()
+ .id("call-write")
+ .name("write_file")
+ .input(writeInput)
+ .content(
+ JsonUtils.getJsonCodec()
+ .toJson(writeInput))
+ .build())
+ .input(writeInput)
+ .build())
+ .block();
+
+ String text = joinToolResultText(result);
+ assertTrue(
+ text.contains("No active sandbox") || text.contains("sandbox filesystem"),
+ "file tool should use the sandbox proxy outside a sandbox call context: " + text);
+ assertTrue(
+ Files.notExists(localTarget),
+ "sandbox file tool must not fall back to the local workspace");
+ }
+
+ @Test
+ void generalPurposeSubagent_inheritsProjectWorkspaceForFileTools() throws Exception {
+ Files.createDirectories(workspace);
+ Path projectWorkspace = Files.createTempDirectory("agentscope-project-workspace");
+ Path projectTarget = projectWorkspace.resolve("from-subagent.txt");
+ Path stateWorkspaceTarget = workspace.resolve("from-subagent.txt");
+
+ List entries =
+ HarnessAgent.builder()
+ .name("main")
+ .model(stubModel("ok"))
+ .workspace(workspace)
+ .projectWorkspace(projectWorkspace)
+ .buildSubagentEntries(workspace);
+
+ Agent createdAgent =
+ entries.stream()
+ .filter(entry -> entry.name().equals("general-purpose"))
+ .findFirst()
+ .orElseThrow()
+ .factory()
+ .create();
+ assertTrue(createdAgent instanceof HarnessAgent);
+ HarnessAgent subagent = (HarnessAgent) createdAgent;
+
+ Map writeInput =
+ Map.of("path", "/from-subagent.txt", "content", "subagent-project");
+ ToolResultBlock result = callTool(subagent, "write_file", writeInput);
+
+ assertTrue(
+ joinToolResultText(result).contains("Written to /from-subagent.txt"),
+ "subagent file tool should write successfully: " + joinToolResultText(result));
+ assertTrue(
+ Files.readString(projectTarget).contains("subagent-project"),
+ "subagent file tool should use inherited projectWorkspace");
+ assertTrue(
+ Files.notExists(stateWorkspaceTarget),
+ "subagent file tool must not write into the Harness state workspace");
+ }
+
private static Msg userText(String text) {
return Msg.builder()
.role(MsgRole.USER)
@@ -228,6 +321,35 @@ private static String joinAllText(List msgs) {
return msgs.stream().map(Msg::getTextContent).collect(Collectors.joining("\n"));
}
+ private static ToolResultBlock callTool(
+ HarnessAgent agent, String toolName, Map input) {
+ return agent.getDelegate()
+ .getToolkit()
+ .callTool(
+ ToolCallParam.builder()
+ .toolUseBlock(
+ ToolUseBlock.builder()
+ .id("call-" + toolName)
+ .name(toolName)
+ .input(input)
+ .content(JsonUtils.getJsonCodec().toJson(input))
+ .build())
+ .input(input)
+ .build())
+ .block();
+ }
+
+ private static String joinToolResultText(ToolResultBlock result) {
+ if (result == null) {
+ return "";
+ }
+ return result.getOutput().stream()
+ .filter(TextBlock.class::isInstance)
+ .map(TextBlock.class::cast)
+ .map(TextBlock::getText)
+ .collect(Collectors.joining("\n"));
+ }
+
private static Model stubModel(String assistantText) {
Model model = mock(Model.class);
when(model.getModelName()).thenReturn("stub-model");