diff --git a/agentscope-examples/agents/agentscope-builder/README.md b/agentscope-examples/agents/agentscope-builder/README.md index d9dc74fd77..2cdad1f015 100644 --- a/agentscope-examples/agents/agentscope-builder/README.md +++ b/agentscope-examples/agents/agentscope-builder/README.md @@ -150,6 +150,23 @@ Agents run directly on the host with `LocalFilesystemWithShell`. Each user's wor **When to use:** Single-node deployments, local development, trusted environments. +### Shared read-only workspace directories + +Local and remote filesystem modes can expose selected directories from the physical agent +workspace root as a shared baseline for every tenant: + +```yaml +builder: + workspace-store: + shared-read-prefixes: docs/,knowledge-base/ +``` + +The equivalent environment variable is +`BUILDER_WORKSPACE_SHARED_READ_PREFIXES=docs/,knowledge-base/`. Reads fall back to the shared +directory (for example `~/.agentscope/builder/workspace/docs/`), while tenant writes use +copy-on-write and remain in that tenant's namespace. Shared source files are never modified by an +agent write. + ### Sandbox Mode ```yaml diff --git a/agentscope-examples/agents/agentscope-builder/src/main/java/io/agentscope/builder/runtime/BuilderBootstrap.java b/agentscope-examples/agents/agentscope-builder/src/main/java/io/agentscope/builder/runtime/BuilderBootstrap.java index 5eb41c8028..18828bacdb 100644 --- a/agentscope-examples/agents/agentscope-builder/src/main/java/io/agentscope/builder/runtime/BuilderBootstrap.java +++ b/agentscope-examples/agents/agentscope-builder/src/main/java/io/agentscope/builder/runtime/BuilderBootstrap.java @@ -109,6 +109,7 @@ public final class BuilderBootstrap { private final Map agents; private final AgentscopeConfig loadedConfig; private final List registeredChannels; + private final List> globalConfigurators; private final HarnessGateway gateway; private final ChannelManager channelManager; @@ -119,6 +120,7 @@ private BuilderBootstrap( Map agents, AgentscopeConfig loadedConfig, List registeredChannels, + List> globalConfigurators, HarnessGateway gateway, ChannelManager channelManager) { this.cwd = Objects.requireNonNull(cwd, "cwd"); @@ -128,6 +130,8 @@ private BuilderBootstrap( this.loadedConfig = loadedConfig != null ? loadedConfig : new AgentscopeConfig(); this.registeredChannels = registeredChannels != null ? List.copyOf(registeredChannels) : List.of(); + this.globalConfigurators = + globalConfigurators != null ? List.copyOf(globalConfigurators) : List.of(); this.gateway = gateway; this.channelManager = channelManager; } @@ -234,6 +238,25 @@ public Path configPath() { return configPath; } + /** + * Applies the cross-cutting configuration registered through {@link + * Builder#configureAllAgents(Consumer)} to an agent created after bootstrap startup. + * + *

Dynamic agents must use this method so filesystem, state-store, middleware, and similar + * application-wide settings remain consistent with agents loaded during {@link Builder#build()}. + * Call it after applying the dynamic agent's own fields and toolkit, but before {@link + * HarnessAgent.Builder#build()}; this mirrors startup ordering and gives global configurators + * the same final override semantics for both startup and dynamically created agents. + * + * @param builder fully initialized dynamic builder awaiting global configuration and build + */ + public void configureDynamicAgent(HarnessAgent.Builder builder) { + Objects.requireNonNull(builder, "builder"); + for (Consumer configurator : globalConfigurators) { + configurator.accept(builder); + } + } + List registeredChannels() { return registeredChannels; } @@ -646,6 +669,7 @@ public BuilderBootstrap build() throws IOException { Map.copyOf(built), fileConfig, resolvedChannels, + globalConfigurators, gateway, channelMgr); } diff --git a/agentscope-examples/agents/agentscope-builder/src/main/java/io/agentscope/builder/web/catalog/AgentCatalogService.java b/agentscope-examples/agents/agentscope-builder/src/main/java/io/agentscope/builder/web/catalog/AgentCatalogService.java index 7b73c74626..94d7964979 100644 --- a/agentscope-examples/agents/agentscope-builder/src/main/java/io/agentscope/builder/web/catalog/AgentCatalogService.java +++ b/agentscope-examples/agents/agentscope-builder/src/main/java/io/agentscope/builder/web/catalog/AgentCatalogService.java @@ -74,7 +74,6 @@ public class AgentCatalogService { private final BuilderBootstrap builderBootstrap; private final UserAgentDefinitionStore store; private final Model model; - private final io.agentscope.builder.web.toolbus.ToolEventBus toolEventBus; private final TemplateRegistry templateRegistry; private final SharedWorkspacePaths sharedWorkspacePaths; private final UserStore userStore; @@ -99,7 +98,6 @@ public AgentCatalogService( BuilderBootstrap builderBootstrap, UserAgentDefinitionStore store, Optional modelOpt, - io.agentscope.builder.web.toolbus.ToolEventBus toolEventBus, TemplateRegistry templateRegistry, SharedWorkspacePaths sharedWorkspacePaths, UserStore userStore, @@ -107,7 +105,6 @@ public AgentCatalogService( this.builderBootstrap = builderBootstrap; this.store = store; this.model = modelOpt.orElse(null); - this.toolEventBus = toolEventBus; this.templateRegistry = templateRegistry; this.sharedWorkspacePaths = sharedWorkspacePaths; this.userStore = userStore; @@ -860,9 +857,10 @@ private String buildAndRegisterUca(String userId, UserAgentDefinitionStore.Store builderBootstrap.channelManager())); b.toolkit(ucaToolkit); - // Inject ToolNotificationMiddleware so user-custom agents also publish tool-call events. - b.middleware( - new io.agentscope.builder.web.toolbus.ToolNotificationMiddleware(toolEventBus)); + // Keep this call after per-agent fields/toolkit setup and before build(). This matches the + // startup build order, so global configurators intentionally get the same final say over + // the filesystem spec (including shared read prefixes), state store, and middleware. + builderBootstrap.configureDynamicAgent(b); HarnessAgent agent = b.build(); diff --git a/agentscope-examples/agents/agentscope-builder/src/main/java/io/agentscope/builder/web/config/BuilderConfig.java b/agentscope-examples/agents/agentscope-builder/src/main/java/io/agentscope/builder/web/config/BuilderConfig.java index efa1056336..db6139ddcc 100644 --- a/agentscope-examples/agents/agentscope-builder/src/main/java/io/agentscope/builder/web/config/BuilderConfig.java +++ b/agentscope-examples/agents/agentscope-builder/src/main/java/io/agentscope/builder/web/config/BuilderConfig.java @@ -36,6 +36,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.List; import java.util.Optional; import javax.sql.DataSource; import org.slf4j.Logger; @@ -113,6 +114,9 @@ public class BuilderConfig { @Value("${builder.workspace:${claw.workspace:}}") private String workspaceDir; + @Value("${builder.workspace-store.shared-read-prefixes:}") + private List sharedReadPrefixes; + // ----------------------------------------------------------------- // Model bean — only created when an api-key is set AND no other // Model bean is already present in the context. The conditional @@ -227,12 +231,17 @@ public BuilderBootstrap builderBootstrap( b.middleware(new ToolNotificationMiddleware(toolEventBus)); b.stateStore(stateStore); if (localStore) { - b.filesystem(new LocalFilesystemSpec().isolationScope(IsolationScope.USER)); + LocalFilesystemSpec filesystemSpec = + new LocalFilesystemSpec().isolationScope(IsolationScope.USER); + sharedReadPrefixes.forEach(filesystemSpec::addSharedPrefix); + b.filesystem(filesystemSpec); } else { - b.filesystem( + RemoteFilesystemSpec filesystemSpec = new RemoteFilesystemSpec(baseStore) .isolationScope(IsolationScope.USER) - .addSharedPrefix("activity/")); + .addSharedPrefix("activity/"); + sharedReadPrefixes.forEach(filesystemSpec::addSharedPrefix); + b.filesystem(filesystemSpec); } }); diff --git a/agentscope-examples/agents/agentscope-builder/src/main/resources/application.yml b/agentscope-examples/agents/agentscope-builder/src/main/resources/application.yml index bdb6a0e0c8..00971585d8 100644 --- a/agentscope-examples/agents/agentscope-builder/src/main/resources/application.yml +++ b/agentscope-examples/agents/agentscope-builder/src/main/resources/application.yml @@ -78,16 +78,15 @@ builder: workspace: ${BUILDER_WORKSPACE:${CLAW_WORKSPACE:}} # ---- Workspace storage topology (multi-tenant filesystem) ---- - # Builder always runs every HarnessAgent on a CompositeFilesystem: a read-only - # LocalFilesystem over `${builder.workspace}/.agentscope/workspace` (templates, - # AGENTS.md, default skills/subagents/knowledge shipped on disk) blended with - # per-(owner, agent) RemoteFilesystem routes for memory/, MEMORY.md, sessions/, - # tasks/, skills/, subagents/. The remote routes are backed by a `BaseStore` - # Spring bean — operators MUST provide one (e.g. SqliteBaseStore, a Redis- - # backed implementation, etc.); a missing bean fails context refresh fast. + # Agents use LocalFilesystemSpec with a local AgentStateStore, or + # RemoteFilesystemSpec when a distributed AgentStateStore is supplied. Both + # topologies honor shared-read-prefixes with the same copy-on-write semantics. workspace-store: local: max-file-size-mb: 10 + # Comma-separated workspace-relative directories exposed to every tenant as a + # shared read-only baseline. Tenant writes are copy-on-write and remain isolated. + shared-read-prefixes: ${BUILDER_WORKSPACE_SHARED_READ_PREFIXES:docs} # ---- DashScope model (used when no Model Spring bean is present) ---- # Set DASHSCOPE_API_KEY env var or override api-key below. diff --git a/agentscope-examples/agents/agentscope-builder/src/test/java/io/agentscope/builder/BuilderBootstrapSmokeTest.java b/agentscope-examples/agents/agentscope-builder/src/test/java/io/agentscope/builder/BuilderBootstrapSmokeTest.java index 123043b8b2..cbe7ca2ce1 100644 --- a/agentscope-examples/agents/agentscope-builder/src/test/java/io/agentscope/builder/BuilderBootstrapSmokeTest.java +++ b/agentscope-examples/agents/agentscope-builder/src/test/java/io/agentscope/builder/BuilderBootstrapSmokeTest.java @@ -15,6 +15,7 @@ */ package io.agentscope.builder; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; @@ -22,13 +23,18 @@ import static org.mockito.Mockito.when; import io.agentscope.builder.runtime.BuilderBootstrap; +import io.agentscope.core.agent.RuntimeContext; import io.agentscope.core.message.Msg; import io.agentscope.core.message.TextBlock; import io.agentscope.core.model.ChatResponse; import io.agentscope.core.model.Model; +import io.agentscope.harness.agent.HarnessAgent; +import io.agentscope.harness.agent.IsolationScope; +import io.agentscope.harness.agent.filesystem.spec.LocalFilesystemSpec; import io.agentscope.harness.agent.gateway.channel.ChannelConfig; import io.agentscope.harness.agent.gateway.channel.DmScope; import io.agentscope.harness.agent.gateway.channel.chatui.ChatUiChannel; +import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import java.util.Map; @@ -103,6 +109,45 @@ void multiAgent_defaultRoutesToMain() throws Exception { assertTrue(reply.getTextContent().contains("from-main")); } + @Test + void dynamicAgentUsesGlobalFilesystemConfigurationForItsOwnWorkspace() throws Exception { + Path customWorkspace = tempDir.resolve("custom-agent-workspace"); + Files.createDirectories(customWorkspace.resolve("docs")); + Files.writeString(customWorkspace.resolve("docs/guide.md"), "custom shared guide"); + + Model model = stubModel("dynamic-agent-reply"); + BuilderBootstrap bootstrap = + BuilderBootstrap.builder() + .skipConfigFile(true) + .cwd(tempDir) + .model(model) + .configureAgent("main", b -> b.name("main")) + .configureAllAgents( + b -> + b.filesystem( + new LocalFilesystemSpec() + .isolationScope(IsolationScope.USER) + .addSharedPrefix("docs"))) + .mainAgent("main") + .build(); + + HarnessAgent.Builder dynamicBuilder = + HarnessAgent.builder() + .agentId("dynamic") + .name("dynamic") + .model(model) + .workspace(customWorkspace); + bootstrap.configureDynamicAgent(dynamicBuilder); + HarnessAgent dynamicAgent = dynamicBuilder.build(); + + String content = + dynamicAgent + .workspaceFor("alice", null) + .readManagedWorkspaceFileUtf8( + RuntimeContext.builder().userId("alice").build(), "docs/guide.md"); + assertEquals("custom shared guide", content); + } + private static Model stubModel(String assistantText) { Model model = mock(Model.class); when(model.getModelName()).thenReturn("stub-model"); diff --git a/agentscope-examples/agents/agentscope-builder/src/test/java/io/agentscope/builder/web/catalog/AgentCatalogServiceFilesystemUserIdTest.java b/agentscope-examples/agents/agentscope-builder/src/test/java/io/agentscope/builder/web/catalog/AgentCatalogServiceFilesystemUserIdTest.java index 1c35c402ae..7a9cedd0eb 100644 --- a/agentscope-examples/agents/agentscope-builder/src/test/java/io/agentscope/builder/web/catalog/AgentCatalogServiceFilesystemUserIdTest.java +++ b/agentscope-examples/agents/agentscope-builder/src/test/java/io/agentscope/builder/web/catalog/AgentCatalogServiceFilesystemUserIdTest.java @@ -27,7 +27,6 @@ import io.agentscope.builder.web.auth.UserStore.UserRecord; import io.agentscope.builder.web.share.AgentAclService; import io.agentscope.builder.web.template.TemplateRegistry; -import io.agentscope.builder.web.toolbus.ToolEventBus; import io.agentscope.builder.web.workspace.SharedWorkspacePaths; import io.agentscope.harness.agent.HarnessAgent; import java.util.List; @@ -107,7 +106,6 @@ void constructor_installsResolverOnGateway() { bootstrap, mock(UserAgentDefinitionStore.class), Optional.empty(), - mock(ToolEventBus.class), mock(TemplateRegistry.class), mock(SharedWorkspacePaths.class), mock(UserStore.class), @@ -142,7 +140,6 @@ private static AgentCatalogService newService( bootstrap, store, Optional.empty(), - mock(ToolEventBus.class), mock(TemplateRegistry.class), mock(SharedWorkspacePaths.class), userStore, diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/IsolationScope.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/IsolationScope.java index 2882aba33d..2562bcb206 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/IsolationScope.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/IsolationScope.java @@ -17,15 +17,16 @@ import io.agentscope.harness.agent.filesystem.remote.RemoteFilesystem; import io.agentscope.harness.agent.filesystem.remote.store.NamespaceFactory; +import io.agentscope.harness.agent.filesystem.spec.LocalFilesystemSpec; import io.agentscope.harness.agent.filesystem.spec.RemoteFilesystemSpec; import java.util.List; /** * Controls how agent state is isolated and shared across calls. * - *

This enum is the canonical isolation-scope definition used by both the sandbox filesystem - * backend ({@link io.agentscope.harness.agent.sandbox.SandboxContext}) and the remote filesystem - * backend ({@link RemoteFilesystemSpec}). + *

This enum is the canonical isolation-scope definition used by the sandbox filesystem backend + * ({@link io.agentscope.harness.agent.sandbox.SandboxContext}), the remote filesystem backend + * ({@link RemoteFilesystemSpec}), and local shared-prefix overlays ({@link LocalFilesystemSpec}). * *

Sandbox semantics: the scope determines which key is used when persisting and loading * {@code _sandbox.json} state. Calls that resolve to the same scope key will @@ -37,6 +38,11 @@ * key-value store. Different scopes produce different namespace prefixes, controlling which calls * share the same view of stored files. * + *

Local shared-prefix semantics: the scope determines the local workspace namespace used + * for copy-on-write overrides of directories registered through {@link + * LocalFilesystemSpec#addSharedPrefix(String)}. The un-namespaced directory remains the shared, + * read-only baseline. + * *

Scope selection: *

    *
  • {@link #USER} – shared across all sessions of the same user; the default. When diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/spec/LocalFilesystemSpec.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/spec/LocalFilesystemSpec.java index 8e2531a208..dc8849e182 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/spec/LocalFilesystemSpec.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/spec/LocalFilesystemSpec.java @@ -15,23 +15,35 @@ */ package io.agentscope.harness.agent.filesystem.spec; +import io.agentscope.core.agent.RuntimeContext; import io.agentscope.harness.agent.IsolationScope; import io.agentscope.harness.agent.filesystem.AbstractFilesystem; +import io.agentscope.harness.agent.filesystem.CompositeFilesystem; import io.agentscope.harness.agent.filesystem.OverlayFilesystem; import io.agentscope.harness.agent.filesystem.ProjectAwareOverlay; import io.agentscope.harness.agent.filesystem.local.LocalFilesystem; import io.agentscope.harness.agent.filesystem.local.LocalFilesystemWithShell; +import io.agentscope.harness.agent.filesystem.model.ExecuteResponse; +import io.agentscope.harness.agent.filesystem.model.FileInfo; +import io.agentscope.harness.agent.filesystem.model.GlobResult; +import io.agentscope.harness.agent.filesystem.model.GrepMatch; +import io.agentscope.harness.agent.filesystem.model.GrepResult; +import io.agentscope.harness.agent.filesystem.model.LsResult; import io.agentscope.harness.agent.filesystem.remote.store.NamespaceFactory; import io.agentscope.harness.agent.filesystem.sandbox.AbstractSandboxFilesystem; +import io.agentscope.harness.agent.filesystem.util.SharedPrefixUtils; import io.agentscope.harness.agent.workspace.LocalFsMode; import io.agentscope.harness.agent.workspace.PathPolicy; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; /** * Specification for the local filesystem mode (with shell execution). @@ -51,6 +63,7 @@ public class LocalFilesystemSpec { private int executeTimeoutSeconds = LocalFilesystemWithShell.DEFAULT_EXECUTE_TIMEOUT; private int maxOutputBytes = 100_000; private final Map env = new LinkedHashMap<>(); + private final Set sharedPrefixes = new LinkedHashSet<>(); private boolean inheritEnv = false; /** @@ -194,6 +207,40 @@ public IsolationScope getIsolationScope() { return isolationScope; } + /** + * Adds a workspace-relative prefix whose files are exposed as a shared, read-only baseline. + * Per-user writes still land in the namespaced workspace upper layer and therefore override, + * rather than mutate, the shared source files. + * + *

    For example, {@code addSharedPrefix("docs/")} exposes {@code /docs/} to every + * user while a write by user {@code bob} lands under {@code /bob/docs/}. + * + * @param prefix workspace-relative directory prefix + * @return this spec + */ + public LocalFilesystemSpec addSharedPrefix(String prefix) { + if (prefix != null && !prefix.isBlank()) { + sharedPrefixes.add(SharedPrefixUtils.normalizeDirectoryPrefix(prefix)); + } + return this; + } + + /** Replaces the configured shared, read-only workspace prefixes. */ + public LocalFilesystemSpec sharedPrefixes(Collection prefixes) { + sharedPrefixes.clear(); + if (prefixes != null) { + for (String prefix : prefixes) { + addSharedPrefix(prefix); + } + } + return this; + } + + /** Returns the normalized shared prefixes in registration order. */ + public Set getSharedPrefixes() { + return Collections.unmodifiableSet(new LinkedHashSet<>(sharedPrefixes)); + } + /** * Adds an extra host directory the agent is allowed to access by absolute path under * {@link LocalFsMode#ROOTED}. {@code null} entries are ignored. @@ -304,14 +351,197 @@ public AbstractFilesystem toFilesystem(Path workspace, NamespaceFactory localNam inheritEnv, localNamespaceFactory, effectiveProject); - LocalFilesystem lower = new LocalFilesystem(effectiveProject, true, 10, null); + AbstractFilesystem lower = new LocalFilesystem(effectiveProject, true, 10, null); + AbstractFilesystem defaultFilesystem; if (projectWritable) { LocalFilesystem projectFs = new LocalFilesystem( effectiveProject, mode, pathPolicy, 10, localNamespaceFactory); - return new ProjectAwareOverlay( - (AbstractSandboxFilesystem) upper, lower, projectFs, workspace); + defaultFilesystem = + new ProjectAwareOverlay( + (AbstractSandboxFilesystem) upper, lower, projectFs, workspace); + } else { + defaultFilesystem = OverlayFilesystem.of(upper, lower); } - return OverlayFilesystem.of(upper, lower); + + if (sharedPrefixes.isEmpty()) { + return defaultFilesystem; + } + + Map routes = new LinkedHashMap<>(); + for (String prefix : sharedPrefixes) { + String routeSegment = SharedPrefixUtils.routeSegment(prefix); + routes.put( + prefix, + localOverlayRoute(workspace, routeSegment, localNamespaceFactory, pathPolicy)); + } + return new LocalCompositeFilesystem( + defaultFilesystem, routes, (AbstractSandboxFilesystem) defaultFilesystem); + } + + /** + * Builds the copy-on-write view for one shared prefix. The upper store is rooted at the main + * workspace and receives a tenant namespace plus {@code routeSegment}, so every tenant writes + * to its own override directory. The lower store is rooted directly at the un-namespaced + * shared directory and therefore acts as the common read-only baseline. {@link + * OverlayFilesystem} resolves reads from upper to lower while routing all mutations to upper. + */ + private static OverlayFilesystem localOverlayRoute( + Path workspace, + String routeSegment, + NamespaceFactory localNamespaceFactory, + PathPolicy pathPolicy) { + NamespaceFactory routeNamespace = + rc -> { + List namespace = + localNamespaceFactory != null + ? new ArrayList<>(localNamespaceFactory.getNamespace(rc)) + : new ArrayList<>(); + // USER/SESSION normally provide a namespace. AGENT/GLOBAL (and missing + // runtime identity) do not, so keep their writable overlay in a hidden + // directory rather than writing into the shared source itself. + if (namespace.isEmpty()) { + namespace.add(".shared-overrides"); + } + for (String segment : routeSegment.split("/")) { + if (!segment.isBlank()) { + namespace.add(segment); + } + } + return namespace; + }; + LocalFilesystem upper = new RouteLocalFilesystem(workspace, pathPolicy, routeNamespace); + Path sharedRoot = workspace.resolve(routeSegment); + LocalFilesystem lower = + new RouteLocalFilesystem(sharedRoot, PathPolicy.of(List.of(sharedRoot)), null); + return new LocalRouteOverlay(upper, lower); + } + + /** + * CompositeFilesystem supplies route-local paths with a leading slash. Strip it inside the + * Local implementation so LocalFilesystem applies the tenant namespace instead of treating + * the path as an already-scoped host absolute path. + */ + private static final class RouteLocalFilesystem extends LocalFilesystem { + + private RouteLocalFilesystem( + Path root, PathPolicy pathPolicy, NamespaceFactory namespaceFactory) { + super(root, LocalFsMode.ROOTED, pathPolicy, 10, namespaceFactory); + } + + @Override + protected Path resolvePath(RuntimeContext runtimeContext, String path) { + return super.resolvePath(runtimeContext, relativeRoutePath(path)); + } + } + + /** + * Copy-on-write overlay for a single shared route. + * + *

    The delegate {@link LocalFilesystem} instances report paths relative to their physical + * roots. Before those results return to {@link CompositeFilesystem}, this adapter converts + * them to route-local absolute paths (for example, {@code /guide.md}). The composite layer can + * then prepend the registered prefix exactly once, without exposing tenant namespace segments + * or duplicating the shared prefix in {@code ls}, {@code grep}, and {@code glob} results. + */ + private static final class LocalRouteOverlay extends OverlayFilesystem { + + /** + * Creates a route-local CoW view. + * + * @param upper tenant-namespaced writable overrides for this shared prefix + * @param lower un-namespaced shared baseline used only when upper has no override + */ + private LocalRouteOverlay(AbstractFilesystem upper, AbstractFilesystem lower) { + super(upper, lower); + } + + @Override + public LsResult ls(RuntimeContext runtimeContext, String path) { + LsResult result = super.ls(runtimeContext, path); + if (!result.isSuccess() || result.entries() == null) { + return result; + } + return LsResult.success(result.entries().stream().map(this::routeFileInfo).toList()); + } + + @Override + public GrepResult grep( + RuntimeContext runtimeContext, String pattern, String path, String glob) { + GrepResult result = super.grep(runtimeContext, pattern, path, glob); + if (!result.isSuccess() || result.matches() == null) { + return result; + } + return GrepResult.success( + result.matches().stream() + .map( + match -> + new GrepMatch( + routeAbsolutePath(match.path()), + match.line(), + match.text())) + .toList()); + } + + @Override + public GlobResult glob(RuntimeContext runtimeContext, String pattern, String path) { + GlobResult result = super.glob(runtimeContext, pattern, path); + if (!result.isSuccess() || result.matches() == null) { + return result; + } + return GlobResult.success(result.matches().stream().map(this::routeFileInfo).toList()); + } + + private FileInfo routeFileInfo(FileInfo file) { + return new FileInfo( + routeAbsolutePath(file.path()), + file.isDirectory(), + file.size(), + file.modifiedAt()); + } + } + + /** Keeps LocalFilesystemSpec's existing shell capability outside the routed file view. */ + private static final class LocalCompositeFilesystem extends CompositeFilesystem + implements AbstractSandboxFilesystem { + + private final AbstractSandboxFilesystem shell; + + private LocalCompositeFilesystem( + AbstractFilesystem defaultFilesystem, + Map routes, + AbstractSandboxFilesystem shell) { + super(defaultFilesystem, routes); + this.shell = shell; + } + + @Override + public String id() { + return shell.id(); + } + + @Override + public ExecuteResponse execute( + RuntimeContext runtimeContext, String command, Integer timeoutSeconds) { + return shell.execute(runtimeContext, command, timeoutSeconds); + } + } + + private static String relativeRoutePath(String path) { + if (path == null) { + return null; + } + String normalized = path.replace('\\', '/'); + int first = 0; + while (first < normalized.length() && normalized.charAt(first) == '/') { + first++; + } + String relative = normalized.substring(first); + return relative.isEmpty() ? "." : relative; + } + + private static String routeAbsolutePath(String path) { + String relative = relativeRoutePath(path); + return ".".equals(relative) ? "/" : "/" + relative; } } diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/util/SharedPrefixUtils.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/util/SharedPrefixUtils.java new file mode 100644 index 0000000000..9be5d1d9c4 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/util/SharedPrefixUtils.java @@ -0,0 +1,47 @@ +/* + * 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.filesystem.util; + +import io.agentscope.harness.agent.filesystem.spec.LocalFilesystemSpec; + +/** Shared directory-prefix normalization used by {@link LocalFilesystemSpec}. */ +public final class SharedPrefixUtils { + + private SharedPrefixUtils() {} + + public static String normalizeDirectoryPrefix(String prefix) { + String normalized = prefix.replace('\\', '/').strip(); + while (normalized.startsWith("/")) { + normalized = normalized.substring(1); + } + while (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + if (normalized.isBlank()) { + throw new IllegalArgumentException("shared prefix must not resolve to workspace root"); + } + for (String segment : normalized.split("/")) { + if (segment.isBlank() || ".".equals(segment) || "..".equals(segment)) { + throw new IllegalArgumentException("invalid shared prefix: " + prefix); + } + } + return normalized + "/"; + } + + public static String routeSegment(String normalizedPrefix) { + return normalizedPrefix.substring(0, normalizedPrefix.length() - 1); + } +} diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/filesystem/LocalFilesystemSpecSharedPrefixTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/filesystem/LocalFilesystemSpecSharedPrefixTest.java new file mode 100644 index 0000000000..92ba7aa25f --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/filesystem/LocalFilesystemSpecSharedPrefixTest.java @@ -0,0 +1,176 @@ +/* + * 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.filesystem; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.harness.agent.IsolationScope; +import io.agentscope.harness.agent.filesystem.model.GlobResult; +import io.agentscope.harness.agent.filesystem.model.GrepResult; +import io.agentscope.harness.agent.filesystem.model.LsResult; +import io.agentscope.harness.agent.filesystem.sandbox.AbstractSandboxFilesystem; +import io.agentscope.harness.agent.filesystem.spec.LocalFilesystemSpec; +import io.agentscope.harness.agent.filesystem.util.SharedPrefixUtils; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class LocalFilesystemSpecSharedPrefixTest { + + @TempDir Path tempDir; + + @Test + void sharedPrefixesAreNormalizedOrderedAndImmutable() { + LocalFilesystemSpec spec = + new LocalFilesystemSpec() + .addSharedPrefix(null) + .addSharedPrefix(" ") + .addSharedPrefix("old") + .sharedPrefixes(Arrays.asList(" docs\\ ", "", null, "knowledge/", "docs")); + + assertEquals(List.of("docs/", "knowledge/"), new ArrayList<>(spec.getSharedPrefixes())); + assertThrows( + UnsupportedOperationException.class, () -> spec.getSharedPrefixes().add("other/")); + + spec.sharedPrefixes(null); + assertTrue(spec.getSharedPrefixes().isEmpty()); + } + + @Test + void sharedPrefixNormalizationHandlesSeparatorsAndRejectsUnsafeSegments() { + assertEquals( + "docs/reference/", + SharedPrefixUtils.normalizeDirectoryPrefix(" ///docs\\reference/// ")); + + assertThrows( + IllegalArgumentException.class, + () -> SharedPrefixUtils.normalizeDirectoryPrefix("////")); + assertThrows( + IllegalArgumentException.class, + () -> SharedPrefixUtils.normalizeDirectoryPrefix("docs//private")); + assertThrows( + IllegalArgumentException.class, + () -> SharedPrefixUtils.normalizeDirectoryPrefix("./docs")); + assertThrows( + IllegalArgumentException.class, + () -> SharedPrefixUtils.normalizeDirectoryPrefix("docs/../private")); + } + + @Test + void sharedPrefixIsVisibleToEveryUserAndKeepsShellCapability() throws Exception { + Path workspace = tempDir.resolve("workspace"); + Path project = tempDir.resolve("project"); + Files.createDirectories(workspace.resolve("docs")); + Files.createDirectories(project); + Files.writeString(workspace.resolve("docs/guide.md"), "shared"); + + AbstractFilesystem fs = + new LocalFilesystemSpec() + .project(project) + .isolationScope(IsolationScope.USER) + .addSharedPrefix("docs") + .toFilesystem(workspace, IsolationScope.USER.toNamespaceFactory()); + RuntimeContext alice = RuntimeContext.builder().userId("alice").build(); + RuntimeContext bob = RuntimeContext.builder().userId("bob").build(); + + assertTrue(fs instanceof AbstractSandboxFilesystem, "local shell capability must survive"); + assertEquals("shared", fs.read(alice, "docs/guide.md", 0, 0).fileData().content()); + assertEquals("shared", fs.read(bob, "docs/guide.md", 0, 0).fileData().content()); + assertTrue( + fs.glob(bob, "**/*", "/").matches().stream() + .anyMatch(file -> "docs/guide.md".equals(file.path()))); + + assertTrue(fs.write(alice, "docs/guide.md", "alice override").isSuccess()); + assertEquals("alice override", fs.read(alice, "docs/guide.md", 0, 0).fileData().content()); + assertEquals("shared", fs.read(bob, "docs/guide.md", 0, 0).fileData().content()); + assertEquals("shared", Files.readString(workspace.resolve("docs/guide.md"))); + assertEquals("alice override", Files.readString(workspace.resolve("alice/docs/guide.md"))); + } + + @Test + void agentScopeKeepsWritableOverlaySeparateFromSharedSource() throws Exception { + Path workspace = tempDir.resolve("agent-workspace"); + Path project = tempDir.resolve("agent-project"); + Files.createDirectories(workspace.resolve("docs")); + Files.createDirectories(project); + Files.writeString(workspace.resolve("docs/guide.md"), "shared"); + + AbstractFilesystem fs = + new LocalFilesystemSpec() + .project(project) + .isolationScope(IsolationScope.AGENT) + .addSharedPrefix("docs/") + .toFilesystem(workspace, IsolationScope.AGENT.toNamespaceFactory()); + RuntimeContext alice = RuntimeContext.builder().userId("alice").build(); + RuntimeContext bob = RuntimeContext.builder().userId("bob").build(); + + assertTrue(fs.write(alice, "docs/guide.md", "agent override").isSuccess()); + assertEquals("agent override", fs.read(bob, "docs/guide.md", 0, 0).fileData().content()); + assertEquals("shared", Files.readString(workspace.resolve("docs/guide.md"))); + assertEquals( + "agent override", + Files.readString(workspace.resolve(".shared-overrides/docs/guide.md"))); + } + + @Test + void sharedRouteSupportsListingSearchingAndShellDelegationWithProjectWritable() + throws Exception { + Path workspace = tempDir.resolve("routed-workspace"); + Path project = tempDir.resolve("routed-project"); + Files.createDirectories(workspace.resolve("docs/nested")); + Files.createDirectories(project); + Files.writeString(workspace.resolve("docs/guide.md"), "shared guide"); + Files.writeString(workspace.resolve("docs/nested/notes.md"), "shared notes"); + + LocalFilesystemSpec spec = + new LocalFilesystemSpec() + .project(project) + .projectWritable(true) + .addSharedPrefix("docs"); + AbstractFilesystem fs = spec.toFilesystem(workspace, null); + RuntimeContext context = RuntimeContext.empty(); + + assertTrue(spec.isProjectWritable()); + + LsResult listing = fs.ls(context, "docs"); + assertTrue(listing.isSuccess()); + assertEquals( + List.of("docs/guide.md", "docs/nested/"), + listing.entries().stream().map(file -> file.path()).toList()); + + GrepResult grep = fs.grep(context, "shared", "docs", "*.md"); + assertTrue(grep.isSuccess()); + assertTrue(grep.matches().stream().anyMatch(match -> "docs/guide.md".equals(match.path()))); + assertTrue( + grep.matches().stream() + .anyMatch(match -> "docs/nested/notes.md".equals(match.path()))); + + GlobResult glob = fs.glob(context, "**/*", "docs"); + assertTrue(glob.isSuccess()); + assertTrue(glob.matches().stream().anyMatch(file -> "docs/guide.md".equals(file.path()))); + + AbstractSandboxFilesystem shell = (AbstractSandboxFilesystem) fs; + assertTrue(shell.id().startsWith("local-")); + assertEquals(1, shell.execute(context, " ", null).exitCode()); + } +} diff --git a/docs/v2/en/docs/harness/filesystem.md b/docs/v2/en/docs/harness/filesystem.md index 4573ee42fe..089205af4e 100644 --- a/docs/v2/en/docs/harness/filesystem.md +++ b/docs/v2/en/docs/harness/filesystem.md @@ -300,6 +300,8 @@ HarnessAgent agent = HarnessAgent.builder() .env("MY_VAR", "value") // extra environment variables .inheritEnv(true) // inherit parent process env .mode(LocalFsMode.ROOTED) // path policy + .isolationScope(IsolationScope.USER) // isolate shared-prefix overrides per user + .addSharedPrefix("docs/") // shared read-only baseline in the workspace .project(Paths.get("/my/project")) // project root (shell cwd + overlay lower) .addRoot(Paths.get("/extra/dir"))) // extra allowed directory ``` @@ -311,6 +313,9 @@ HarnessAgent agent = HarnessAgent.builder() | `env(String, String)` | Add a shell environment variable | none | | `inheritEnv(boolean)` | Inherit parent process environment | `false` | | `mode(LocalFsMode)` | Path resolution policy | `ROOTED` | +| `isolationScope(IsolationScope)` | Isolation dimension for local shared-prefix overrides | `USER` | +| `addSharedPrefix(String)` | Add a workspace-relative shared read-only baseline directory | none | +| `sharedPrefixes(Collection)` | Replace the shared read-only baseline directories in bulk | none | | `project(Path)` | Project root directory (overlay lower layer + shell cwd) | `System.getProperty("user.dir")` | | `addRoot(Path)` | Extra host directory the agent may access | none | | `additionalRoots(Collection)` | Batch-set extra directories | none | @@ -333,6 +338,41 @@ Local mode actually produces an `OverlayFilesystem`: Reads check workspace first, then fall back to project (copy-on-write semantics). Shell `pwd` is the project directory, so `ls` shows project files. +#### Shared read-only prefixes (`addSharedPrefix`) + +Local mode can register additional workspace-relative directories just like Remote mode, but the persistence location differs: Remote mode writes tenant overrides to the `BaseStore`, while Local mode keeps both the shared baseline and all scoped overrides in the agent's local workspace. + +```java +HarnessAgent agent = HarnessAgent.builder() + .name("local-team-agent") + .model(model) + .workspace(Paths.get("/srv/agent-workspace")) + .filesystem(new LocalFilesystemSpec() + .isolationScope(IsolationScope.USER) + .addSharedPrefix("docs/") + .addSharedPrefix("knowledge-base/")) + .build(); +``` + +With `USER` scope, the directory layout is: + +```text +/srv/agent-workspace/ +├── docs/guide.md # shared read-only baseline +├── knowledge-base/faq.md # shared read-only baseline +├── alice/docs/guide.md # Alice's private override after a write +└── bob/docs/guide.md # Bob's private override after a write +``` + +- Reads prefer the current scope's override and fall back to the shared source under `/`. +- `write` / `edit` use copy-on-write: they change only the namespaced override, never the shared source. +- `delete` removes only the current scope's override; if the shared source still exists, later reads reveal it again. +- `ls` / `glob` / `grep` merge the shared and override layers, with the override winning on path collisions. +- This merged view applies to filesystem APIs and file tools. Shell commands still use `project` as their `cwd` and do not materialize the virtual merged view on the host. +- Prefixes must be workspace-relative directories; empty paths, `.`, `..`, and paths containing those segments are rejected. + +This feature requires an explicit `LocalFilesystemSpec`. Omitting `filesystem(...)` still selects ordinary Local mode and does not register any shared prefixes automatically. + #### Project-writable mode (`projectWritable`) By default all writes land in the workspace — fine for read/analyze scenarios, but if the agent's job is to **generate code** (e.g. scaffold a microservice), files end up in `.agentscope/workspace/` instead of the project directory. @@ -374,7 +414,7 @@ The agent can read/write files under `/Users/alice/my-project` and `/Users/alice ## IsolationScope — bucketing across users and replicas -Both mode 1 (shared store) and mode 2 (sandbox) use the same `IsolationScope` concept to decide **who shares state with whom**: +Mode 1 (shared store), mode 2 (sandbox), and local shared-prefix overrides in mode 3 use the same `IsolationScope` concept to decide **who shares state or an override view with whom**: | Scope | Meaning | Namespace key | Typical use | |-------|---------|--------------|-------------| diff --git a/docs/v2/zh/docs/harness/filesystem.md b/docs/v2/zh/docs/harness/filesystem.md index 8ce4b8e1c3..ada0e70ab7 100644 --- a/docs/v2/zh/docs/harness/filesystem.md +++ b/docs/v2/zh/docs/harness/filesystem.md @@ -300,6 +300,8 @@ HarnessAgent agent = HarnessAgent.builder() .env("MY_VAR", "value") // 额外环境变量 .inheritEnv(true) // 是否继承父进程环境 .mode(LocalFsMode.ROOTED) // 路径策略 + .isolationScope(IsolationScope.USER) // 共享前缀的覆盖层按用户隔离 + .addSharedPrefix("docs/") // workspace 内的共享只读基线目录 .project(Paths.get("/my/project")) // 项目根(shell 的 cwd + overlay 下层) .addRoot(Paths.get("/extra/dir"))) // 额外可访问目录 ``` @@ -311,6 +313,9 @@ HarnessAgent agent = HarnessAgent.builder() | `env(String, String)` | 添加 shell 环境变量 | 无 | | `inheritEnv(boolean)` | 是否继承父进程环境 | `false` | | `mode(LocalFsMode)` | 路径解析策略 | `ROOTED` | +| `isolationScope(IsolationScope)` | 共享前缀的本地覆盖层隔离维度 | `USER` | +| `addSharedPrefix(String)` | 添加一个 workspace 相对的共享只读基线目录 | 无 | +| `sharedPrefixes(Collection)` | 批量替换共享只读基线目录 | 无 | | `project(Path)` | 项目根目录(overlay 下层 + shell cwd) | `System.getProperty("user.dir")` | | `addRoot(Path)` | 额外允许访问的宿主目录 | 无 | | `additionalRoots(Collection)` | 批量设置额外目录 | 无 | @@ -333,6 +338,41 @@ HarnessAgent agent = HarnessAgent.builder() 读取时先看 workspace,没有再退到 project(copy-on-write 语义)。shell 的 `pwd` 是 project 目录,所以 agent 执行 `ls` 看到的是项目文件。 +#### 共享只读前缀(`addSharedPrefix`) + +Local 模式也可以像 Remote 模式一样注册额外的 workspace 相对目录,但两者的持久化位置不同:Remote 模式把租户覆盖写入 `BaseStore`,Local 模式则全部保存在当前 agent 的本地 workspace 中。 + +```java +HarnessAgent agent = HarnessAgent.builder() + .name("local-team-agent") + .model(model) + .workspace(Paths.get("/srv/agent-workspace")) + .filesystem(new LocalFilesystemSpec() + .isolationScope(IsolationScope.USER) + .addSharedPrefix("docs/") + .addSharedPrefix("knowledge-base/")) + .build(); +``` + +以 `USER` scope 为例,目录布局如下: + +```text +/srv/agent-workspace/ +├── docs/guide.md # 共享只读基线 +├── knowledge-base/faq.md # 共享只读基线 +├── alice/docs/guide.md # alice 写入后的私有覆盖 +└── bob/docs/guide.md # bob 写入后的私有覆盖 +``` + +- 读取优先使用当前 scope 的覆盖文件;没有覆盖时回退到 `/` 下的共享源文件。 +- `write` / `edit` 使用 copy-on-write,只修改命名空间内的覆盖文件,不修改共享源文件。 +- `delete` 只删除当前 scope 的覆盖;如果共享源仍存在,后续读取会再次看到共享版本。 +- `ls` / `glob` / `grep` 合并共享层和覆盖层,同路径由覆盖层优先。 +- 上述合并视图作用于文件系统 API 和文件工具;shell 仍以 `project` 为 `cwd`,不会在宿主目录中物化一份合并视图。 +- 前缀必须是 workspace 相对目录,不能是空路径、`.`、`..` 或包含这类路径段。 + +此功能需要显式配置 `LocalFilesystemSpec`;省略 `filesystem(...)` 时仍是普通 Local 模式,不会自动注册任何共享前缀。 + #### 项目可写模式(`projectWritable`) 默认情况下,所有写入都落到 workspace——这对阅读/分析类场景足够,但如果 agent 的核心任务是**生成代码**(如写一个微服务),你会发现文件全写到了 `.agentscope/workspace/` 而不是项目目录。 @@ -374,7 +414,7 @@ agent 可以读写 `/Users/alice/my-project` 和 `/Users/alice/.config` 下的 ## IsolationScope —— 多用户与多副本怎么分桶 -模式 1(共享存储)和模式 2(沙箱)都用同一个 `IsolationScope` 概念,决定**谁和谁共享同一份状态**: +模式 1(共享存储)、模式 2(沙箱),以及模式 3 中配置了共享前缀的本地覆盖层,都使用同一个 `IsolationScope` 概念,决定**谁和谁共享同一份状态或覆盖视图**: | Scope | 含义 | 命名空间键 | 典型场景 | |-------|------|-----------|---------|