From 0f87023ba4194fbbb4914739babf6045f13d62df Mon Sep 17 00:00:00 2001 From: zouyx Date: Thu, 23 Jul 2026 23:40:47 +0800 Subject: [PATCH 1/3] fix: isolate static subagent registries by runtime context --- .../agent/middleware/SubagentsMiddleware.java | 49 +++++-- .../agent/subagent/AgentSpecLoader.java | 24 +++- .../agent/subagent/DefaultAgentManager.java | 6 +- .../harness/agent/tool/AgentSpawnTool.java | 51 ++++--- .../harness/agent/HarnessAgentTest.java | 132 ++++++++++++++++++ 5 files changed, 221 insertions(+), 41 deletions(-) diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/SubagentsMiddleware.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/SubagentsMiddleware.java index 1b102a92f9..e6962156e5 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/SubagentsMiddleware.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/SubagentsMiddleware.java @@ -166,7 +166,6 @@ public class SubagentsMiddleware implements HarnessRuntimeMiddleware { // @formatter:on private final List baseEntries; - private volatile List entries; private volatile Object subagentTool; private final TaskTool taskTool; private final TaskRepository taskRepository; @@ -176,6 +175,10 @@ public class SubagentsMiddleware implements HarnessRuntimeMiddleware { private final Path mainWorkspace; private final Function factoryBuilder; private final DefaultAgentManager agentManager; + private final WorkspaceManager workspaceManager; + + private record SubagentSnapshot( + List entries, DefaultAgentManager agentManager) {} /** * Optional {@link AgentGenerateTool} for LLM-driven subagent spec generation. Lazy because @@ -195,10 +198,10 @@ public SubagentsMiddleware( Path mainWorkspace, Function factoryBuilder) { this.baseEntries = List.copyOf(entries); - this.entries = this.baseEntries; this.isSessionMode = false; DefaultAgentManager dam = new DefaultAgentManager(entries, workspaceManager); this.agentManager = dam; + this.workspaceManager = workspaceManager; java.util.Objects.requireNonNull(taskRepository, "taskRepository"); this.taskRepository = taskRepository; this.subagentTool = new AgentSpawnTool(dam, taskRepository, 0); @@ -226,9 +229,9 @@ public SubagentsMiddleware( Object externalSubagentTool, TaskRepository taskRepository) { this.baseEntries = List.copyOf(entries); - this.entries = this.baseEntries; this.isSessionMode = true; this.agentManager = null; + this.workspaceManager = null; this.subagentTool = externalSubagentTool; java.util.Objects.requireNonNull(taskRepository, "taskRepository"); this.taskRepository = taskRepository; @@ -373,7 +376,7 @@ public DefaultAgentManager getAgentManager() { * contains an {@link AgentGenerateTool}. */ public List getTools() { - if (entries.isEmpty()) { + if (baseEntries.isEmpty()) { return List.of(); } AgentGenerateTool gen = this.agentGenerateTool; @@ -389,7 +392,9 @@ public Flux onAgent( RuntimeContext ctx, AgentInput input, Function> next) { - reloadSubagentEntries(); + if (ctx != null) { + installSnapshot(ctx, loadSubagentSnapshot(ctx)); + } return next.apply(input); } @@ -399,11 +404,11 @@ public Flux onReasoning( RuntimeContext ctx, ReasoningInput input, Function> next) { - List currentEntries = this.entries; + RuntimeContext rc = ctx != null ? ctx : RuntimeContext.empty(); + List currentEntries = snapshotFor(rc).entries(); if (currentEntries.isEmpty()) { return next.apply(input); } - RuntimeContext rc = ctx != null ? ctx : RuntimeContext.empty(); String sessionId = rc != null ? rc.getSessionId() : null; // ---- Phase B-3 push delivery ------------------------------------------------------- @@ -584,13 +589,28 @@ static List prependToSystemMessage(List messages, String extra) { return out; } - private void reloadSubagentEntries() { + private SubagentSnapshot snapshotFor(RuntimeContext runtimeContext) { + SubagentSnapshot existing = runtimeContext.get(SubagentSnapshot.class); + if (existing != null) { + return existing; + } + SubagentSnapshot snapshot = loadSubagentSnapshot(runtimeContext); + installSnapshot(runtimeContext, snapshot); + return snapshot; + } + + private void installSnapshot(RuntimeContext runtimeContext, SubagentSnapshot snapshot) { + runtimeContext.put(SubagentSnapshot.class, snapshot); + runtimeContext.put(AgentSpawnTool.CTX_AGENT_MANAGER, snapshot.agentManager()); + } + + private SubagentSnapshot loadSubagentSnapshot(RuntimeContext runtimeContext) { if (filesystem == null || factoryBuilder == null || isSessionMode) { - return; + return new SubagentSnapshot(baseEntries, agentManager); } try { List decls = - AgentSpecLoader.loadFromFilesystem(filesystem, mainWorkspace); + AgentSpecLoader.loadFromFilesystem(filesystem, runtimeContext, mainWorkspace); List newEntries = new ArrayList<>(baseEntries); for (SubagentDeclaration decl : decls) { @@ -605,13 +625,12 @@ private void reloadSubagentEntries() { decl)); } } - - this.entries = List.copyOf(newEntries); - if (agentManager != null) { - agentManager.refreshEntries(this.entries); - } + List snapshotEntries = List.copyOf(newEntries); + return new SubagentSnapshot( + snapshotEntries, new DefaultAgentManager(snapshotEntries, workspaceManager)); } catch (Exception e) { log.warn("Failed to reload subagent entries from filesystem: {}", e.getMessage()); + return new SubagentSnapshot(baseEntries, agentManager); } } diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/AgentSpecLoader.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/AgentSpecLoader.java index b73623ab35..800ca035f2 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/AgentSpecLoader.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/AgentSpecLoader.java @@ -122,8 +122,11 @@ public static List loadFromDirectory( } /** - * Loads subagent declarations via the {@link AbstractFilesystem}, respecting namespace - * isolation. Scans {@code subagents/} for {@code *.md} files using filesystem glob. + * Loads subagent declarations via the {@link AbstractFilesystem} using an empty runtime + * context. Scans {@code subagents/} for {@code *.md} files using filesystem glob. + * + *

Use {@link #loadFromFilesystem(AbstractFilesystem, RuntimeContext, Path)} when + * declarations are scoped to a calling user's namespace. * * @param filesystem the filesystem layer (applies namespace transparently) * @param mainWorkspace the parent workspace for resolving relative workspace paths; may be @@ -132,10 +135,25 @@ public static List loadFromDirectory( */ public static List loadFromFilesystem( AbstractFilesystem filesystem, Path mainWorkspace) { + return loadFromFilesystem(filesystem, RuntimeContext.empty(), mainWorkspace); + } + + /** + * Loads subagent declarations via the {@link AbstractFilesystem}, respecting the supplied + * runtime context's namespace isolation. + * + * @param filesystem the filesystem layer (applies namespace transparently) + * @param runtimeContext the call context used to resolve namespace-scoped declarations + * @param mainWorkspace the parent workspace for resolving relative workspace paths; may be + * {@code null} + * @return list of parsed declarations; never {@code null} + */ + public static List loadFromFilesystem( + AbstractFilesystem filesystem, RuntimeContext runtimeContext, Path mainWorkspace) { if (filesystem == null) { return Collections.emptyList(); } - RuntimeContext ctx = RuntimeContext.empty(); + RuntimeContext ctx = runtimeContext != null ? runtimeContext : RuntimeContext.empty(); GlobResult glob = filesystem.glob(ctx, "*.md", "subagents"); if (!glob.isSuccess() || glob.matches() == null || glob.matches().isEmpty()) { return Collections.emptyList(); diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/DefaultAgentManager.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/DefaultAgentManager.java index 7df0b67893..b97508e7e1 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/DefaultAgentManager.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/DefaultAgentManager.java @@ -67,11 +67,7 @@ public DefaultAgentManager(List entries, WorkspaceManager workspa this.workspaceManager = workspaceManager; } - /** - * Replaces the current set of entries with a new snapshot. Called per-call from - * {@link io.agentscope.harness.agent.middleware.SubagentsMiddleware} to reflect per-user subagent - * configurations. - */ + /** Replaces the current set of entries with a new snapshot. */ public void refreshEntries(List entries) { Map factories = new HashMap<>(); Map decls = new HashMap<>(); diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/AgentSpawnTool.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/AgentSpawnTool.java index 071919db0c..39eeccab01 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/AgentSpawnTool.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/AgentSpawnTool.java @@ -113,6 +113,14 @@ public class AgentSpawnTool { */ public static final String CTX_EXPOSE_TO_USER = "agentscope.subagent.expose_to_user"; + /** + * {@link RuntimeContext} string key for the immutable subagent registry selected by the + * current parent-agent invocation. {@link + * io.agentscope.harness.agent.middleware.SubagentsMiddleware} installs a namespace-scoped + * manager here so concurrent callers never overwrite each other's declarations. + */ + public static final String CTX_AGENT_MANAGER = "agentscope.subagent.agent_manager"; + private static final String BG_RESULT_TEMPLATE = """ status: accepted @@ -240,23 +248,24 @@ public Mono agentSpawn( return Mono.just("Error: Maximum spawn depth exceeded (max=" + MAX_SPAWN_DEPTH + ")"); } String canonLabel = label != null && !label.isBlank() ? label.trim() : null; + DefaultAgentManager manager = managerFor(runtimeContext); - Optional agentOpt = agentManager.createAgentIfPresent(agentId, runtimeContext); + Optional agentOpt = manager.createAgentIfPresent(agentId, runtimeContext); if (agentOpt.isEmpty()) { - if (agentManager.isPrimaryOnly(agentId)) { + if (manager.isPrimaryOnly(agentId)) { return Mono.just( "Error: agent_id '" + agentId + "' is PRIMARY-only and cannot be spawned as a subagent."); } - log.warn("agent_spawn unknown agentId={}, known={}", agentId, agentManager); + log.warn("agent_spawn unknown agentId={}, known={}", agentId, manager); return Mono.just("Error: Unknown agent_id: " + agentId); } log.debug("agent_spawn resolved: agentId={}", agentId); Agent agent = agentOpt.get(); String currentUserId = runtimeContext != null ? runtimeContext.getUserId() : null; String parentSessionId = runtimeContext != null ? runtimeContext.getSessionId() : null; - var declOpt = agentManager.getDeclaration(agentId); + var declOpt = manager.getDeclaration(agentId); boolean persist = declOpt.map(SubagentDeclaration::isPersistSession).orElse(false); String key; @@ -353,8 +362,7 @@ public Mono agentSpawn( () -> { try { Msg reply = - agentManager - .invokeAgent( + manager.invokeAgent( agent, sessionId, currentUserId, @@ -501,7 +509,8 @@ public Mono agentSend( long timeoutMs = resolveTimeoutMs(timeoutSeconds, DEFAULT_TIMEOUT_SECONDS); String currentUserId = runtimeContext != null ? runtimeContext.getUserId() : null; String parentSessionId = runtimeContext != null ? runtimeContext.getSessionId() : null; - var declOpt = agentManager.getDeclaration(spawned.agentId()); + DefaultAgentManager manager = managerFor(runtimeContext); + var declOpt = manager.getDeclaration(spawned.agentId()); boolean remote = declOpt.map(SubagentDeclaration::isRemote).orElse(false); if (timeoutMs == 0) { @@ -519,8 +528,7 @@ public Mono agentSend( () -> { try { Msg reply = - agentManager - .invokeAgent( + manager.invokeAgent( spawned.agent(), spawned.sessionId(), currentUserId, @@ -592,6 +600,14 @@ public String agentList() { // Helpers // ----------------------------------------------------------------- + private DefaultAgentManager managerFor(RuntimeContext runtimeContext) { + DefaultAgentManager scoped = + runtimeContext != null + ? runtimeContext.get(CTX_AGENT_MANAGER, DefaultAgentManager.class) + : null; + return scoped != null ? scoped : agentManager; + } + /** * Returns a {@link Mono} that invokes the local subagent. * @@ -621,6 +637,7 @@ private Mono execLocalSync( RuntimeContext parentCtx) { return Mono.deferContextual( ctxView -> { + DefaultAgentManager manager = managerFor(parentCtx); // ── Path 1: streamEvents() — AgentEvent forwarding ── Optional emitterOpt = AgentEventEmitter.fromContext(ctxView); if (emitterOpt.isPresent()) { @@ -633,8 +650,7 @@ private Mono execLocalSync( new AgentStartEvent(spawned.sessionId(), null, spawned.agentId()) .withSource(sourcePath)); - return agentManager - .invokeAgent(agent, sessionId, userId, prompt, parentCtx) + return manager.invokeAgent(agent, sessionId, userId, prompt, parentCtx) .contextWrite( c -> c.put( @@ -652,8 +668,7 @@ private Mono execLocalSync( SubagentEventBus bus = ctxView.get(SubagentEventBus.CONTEXT_KEY); EventSource childSource = buildChildSource(spawned, parentCtx); - return agentManager - .invokeAgentStream( + return manager.invokeAgentStream( agent, sessionId, userId, @@ -677,13 +692,13 @@ private Mono execLocalSync( .switchIfEmpty( Mono.defer( () -> - agentManager.invokeAgent( + manager.invokeAgent( agent, sessionId, userId, prompt, parentCtx))); } // ── Path 3: non-streaming ── - return agentManager.invokeAgent(agent, sessionId, userId, prompt, parentCtx); + return manager.invokeAgent(agent, sessionId, userId, prompt, parentCtx); }); } @@ -901,7 +916,7 @@ private SpawnedAgent tryRestoreFromState( return null; } Optional agentOpt = - agentManager.createAgentIfPresent(entry.agentId(), runtimeContext); + managerFor(runtimeContext).createAgentIfPresent(entry.agentId(), runtimeContext); if (agentOpt.isEmpty()) { log.warn( "Failed to restore subagent from state: agentId={} not found in registry", @@ -1124,6 +1139,7 @@ private Mono execSpawnTask( long timeoutMs = resolveTimeoutMs(timeoutSeconds, DEFAULT_TIMEOUT_SECONDS); String currentUserId = runtimeContext != null ? runtimeContext.getUserId() : null; String parentSessionId = runtimeContext != null ? runtimeContext.getSessionId() : null; + DefaultAgentManager manager = managerFor(runtimeContext); boolean remote = declOpt.map(SubagentDeclaration::isRemote).orElse(false); if (timeoutMs == 0) { @@ -1141,8 +1157,7 @@ private Mono execSpawnTask( () -> { try { Msg reply = - agentManager - .invokeAgent( + manager.invokeAgent( spawned.agent(), spawned.sessionId(), currentUserId, 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 510a9c295d..8b514e475d 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 @@ -479,6 +479,109 @@ void subagentMarkdown_registersIdsAndSubagentTools() throws Exception { "built-in general-purpose entry should be listed"); } + @Test + void staticSubagentsMiddleware_loadsNamespacedMarkdownDeclarations() throws Exception { + Files.createDirectories(workspace.resolve("alice/subagents")); + Files.writeString( + workspace.resolve("alice/subagents/user-helper.md"), + """ + --- + description: User-scoped subagent declaration + --- + You only reply OK. + """); + + Model model = stubModel("done"); + HarnessAgent agent = + HarnessAgent.builder() + .name("main") + .model(model) + .workspace(workspace) + .disableDynamicSubagents() + .build(); + + agent.call(userText("go"), RuntimeContext.builder().userId("alice").sessionId("s2").build()) + .block(); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(model, atLeast(1)).stream(captor.capture(), any(), any()); + String combined = + captor.getAllValues().stream() + .map(HarnessAgentTest::joinAllText) + .filter(s -> s.contains("## Subagents")) + .findFirst() + .orElse(""); + assertTrue( + combined.contains("`user-helper`"), + "static subagent reload must use the calling user's filesystem namespace"); + } + + @Test + void staticSubagentsMiddleware_keepsNamespacedDeclarationsCallScoped() throws Exception { + Files.createDirectories(workspace.resolve("alice/subagents")); + Files.createDirectories(workspace.resolve("bob/subagents")); + Files.writeString( + workspace.resolve("alice/subagents/alice-helper.md"), + """ + --- + description: Alice-only subagent + --- + You only reply ALICE. + """); + Files.writeString( + workspace.resolve("bob/subagents/bob-helper.md"), + """ + --- + description: Bob-only subagent + --- + You only reply BOB. + """); + + RecordingModel model = new RecordingModel(); + HarnessAgent agent = + HarnessAgent.builder() + .name("main") + .model(model) + .workspace(workspace) + .disableDynamicSubagents() + .build(); + + Mono aliceCall = + agent.getDelegate() + .call( + List.of(userText("request-from-alice")), + RuntimeContext.builder() + .userId("alice") + .sessionId("alice-s") + .build()); + Mono bobCall = + agent.getDelegate() + .call( + List.of(userText("request-from-bob")), + RuntimeContext.builder().userId("bob").sessionId("bob-s").build()); + + aliceCall.block(); + bobCall.block(); + + String alicePrompt = + model.inputs().stream() + .map(HarnessAgentTest::joinAllText) + .filter(input -> input.contains("request-from-alice")) + .findFirst() + .orElseThrow(); + String bobPrompt = + model.inputs().stream() + .map(HarnessAgentTest::joinAllText) + .filter(input -> input.contains("request-from-bob")) + .findFirst() + .orElseThrow(); + assertTrue(alicePrompt.contains("`alice-helper`")); + assertFalse(alicePrompt.contains("`bob-helper`")); + assertTrue(bobPrompt.contains("`bob-helper`")); + assertFalse(bobPrompt.contains("`alice-helper`")); + } + @Test void subagentsDir_loadsMarkdownDeclarations() throws Exception { Files.createDirectories(workspace); @@ -1248,6 +1351,35 @@ private static Model stubModel(String assistantText) { return model; } + private static final class RecordingModel implements Model { + + private final List> inputs = new java.util.concurrent.CopyOnWriteArrayList<>(); + + @Override + public String getModelName() { + return "recording-model"; + } + + @Override + public Flux stream( + List messages, + List tools, + io.agentscope.core.model.GenerateOptions options) { + inputs.add(List.copyOf(messages)); + return Flux.just( + new ChatResponse( + "recording-model", + List.of(TextBlock.builder().text("done").build()), + null, + Map.of(), + "stop")); + } + + List> inputs() { + return inputs; + } + } + private static AgentTool mockAgentTool(String name) { AgentTool tool = mock(AgentTool.class); when(tool.getName()).thenReturn(name); From 1431d7ac02209ce2609261c06eefb030b40e68dc Mon Sep 17 00:00:00 2001 From: zouyx Date: Fri, 24 Jul 2026 08:26:15 +0800 Subject: [PATCH 2/3] test: cover scoped subagent manager paths --- .../harness/agent/HarnessAgentTest.java | 41 ++++- .../agent/tool/AgentSpawnToolKeyTest.java | 164 ++++++++++++++++++ 2 files changed, 199 insertions(+), 6 deletions(-) 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 8b514e475d..71266c1b27 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 @@ -38,6 +38,7 @@ import io.agentscope.core.middleware.MiddlewareBase; import io.agentscope.core.model.ChatResponse; import io.agentscope.core.model.ExecutionConfig; +import io.agentscope.core.model.GenerateOptions; import io.agentscope.core.model.Model; import io.agentscope.core.model.ToolSchema; import io.agentscope.core.shutdown.GracefulShutdownMiddleware; @@ -67,6 +68,7 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; @@ -76,6 +78,7 @@ import org.mockito.ArgumentCaptor; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; /** * Tests for {@link HarnessAgent} workspace wiring: {@code AGENTS.md} context and subagent @@ -561,8 +564,10 @@ void staticSubagentsMiddleware_keepsNamespacedDeclarationsCallScoped() throws Ex List.of(userText("request-from-bob")), RuntimeContext.builder().userId("bob").sessionId("bob-s").build()); - aliceCall.block(); - bobCall.block(); + Mono.when( + aliceCall.subscribeOn(Schedulers.parallel()), + bobCall.subscribeOn(Schedulers.parallel())) + .block(); String alicePrompt = model.inputs().stream() @@ -614,6 +619,32 @@ void subagentsDir_loadsMarkdownDeclarations() throws Exception { "subagents/*.md declaration should use filename as name"); } + @Test + void agentSpecLoader_filesystemOverloadsSupportDefaultAndNullContexts() throws Exception { + Path subagents = workspace.resolve("subagents"); + Files.createDirectories(subagents); + Files.writeString( + subagents.resolve("helper.md"), + """ + --- + description: Filesystem-loaded helper + --- + You are a helper. + """); + + AbstractFilesystem filesystem = new LocalFilesystem(workspace); + assertEquals( + List.of("helper"), + AgentSpecLoader.loadFromFilesystem(filesystem, workspace).stream() + .map(SubagentDeclaration::getName) + .toList()); + assertEquals( + List.of("helper"), + AgentSpecLoader.loadFromFilesystem(filesystem, null, workspace).stream() + .map(SubagentDeclaration::getName) + .toList()); + } + @Test void parentMiddleware_propagatesToBuiltinAndMarkdownSubagents() throws Exception { Files.createDirectories(workspace); @@ -1353,7 +1384,7 @@ private static Model stubModel(String assistantText) { private static final class RecordingModel implements Model { - private final List> inputs = new java.util.concurrent.CopyOnWriteArrayList<>(); + private final List> inputs = new CopyOnWriteArrayList<>(); @Override public String getModelName() { @@ -1362,9 +1393,7 @@ public String getModelName() { @Override public Flux stream( - List messages, - List tools, - io.agentscope.core.model.GenerateOptions options) { + List messages, List tools, GenerateOptions options) { inputs.add(List.copyOf(messages)); return Flux.just( new ChatResponse( diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolKeyTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolKeyTest.java index 39b2b47624..bfa0a8ac71 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolKeyTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolKeyTest.java @@ -20,8 +20,27 @@ import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import io.agentscope.core.ReActAgent; +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.GenerateOptions; +import io.agentscope.core.model.Model; +import io.agentscope.core.model.ToolSchema; +import io.agentscope.core.state.AgentState; +import io.agentscope.core.state.ToolContextState; +import io.agentscope.harness.agent.middleware.SubagentEntry; +import io.agentscope.harness.agent.subagent.DefaultAgentManager; import io.agentscope.harness.agent.subagent.SubagentDeclaration; +import io.agentscope.harness.agent.subagent.task.BackgroundTask; +import io.agentscope.harness.agent.subagent.task.TaskRepository; +import io.agentscope.harness.agent.subagent.task.TaskRunSpec; +import io.agentscope.harness.agent.subagent.task.TaskStatus; +import java.util.Collection; +import java.util.List; import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; /** * Tests for {@link AgentSpawnTool#deterministicHash} — the deterministic key derivation used when @@ -29,6 +48,151 @@ */ class AgentSpawnToolKeyTest { + @Test + void runtimeContextManagerIsUsedForSpawnAndSend() { + ReActAgent child = + ReActAgent.builder() + .name("child") + .sysPrompt("child") + .model(replyingModel()) + .build(); + + DefaultAgentManager defaultManager = + new DefaultAgentManager( + List.of(new SubagentEntry("default-agent", "default", rc -> child)), null); + SubagentDeclaration scopedDeclaration = + SubagentDeclaration.builder() + .name("scoped-agent") + .description("scoped") + .inlineAgentsBody("scoped") + .persistSession(true) + .build(); + DefaultAgentManager scopedManager = + new DefaultAgentManager( + List.of( + new SubagentEntry( + "scoped-agent", "scoped", rc -> child, scopedDeclaration)), + null); + NoopTaskRepository repository = new NoopTaskRepository(); + AgentSpawnTool tool = new AgentSpawnTool(defaultManager, repository, 0); + + String defaultSpawn = + tool.agentSpawn( + RuntimeContext.empty(), + null, + "default-agent", + "ignored", + null, + 1, + null) + .block(); + assertTrue(defaultSpawn.contains("agent_id: default-agent")); + + RuntimeContext scopedContext = + RuntimeContext.builder().sessionId("s1").userId("u1").build(); + scopedContext.put(AgentSpawnTool.CTX_AGENT_MANAGER, scopedManager); + String persistentSpawn = + tool.agentSpawn( + scopedContext, + null, + "scoped-agent", + "first task", + "scoped", + 0, + null) + .block(); + assertTrue(persistentSpawn.contains("agent_id: scoped-agent")); + assertEquals("done", repository.runLatestLocalTask()); + + String key = + persistentSpawn.lines().findFirst().orElseThrow().substring("agent_key: ".length()); + String sent = tool.agentSend(scopedContext, null, key, null, "follow-up", 1).block(); + assertTrue(sent.contains("done")); + + String backgroundSend = + tool.agentSend(scopedContext, null, key, null, "background", 0).block(); + assertTrue(backgroundSend.contains("task_id:")); + assertEquals("done", repository.runLatestLocalTask()); + + AgentState restoredState = AgentState.builder().build(); + String restoredKey = "agent:scoped-agent:restored"; + restoredState + .getToolContext() + .putSpawnEntry( + restoredKey, + new ToolContextState.SpawnEntry( + restoredKey, "scoped-agent", "sub-restored", null, 1)); + AgentSpawnTool restoreTool = new AgentSpawnTool(defaultManager, repository, 0); + String restored = + restoreTool + .agentSend(scopedContext, restoredState, restoredKey, null, "restore", 1) + .block(); + assertTrue(restored.contains("done")); + } + + private static Model replyingModel() { + return new Model() { + @Override + public String getModelName() { + return "test-model"; + } + + @Override + public Flux stream( + List messages, List tools, GenerateOptions options) { + return Flux.just( + new ChatResponse( + "test-model", + List.of(TextBlock.builder().text("done").build()), + null, + java.util.Map.of(), + "stop")); + } + }; + } + + private static final class NoopTaskRepository implements TaskRepository { + + private TaskRunSpec latestSpec; + + @Override + public BackgroundTask getTask(RuntimeContext rc, String sessionId, String taskId) { + return null; + } + + @Override + public BackgroundTask putTask( + RuntimeContext rc, + String taskId, + String subAgentId, + String sessionId, + TaskRunSpec spec) { + latestSpec = spec; + return null; + } + + String runLatestLocalTask() { + return ((TaskRunSpec.LocalTaskRunSpec) latestSpec).execution().get(); + } + + @Override + public void removeTask(RuntimeContext rc, String sessionId, String taskId) {} + + @Override + public void clear() {} + + @Override + public Collection listTasks( + RuntimeContext rc, String sessionId, TaskStatus filter) { + return List.of(); + } + + @Override + public boolean cancelTask(RuntimeContext rc, String sessionId, String taskId) { + return false; + } + } + @Test void sameInputs_produceSameHash() { String h1 = AgentSpawnTool.deterministicHash("session-1", "code-reviewer", "review"); From 57908c9af316381400344b5e1dfc64ac88866b82 Mon Sep 17 00:00:00 2001 From: zouyx Date: Fri, 24 Jul 2026 09:41:37 +0800 Subject: [PATCH 3/3] test: cover subagent spawn validation --- .../agent/tool/AgentSpawnToolKeyTest.java | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolKeyTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolKeyTest.java index bfa0a8ac71..dde9ca016b 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolKeyTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolKeyTest.java @@ -88,6 +88,49 @@ void runtimeContextManagerIsUsedForSpawnAndSend() { .block(); assertTrue(defaultSpawn.contains("agent_id: default-agent")); + SubagentDeclaration primaryDeclaration = + SubagentDeclaration.builder() + .name("primary-agent") + .description("primary") + .inlineAgentsBody("primary") + .mode(SubagentDeclaration.Mode.PRIMARY) + .build(); + DefaultAgentManager restrictedManager = + new DefaultAgentManager( + List.of( + new SubagentEntry( + "primary-agent", + "primary", + rc -> child, + primaryDeclaration)), + null); + AgentSpawnTool validationTool = + new AgentSpawnTool(restrictedManager, new NoopTaskRepository(), 0); + assertTrue( + validationTool + .agentSpawn( + RuntimeContext.empty(), + null, + "primary-agent", + "ignored", + null, + 1, + null) + .block() + .contains("PRIMARY-only")); + assertTrue( + validationTool + .agentSpawn( + RuntimeContext.empty(), + null, + "unknown-agent", + "ignored", + null, + 1, + null) + .block() + .contains("Unknown agent_id")); + RuntimeContext scopedContext = RuntimeContext.builder().sessionId("s1").userId("u1").build(); scopedContext.put(AgentSpawnTool.CTX_AGENT_MANAGER, scopedManager);