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 2a3b8f7b03..e22ad8d91f 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 @@ -2431,14 +2431,22 @@ public HarnessAgent build() { io.agentscope.harness.agent.skill.runtime.ShellPathPolicy.noShell(); } - inner.middleware( + HarnessSkillMiddleware skillMiddleware = new HarnessSkillMiddleware( orderedSkillRepos, agentToolkit, skillFilter, visibilityFilter, stager, - shellPolicy)); + shellPolicy); + inner.middleware(skillMiddleware); + + // Wire pre-start staging so sandbox projection picks up .skills-cache content + // that MarketplaceStager materialises from database-backed repositories. + if (sandboxLifecycleMw != null && stager != null) { + sandboxLifecycleMw.setBeforeStartCallback( + skillMiddleware::prestageMarketplaceSkills); + } } else if (disableDynamicSkills) { // Suppress core's auto-install so the static SkillBox fallback (constructed // below by staticSkillBoxFromRepos) remains the only skill source. diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/HarnessSkillMiddleware.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/HarnessSkillMiddleware.java index 5ee8667614..a5ed05073b 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/HarnessSkillMiddleware.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/HarnessSkillMiddleware.java @@ -144,6 +144,31 @@ public SkillRuntime runtime() { return runtime; } + /** + * Pre-stages marketplace skill resources to {@code .skills-cache/} on the host workspace. + * Intended to be called before sandbox start so that workspace projection picks up + * the staged content in the same call. Safe to call multiple times — staging is idempotent + * (content-hash guarded). + * + * @param ctx the per-call runtime context + */ + public void prestageMarketplaceSkills(RuntimeContext ctx) { + if (stager == null) { + return; + } + if (ctx == null) { + ctx = RuntimeContext.empty(); + } + Map merged = mergeRepositories(ctx); + if (merged.isEmpty()) { + return; + } + List visible = applyVisibility(merged.values(), ctx); + if (!visible.isEmpty()) { + stager.stage(visible, sourceNamespaces); + } + } + @Override public Mono onSystemPrompt(Agent agent, RuntimeContext ctx, String currentPrompt) { if (ctx == null) { diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/SandboxLifecycleMiddleware.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/SandboxLifecycleMiddleware.java index d754345c79..a33ff6edc5 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/SandboxLifecycleMiddleware.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/SandboxLifecycleMiddleware.java @@ -22,6 +22,7 @@ import io.agentscope.harness.agent.sandbox.SandboxContext; import io.agentscope.harness.agent.sandbox.SandboxManager; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -55,6 +56,7 @@ public class SandboxLifecycleMiddleware implements HarnessRuntimeMiddleware { private final SandboxBackedFilesystem filesystemProxy; private final AtomicReference currentAcquireResult = new AtomicReference<>(); + private volatile Consumer beforeStartCallback; public SandboxLifecycleMiddleware( SandboxManager sandboxManager, SandboxBackedFilesystem filesystemProxy) { @@ -62,6 +64,18 @@ public SandboxLifecycleMiddleware( this.filesystemProxy = filesystemProxy; } + /** + * Registers a callback that runs after the sandbox session is acquired but before + * {@link io.agentscope.harness.agent.sandbox.Sandbox#start()} applies workspace projection. + * This allows callers to materialise resources on the host workspace (e.g. + * {@code .skills-cache/}) so that projection picks them up in the same call. + * + * @param callback receives the per-call {@link RuntimeContext}; may be {@code null} to clear + */ + public void setBeforeStartCallback(Consumer callback) { + this.beforeStartCallback = callback; + } + /** * Acquires the sandbox for the current call. Called from * {@code ReActAgent.beforeAgentExecution()} to ensure the sandbox is available @@ -78,6 +92,18 @@ public void acquireForCall(RuntimeContext ctx) { return; } try { + Consumer cb = beforeStartCallback; + if (cb != null) { + try { + cb.accept(ctx); + } catch (Exception e) { + log.warn( + "[sandbox-mw] beforeStartCallback failed; proceeding with sandbox" + + " start: {}", + e.getMessage(), + e); + } + } SandboxAcquireResult result = sandboxManager.acquire(sandboxContext, ctx); Sandbox sandbox = result.getSandbox(); try { diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/SandboxLifecycleMiddlewareCallbackTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/SandboxLifecycleMiddlewareCallbackTest.java new file mode 100644 index 0000000000..ef052147a5 --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/SandboxLifecycleMiddlewareCallbackTest.java @@ -0,0 +1,205 @@ +/* + * 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.middleware; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.skill.AgentSkill; +import io.agentscope.core.skill.repository.AgentSkillRepository; +import io.agentscope.core.skill.repository.AgentSkillRepositoryInfo; +import io.agentscope.core.tool.Toolkit; +import io.agentscope.harness.agent.skill.runtime.MarketplaceStager; +import io.agentscope.harness.agent.skill.runtime.ShellPathPolicy; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Verifies that {@link HarnessSkillMiddleware#prestageMarketplaceSkills} materialises + * {@code .skills-cache/} on the host workspace so that sandbox projection can pick up + * database-backed skill resources before {@code sandbox.start()}. + */ +class SandboxLifecycleMiddlewareCallbackTest { + + @TempDir Path tempWorkspace; + + @Test + void prestageMarketplaceSkillsMaterialisesSkillsCache() throws IOException { + AgentSkill dbSkill = + new AgentSkill( + "db-tool", + "A database-sourced skill", + "skill content", + Map.of("run.sh", "#!/bin/bash\necho hello"), + "test-db"); + + StubSkillRepository dbRepo = new StubSkillRepository(List.of(dbSkill), "test-db"); + MarketplaceStager stager = new MarketplaceStager(tempWorkspace); + + HarnessSkillMiddleware middleware = + new HarnessSkillMiddleware( + List.of(dbRepo), + new Toolkit(), + null, + null, + stager, + ShellPathPolicy.noShell()); + + Path cacheDir = tempWorkspace.resolve(".skills-cache"); + assertTrue(Files.notExists(cacheDir), ".skills-cache should not exist before prestage"); + + middleware.prestageMarketplaceSkills(RuntimeContext.empty()); + + assertTrue(Files.isDirectory(cacheDir), ".skills-cache should be created by prestage"); + Path stagedScript = cacheDir.resolve("test-db").resolve("db-tool").resolve("run.sh"); + assertTrue(Files.exists(stagedScript), "run.sh should be staged"); + String content = Files.readString(stagedScript); + assertEquals("#!/bin/bash\necho hello", content); + } + + @Test + void prestageIsIdempotent() { + AgentSkill skill = + new AgentSkill( + "idempotent-skill", "test", "content", Map.of("data.txt", "hello"), "src"); + + StubSkillRepository repo = new StubSkillRepository(List.of(skill), "src"); + MarketplaceStager stager = new MarketplaceStager(tempWorkspace); + + HarnessSkillMiddleware middleware = + new HarnessSkillMiddleware( + List.of(repo), + new Toolkit(), + null, + null, + stager, + ShellPathPolicy.noShell()); + + middleware.prestageMarketplaceSkills(RuntimeContext.empty()); + middleware.prestageMarketplaceSkills(RuntimeContext.empty()); + + Path staged = tempWorkspace.resolve(".skills-cache/src/idempotent-skill/data.txt"); + assertTrue(Files.exists(staged)); + } + + @Test + void prestageWithNullStagerIsNoOp() { + HarnessSkillMiddleware middleware = + new HarnessSkillMiddleware( + List.of(), new Toolkit(), null, null, null, ShellPathPolicy.noShell()); + + middleware.prestageMarketplaceSkills(RuntimeContext.empty()); + assertTrue(Files.notExists(tempWorkspace.resolve(".skills-cache"))); + } + + @Test + void prestageCalledViaCallbackMaterialisesCache() { + AgentSkill skill = + new AgentSkill( + "callback-skill", + "test", + "content", + Map.of("tool.py", "print('hi')"), + "cb-src"); + + StubSkillRepository repo = new StubSkillRepository(List.of(skill), "cb-src"); + MarketplaceStager stager = new MarketplaceStager(tempWorkspace); + + HarnessSkillMiddleware skillMw = + new HarnessSkillMiddleware( + List.of(repo), + new Toolkit(), + null, + null, + stager, + ShellPathPolicy.noShell()); + + // Simulate the callback wiring done by HarnessAgent.Builder.build() + java.util.function.Consumer callback = skillMw::prestageMarketplaceSkills; + callback.accept(RuntimeContext.empty()); + + Path staged = tempWorkspace.resolve(".skills-cache/cb-src/callback-skill/tool.py"); + assertTrue( + Files.exists(staged), + ".skills-cache should be populated by the callback before sandbox.start()"); + } + + /** Minimal repository stub that returns a fixed skill list. */ + private static final class StubSkillRepository implements AgentSkillRepository { + + private final List skills; + private final String source; + + StubSkillRepository(List skills, String source) { + this.skills = skills; + this.source = source; + } + + @Override + public AgentSkill getSkill(String name) { + return skills.stream().filter(s -> s.getName().equals(name)).findFirst().orElse(null); + } + + @Override + public List getAllSkillNames() { + return skills.stream().map(AgentSkill::getName).toList(); + } + + @Override + public List getAllSkills() { + return skills; + } + + @Override + public boolean save(List skills, boolean force) { + return false; + } + + @Override + public boolean delete(String skillName) { + return false; + } + + @Override + public boolean skillExists(String skillName) { + return skills.stream().anyMatch(s -> s.getName().equals(skillName)); + } + + @Override + public AgentSkillRepositoryInfo getRepositoryInfo() { + return new AgentSkillRepositoryInfo(source, "", false); + } + + @Override + public String getSource() { + return source; + } + + @Override + public void setWriteable(boolean writeable) {} + + @Override + public boolean isWriteable() { + return false; + } + } +}