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 @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -591,6 +593,23 @@ public Builder workspace(Path workspace) {
return this;
}

/**
* Sets the project workspace directory exposed to the built-in file tools.
*
* <p>{@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.
*
* <p>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
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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();
Expand All @@ -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));
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1221,6 +1250,7 @@ private SubagentFactory buildGeneralPurposeFactory(
final List<Hook> capturedHooks = List.copyOf(this.hooks);
final AgentSkillRepository capturedSkillRepo = this.skillRepository;
final boolean capturedUseLegacyXmlWorkspaceContext = this.useLegacyXmlWorkspaceContext;
final Path capturedProjectWorkspace = this.projectWorkspace;

return () -> {
Builder sub =
Expand All @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String, Object> 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<SubagentEntry> 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<String, Object> 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)
Expand All @@ -228,6 +321,35 @@ private static String joinAllText(List<Msg> msgs) {
return msgs.stream().map(Msg::getTextContent).collect(Collectors.joining("\n"));
}

private static ToolResultBlock callTool(
HarnessAgent agent, String toolName, Map<String, Object> 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");
Expand Down
Loading