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");