From e9b9e365748b29cf3ca937416f1b9bda0059e5e3 Mon Sep 17 00:00:00 2001 From: guslegend <1670547022@qq.com> Date: Sun, 14 Jun 2026 10:38:23 +0800 Subject: [PATCH 1/7] fix: preserve runtime context in harness subagents --- .../agentscope/core/agent/RuntimeContext.java | 43 +++++++ .../agent/subagent/DefaultAgentManager.java | 52 ++++++++- .../harness/agent/tool/AgentSpawnTool.java | 11 +- ...DefaultAgentManagerRuntimeContextTest.java | 106 ++++++++++++++++++ 4 files changed, 204 insertions(+), 8 deletions(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/agent/RuntimeContext.java b/agentscope-core/src/main/java/io/agentscope/core/agent/RuntimeContext.java index 6d50da1fab..d3a76bbc97 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/agent/RuntimeContext.java +++ b/agentscope-core/src/main/java/io/agentscope/core/agent/RuntimeContext.java @@ -76,6 +76,33 @@ private RuntimeContext(Builder builder) { } } + private RuntimeContext( + String sessionId, + String userId, + Map stringAttributes, + Map, ? extends Map> typedAttributes, + ToolExecutionContext toolExecutionContext) { + this.sessionId = sessionId; + this.userId = userId; + this.stringAttributes = new ConcurrentHashMap<>(); + this.typedAttributes = new ConcurrentHashMap<>(); + this.toolExecutionContext = toolExecutionContext; + this.agentState = null; + if (stringAttributes != null && !stringAttributes.isEmpty()) { + this.stringAttributes.putAll(stringAttributes); + } + if (typedAttributes != null && !typedAttributes.isEmpty()) { + for (Map.Entry, ? extends Map> e : + typedAttributes.entrySet()) { + Map values = e.getValue(); + if (values == null || values.isEmpty()) { + continue; + } + this.typedAttributes.put(e.getKey(), new ConcurrentHashMap<>(values)); + } + } + } + /** * Shallow, mutable empty context (null session fields, empty attribute maps, no tool context). */ @@ -108,6 +135,22 @@ public void setAgentState(AgentState agentState) { this.agentState = agentState; } + /** + * Creates a child context that preserves this context's extras, typed attributes, and tool + * execution context while letting the caller override the child session and user identity. + * + *

The call-scoped {@link AgentState} is intentionally not copied; child agents install + * their own state when their call begins. + */ + public RuntimeContext fork(String sessionId, String userId) { + return new RuntimeContext( + sessionId != null ? sessionId : this.sessionId, + userId != null ? userId : this.userId, + this.stringAttributes, + this.typedAttributes, + this.toolExecutionContext); + } + /** * Resolves the live {@link AgentState} for the current call, preferring the call-scoped state * carried on {@code ctx} (concurrency-safe) and falling back to {@code fallbackAgent}'s state 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 8d9eec787b..839d0466dd 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 @@ -167,9 +167,10 @@ public Agent createAgent(String agentId, RuntimeContext parentRc) { * Invokes an agent with a user prompt. Handles both plain {@link Agent} and {@link * HarnessAgent} (injects {@link RuntimeContext} for the latter). * - *

For {@link HarnessAgent} children, {@code userId} is propagated so that isolation-key - * resolution (e.g. {@code USER}-scoped sandbox slots) works correctly. A fresh {@code - * sessionId} is always assigned independently of the parent session. + *

For {@link HarnessAgent} children, the parent {@link RuntimeContext}'s attributes, + * typed values, and tool execution context are preserved. {@code userId} still propagates so + * isolation-key resolution (e.g. {@code USER}-scoped sandbox slots) works correctly, while a + * fresh {@code sessionId} is always assigned independently of the parent session. * * @param agent the agent to invoke * @param sessionId a new, child-specific session id @@ -177,7 +178,22 @@ public Agent createAgent(String agentId, RuntimeContext parentRc) { * @param prompt the user message to send */ public Mono invokeAgent(Agent agent, String sessionId, String userId, String prompt) { - RuntimeContext ctx = RuntimeContext.builder().sessionId(sessionId).userId(userId).build(); + return invokeAgent(agent, null, sessionId, userId, prompt); + } + + /** + * Invokes an agent with a user prompt and the parent call context. + * + *

This is the preferred entry point for harness subagent delegation because it preserves + * the parent runtime metadata on the child call. + */ + public Mono invokeAgent( + Agent agent, + RuntimeContext parentContext, + String sessionId, + String userId, + String prompt) { + RuntimeContext ctx = childContext(parentContext, sessionId, userId); if (agent instanceof ReActAgent react) { return react.call(List.of(userMessage(prompt)), ctx); } @@ -213,9 +229,25 @@ public Flux invokeAgentStream( String prompt, EventSource source, StreamOptions options) { + return invokeAgentStream(agent, null, sessionId, userId, prompt, source, options); + } + + /** + * Invokes an agent and returns its execution as a tagged {@link Flux} of {@link Event}s. + * + *

This overload preserves the parent runtime context for harness subagents. + */ + public Flux invokeAgentStream( + Agent agent, + RuntimeContext parentContext, + String sessionId, + String userId, + String prompt, + EventSource source, + StreamOptions options) { Flux childFlux; StreamOptions effective = options != null ? options : StreamOptions.defaults(); - RuntimeContext ctx = RuntimeContext.builder().sessionId(sessionId).userId(userId).build(); + RuntimeContext ctx = childContext(parentContext, sessionId, userId); if (agent instanceof ReActAgent react) { childFlux = react.stream(List.of(userMessage(prompt)), effective, ctx); } else if (agent instanceof HarnessAgent harness) { @@ -230,6 +262,16 @@ public WorkspaceManager getWorkspaceManager() { return workspaceManager; } + private static RuntimeContext childContext( + RuntimeContext parentContext, String sessionId, String userId) { + String effectiveUserId = + userId != null ? userId : parentContext != null ? parentContext.getUserId() : null; + if (parentContext != null) { + return parentContext.fork(sessionId, effectiveUserId); + } + return RuntimeContext.builder().sessionId(sessionId).userId(effectiveUserId).build(); + } + private static Msg userMessage(String prompt) { return Msg.builder().role(MsgRole.USER).textContent(prompt).build(); } 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 d548bd59f5..1391ae2616 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 @@ -353,6 +353,7 @@ public Mono agentSpawn( agentManager .invokeAgent( agent, + runtimeContext, sessionId, currentUserId, capturedTask) @@ -516,6 +517,7 @@ public Mono agentSend( agentManager .invokeAgent( spawned.agent(), + runtimeContext, spawned.sessionId(), currentUserId, capturedMessage) @@ -639,7 +641,7 @@ private Mono execLocalSync( .withSource(sourcePath)); return agentManager - .invokeAgent(agent, sessionId, userId, prompt) + .invokeAgent(agent, parentCtx, sessionId, userId, prompt) .contextWrite( c -> c.put( @@ -660,6 +662,7 @@ private Mono execLocalSync( return agentManager .invokeAgentStream( agent, + parentCtx, sessionId, userId, prompt, @@ -682,11 +685,12 @@ private Mono execLocalSync( Mono.defer( () -> agentManager.invokeAgent( - agent, sessionId, userId, prompt))); + agent, parentCtx, sessionId, userId, + prompt))); } // ── Path 3: non-streaming ── - return agentManager.invokeAgent(agent, sessionId, userId, prompt); + return agentManager.invokeAgent(agent, parentCtx, sessionId, userId, prompt); }); } @@ -892,6 +896,7 @@ private Mono execSpawnTask( agentManager .invokeAgent( spawned.agent(), + runtimeContext, spawned.sessionId(), currentUserId, capturedTask) diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/DefaultAgentManagerRuntimeContextTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/DefaultAgentManagerRuntimeContextTest.java index 065bc78c0a..c36d789435 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/DefaultAgentManagerRuntimeContextTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/DefaultAgentManagerRuntimeContextTest.java @@ -15,15 +15,32 @@ */ package io.agentscope.harness.agent.subagent; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +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.agent.StreamOptions; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.tool.ToolExecutionContext; +import io.agentscope.harness.agent.HarnessAgent; +import io.agentscope.harness.agent.gateway.channel.OutboundAddress; import io.agentscope.harness.agent.middleware.SubagentEntry; import java.util.List; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; /** * Phase B-0 — verify {@link DefaultAgentManager#createAgentIfPresent(String, RuntimeContext)} and @@ -32,6 +49,10 @@ */ class DefaultAgentManagerRuntimeContextTest { + private record TypedMarker(String value) {} + + private record ToolMarker(String value) {} + private static SubagentDeclaration plainDecl(String name) { return SubagentDeclaration.builder() .name(name) @@ -99,4 +120,89 @@ void createAgentIfPresent_nullRuntimeContext_substitutesEmpty() { // We don't assert .equals() here — empty() may return a fresh instance — only that the // factory never sees null. } + + @Test + void invokeAgent_preservesParentRuntimeContextMetadata() { + HarnessAgent child = mock(HarnessAgent.class); + when(child.call(any(Msg.class), any(RuntimeContext.class))) + .thenReturn(Mono.just(reply("ok"))); + + RuntimeContext parent = parentContext(); + DefaultAgentManager mgr = new DefaultAgentManager(List.of(), null); + + mgr.invokeAgent(child, parent, "child-session", parent.getUserId(), "hello").block(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(RuntimeContext.class); + verify(child).call(any(Msg.class), captor.capture()); + + RuntimeContext childCtx = captor.getValue(); + assertEquals("child-session", childCtx.getSessionId()); + assertEquals(parent.getUserId(), childCtx.getUserId()); + assertEquals("trace-123", childCtx.get("traceId")); + assertEquals( + OutboundAddress.direct("chatui", "chatui:123"), + childCtx.get("outboundAddress", OutboundAddress.class)); + assertEquals(new TypedMarker("typed-1"), childCtx.get(TypedMarker.class)); + assertSame(parent.getToolExecutionContext(), childCtx.getToolExecutionContext()); + assertNull(childCtx.getAgentState()); + } + + @Test + void invokeAgentStream_preservesParentRuntimeContextMetadata() { + HarnessAgent child = mock(HarnessAgent.class); + when(child.stream(anyList(), any(StreamOptions.class), any(RuntimeContext.class))) + .thenReturn(Flux.empty()); + + RuntimeContext parent = parentContext(); + DefaultAgentManager mgr = new DefaultAgentManager(List.of(), null); + + mgr.invokeAgentStream( + child, + parent, + "child-stream-session", + parent.getUserId(), + "hello", + null, + StreamOptions.defaults()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(RuntimeContext.class); + verify(child).stream(anyList(), any(StreamOptions.class), captor.capture()); + + RuntimeContext childCtx = captor.getValue(); + assertEquals("child-stream-session", childCtx.getSessionId()); + assertEquals(parent.getUserId(), childCtx.getUserId()); + assertEquals("trace-123", childCtx.get("traceId")); + assertEquals( + OutboundAddress.direct("chatui", "chatui:123"), + childCtx.get("outboundAddress", OutboundAddress.class)); + assertEquals(new TypedMarker("typed-1"), childCtx.get(TypedMarker.class)); + assertSame(parent.getToolExecutionContext(), childCtx.getToolExecutionContext()); + assertNull(childCtx.getAgentState()); + } + + private static RuntimeContext parentContext() { + RuntimeContext ctx = + RuntimeContext.builder() + .sessionId("parent-session") + .userId("alice") + .put("traceId", "trace-123") + .put(TypedMarker.class, new TypedMarker("typed-1")) + .toolExecutionContext( + ToolExecutionContext.builder() + .register(new ToolMarker("tool-di")) + .build()) + .build(); + ctx.put( + "outboundAddress", + OutboundAddress.class, + OutboundAddress.direct("chatui", "chatui:123")); + return ctx; + } + + private static Msg reply(String text) { + return Msg.builder() + .role(MsgRole.ASSISTANT) + .content(TextBlock.builder().text(text).build()) + .build(); + } } From 7a6bd6514ba7c788a7698164e7817e1f143414c6 Mon Sep 17 00:00:00 2001 From: guslegend <1670547022@qq.com> Date: Mon, 15 Jun 2026 08:42:35 +0800 Subject: [PATCH 2/7] fix: simplify subagent runtime context fallback --- .../agent/subagent/DefaultAgentManager.java | 6 +-- ...DefaultAgentManagerRuntimeContextTest.java | 50 +++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) 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 839d0466dd..3c89dc1664 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 @@ -264,12 +264,10 @@ public WorkspaceManager getWorkspaceManager() { private static RuntimeContext childContext( RuntimeContext parentContext, String sessionId, String userId) { - String effectiveUserId = - userId != null ? userId : parentContext != null ? parentContext.getUserId() : null; if (parentContext != null) { - return parentContext.fork(sessionId, effectiveUserId); + return parentContext.fork(sessionId, userId); } - return RuntimeContext.builder().sessionId(sessionId).userId(effectiveUserId).build(); + return RuntimeContext.builder().sessionId(sessionId).userId(userId).build(); } private static Msg userMessage(String prompt) { diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/DefaultAgentManagerRuntimeContextTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/DefaultAgentManagerRuntimeContextTest.java index c36d789435..42a185b222 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/DefaultAgentManagerRuntimeContextTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/DefaultAgentManagerRuntimeContextTest.java @@ -180,6 +180,56 @@ void invokeAgentStream_preservesParentRuntimeContextMetadata() { assertNull(childCtx.getAgentState()); } + @Test + void invokeAgent_withoutParentRuntimeContext_usesProvidedIdentityOnly() { + HarnessAgent child = mock(HarnessAgent.class); + when(child.call(any(Msg.class), any(RuntimeContext.class))) + .thenReturn(Mono.just(reply("ok"))); + + DefaultAgentManager mgr = new DefaultAgentManager(List.of(), null); + mgr.invokeAgent(child, "child-session", "solo-user", "hello").block(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(RuntimeContext.class); + verify(child).call(any(Msg.class), captor.capture()); + + RuntimeContext childCtx = captor.getValue(); + assertEquals("child-session", childCtx.getSessionId()); + assertEquals("solo-user", childCtx.getUserId()); + assertNull(childCtx.get("traceId")); + assertNull(childCtx.get("outboundAddress", OutboundAddress.class)); + assertNull(childCtx.get(TypedMarker.class)); + assertNull(childCtx.getToolExecutionContext()); + assertNull(childCtx.getAgentState()); + } + + @Test + void invokeAgentStream_withoutParentRuntimeContext_usesProvidedIdentityOnly() { + HarnessAgent child = mock(HarnessAgent.class); + when(child.stream(anyList(), any(StreamOptions.class), any(RuntimeContext.class))) + .thenReturn(Flux.empty()); + + DefaultAgentManager mgr = new DefaultAgentManager(List.of(), null); + mgr.invokeAgentStream( + child, + "child-stream-session", + "solo-user", + "hello", + null, + StreamOptions.defaults()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(RuntimeContext.class); + verify(child).stream(anyList(), any(StreamOptions.class), captor.capture()); + + RuntimeContext childCtx = captor.getValue(); + assertEquals("child-stream-session", childCtx.getSessionId()); + assertEquals("solo-user", childCtx.getUserId()); + assertNull(childCtx.get("traceId")); + assertNull(childCtx.get("outboundAddress", OutboundAddress.class)); + assertNull(childCtx.get(TypedMarker.class)); + assertNull(childCtx.getToolExecutionContext()); + assertNull(childCtx.getAgentState()); + } + private static RuntimeContext parentContext() { RuntimeContext ctx = RuntimeContext.builder() From 0ceca8a7f9d527eb3ecccb8df2df898d18c5035f Mon Sep 17 00:00:00 2001 From: guslegend <1670547022@qq.com> Date: Mon, 15 Jun 2026 09:23:57 +0800 Subject: [PATCH 3/7] ci: ignore test sources in codecov patch coverage --- codecov.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/codecov.yml b/codecov.yml index 5ed73f0476..b447dd4b95 100644 --- a/codecov.yml +++ b/codecov.yml @@ -31,3 +31,4 @@ coverage: threshold: 5% ignore: - "agentscope-examples/**/*" + - "**/src/test/**" From 957a39a85137805b575eee762b13e1804c29112b Mon Sep 17 00:00:00 2001 From: guslegend <1670547022@qq.com> Date: Tue, 16 Jun 2026 07:53:23 +0800 Subject: [PATCH 4/7] fix(harness): close local filesystem example cleanly --- .../middleware/MemoryFlushMiddleware.java | 2 +- ...ilesystemPersonalAssistantExampleTest.java | 110 +++++++++--------- 2 files changed, 58 insertions(+), 54 deletions(-) diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/MemoryFlushMiddleware.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/MemoryFlushMiddleware.java index a13022d52f..cc7865eabc 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/MemoryFlushMiddleware.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/MemoryFlushMiddleware.java @@ -125,7 +125,7 @@ public Flux onAgent( AgentInput input, Function> next) { final RuntimeContext rc = ctx != null ? ctx : RuntimeContext.empty(); - return next.apply(input).doOnComplete(() -> doFlush(agent, rc).subscribe()); + return next.apply(input).concatWith(doFlush(agent, rc).thenMany(Flux.empty())); } private reactor.core.publisher.Mono doFlush(Agent agent, RuntimeContext rc) { diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/example/LocalFilesystemPersonalAssistantExampleTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/example/LocalFilesystemPersonalAssistantExampleTest.java index 1b57eebc80..dbff6854a8 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/example/LocalFilesystemPersonalAssistantExampleTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/example/LocalFilesystemPersonalAssistantExampleTest.java @@ -79,34 +79,36 @@ void localFilesystem_filesPersistAcrossCalls() throws Exception { // Build the agent with a LocalFilesystemWithShell store. // No distributed store, no sandbox — all operations go straight to disk. - HarnessAgent agent = + try (HarnessAgent agent = HarnessAgent.builder() .name("my-local-assistant") .model(stubModel("done")) .workspace(workspace.toAbsolutePath().normalize().toString()) .abstractFilesystem(new LocalFilesystemWithShell(workspace)) - .build(); - - // Call 1: write a note to MEMORY.md through the workspace manager - agent.call(userMsg("first call"), ctx("session-1", "alice")).block(); - agent.getWorkspaceManager() - .writeUtf8WorkspaceRelative( - RuntimeContext.empty(), "MEMORY.md", "# Notes\n- item 1"); - - // The file exists on disk after call 1 - Path memoryFile = workspace.resolve("MEMORY.md"); - assertTrue(Files.isRegularFile(memoryFile), "MEMORY.md should exist on disk after call 1"); - String content = Files.readString(memoryFile, StandardCharsets.UTF_8); - assertTrue(content.contains("item 1"), "MEMORY.md content should be persisted on disk"); - - // Call 2: same workspace, different session — file is still there - agent.call(userMsg("second call"), ctx("session-2", "alice")).block(); - assertTrue( - Files.isRegularFile(memoryFile), "MEMORY.md should still exist on disk in call 2"); - assertEquals( - content, - Files.readString(memoryFile, StandardCharsets.UTF_8), - "MEMORY.md content should be unchanged after call 2"); + .build()) { + // Call 1: write a note to MEMORY.md through the workspace manager + agent.call(userMsg("first call"), ctx("session-1", "alice")).block(); + agent.getWorkspaceManager() + .writeUtf8WorkspaceRelative( + RuntimeContext.empty(), "MEMORY.md", "# Notes\n- item 1"); + + // The file exists on disk after call 1 + Path memoryFile = workspace.resolve("MEMORY.md"); + assertTrue( + Files.isRegularFile(memoryFile), "MEMORY.md should exist on disk after call 1"); + String content = Files.readString(memoryFile, StandardCharsets.UTF_8); + assertTrue(content.contains("item 1"), "MEMORY.md content should be persisted on disk"); + + // Call 2: same workspace, different session — file is still there + agent.call(userMsg("second call"), ctx("session-2", "alice")).block(); + assertTrue( + Files.isRegularFile(memoryFile), + "MEMORY.md should still exist on disk in call 2"); + assertEquals( + content, + Files.readString(memoryFile, StandardCharsets.UTF_8), + "MEMORY.md content should be unchanged after call 2"); + } } /** @@ -120,26 +122,27 @@ void localFilesystem_filesPersistAcrossCalls() throws Exception { void localFilesystem_workspaceIsNotPartitionedByUserOrSession() throws Exception { Files.createDirectories(workspace); - HarnessAgent agent = + try (HarnessAgent agent = HarnessAgent.builder() .name("my-local-assistant") .model(stubModel("done")) .workspace(workspace.toAbsolutePath().normalize().toString()) .abstractFilesystem(new LocalFilesystemWithShell(workspace)) - .build(); - - // Alice writes during her session - agent.call(userMsg("alice here"), ctx("session-alice", "alice")).block(); - agent.getWorkspaceManager() - .writeUtf8WorkspaceRelative(RuntimeContext.empty(), "shared.txt", "alice was here"); - - // Bob calls with a different userId — still reads the same workspace - agent.call(userMsg("bob here"), ctx("session-bob", "bob")).block(); - Path sharedFile = workspace.resolve("shared.txt"); - assertTrue( - Files.isRegularFile(sharedFile), - "shared.txt written by alice should be visible in the same workspace, " - + "regardless of userId or sessionId"); + .build()) { + // Alice writes during her session + agent.call(userMsg("alice here"), ctx("session-alice", "alice")).block(); + agent.getWorkspaceManager() + .writeUtf8WorkspaceRelative( + RuntimeContext.empty(), "shared.txt", "alice was here"); + + // Bob calls with a different userId — still reads the same workspace + agent.call(userMsg("bob here"), ctx("session-bob", "bob")).block(); + Path sharedFile = workspace.resolve("shared.txt"); + assertTrue( + Files.isRegularFile(sharedFile), + "shared.txt written by alice should be visible in the same workspace, " + + "regardless of userId or sessionId"); + } } /** @@ -150,26 +153,27 @@ void localFilesystem_workspaceIsNotPartitionedByUserOrSession() throws Exception void localFilesystem_directDiskAccessFromHostProcess() throws Exception { Files.createDirectories(workspace); - HarnessAgent agent = + try (HarnessAgent agent = HarnessAgent.builder() .name("my-local-assistant") .model(stubModel("done")) .workspace(workspace.toAbsolutePath().normalize().toString()) .abstractFilesystem(new LocalFilesystemWithShell(workspace)) - .build(); - - // Write a file from the host process (simulating a user placing a document in the - // workspace) - Path doc = workspace.resolve("document.txt"); - Files.writeString(doc, "Host-written document content"); - - // The agent can see the file through its workspace manager - agent.call(userMsg("check document"), ctx("s1", "user")).block(); - String read = - agent.getWorkspaceManager() - .readManagedWorkspaceFileUtf8(RuntimeContext.empty(), "document.txt"); - assertNotNull(read, "agent should be able to read files written directly to the workspace"); - assertTrue(read.contains("Host-written"), "agent should see the host-written content"); + .build()) { + // Write a file from the host process (simulating a user placing a document in the + // workspace) + Path doc = workspace.resolve("document.txt"); + Files.writeString(doc, "Host-written document content"); + + // The agent can see the file through its workspace manager + agent.call(userMsg("check document"), ctx("s1", "user")).block(); + String read = + agent.getWorkspaceManager() + .readManagedWorkspaceFileUtf8(RuntimeContext.empty(), "document.txt"); + assertNotNull( + read, "agent should be able to read files written directly to the workspace"); + assertTrue(read.contains("Host-written"), "agent should see the host-written content"); + } } // ------------------------------------------------------------------------- From af490d639e11c5f2c2c508060f54b389f6b14f80 Mon Sep 17 00:00:00 2001 From: guslegend <1670547022@qq.com> Date: Tue, 16 Jun 2026 08:39:47 +0800 Subject: [PATCH 5/7] Test MemoryFlushMiddleware onAgent offload path --- .../MemoryFlushMiddlewareTriggerTest.java | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/MemoryFlushMiddlewareTriggerTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/MemoryFlushMiddlewareTriggerTest.java index e4ad880ff0..bc1e808426 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/MemoryFlushMiddlewareTriggerTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/MemoryFlushMiddlewareTriggerTest.java @@ -17,14 +17,31 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import io.agentscope.core.ReActAgent; import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.agent.test.MockModel; +import io.agentscope.core.event.AgentEvent; +import io.agentscope.core.event.CustomEvent; +import io.agentscope.core.message.Msg; +import io.agentscope.core.middleware.AgentInput; +import io.agentscope.core.state.AgentState; +import io.agentscope.core.tool.Toolkit; import io.agentscope.harness.agent.IsolationScope; import io.agentscope.harness.agent.memory.MemoryConfig; import io.agentscope.harness.agent.memory.MemoryFlushManager; +import io.agentscope.harness.agent.workspace.WorkspaceManager; import java.time.Duration; +import java.util.List; import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; /** * Focused unit test for {@link MemoryFlushMiddleware#shouldFlushNow} — the trigger gate @@ -208,4 +225,54 @@ void userScope_timerKeyUsesUserId() { assertEquals("", mw.timerKeyFor(RC_ANON), "no userId → empty key"); assertEquals("", mw.timerKeyFor(null), "null rc → empty key"); } + + @Test + void onAgent_passesThroughDownstreamEvents_andRunsOffloadPath() { + WorkspaceManager workspaceManager = mock(WorkspaceManager.class); + MemoryFlushMiddleware mw = + new MemoryFlushMiddleware( + workspaceManager, + null, + MemoryFlushManager.DEFAULT_FLUSH_PROMPT, + MemoryConfig.FlushTrigger.never()); + + ReActAgent agent = + ReActAgent.builder() + .name("test-agent") + .sysPrompt("Test agent") + .model(new MockModel("noop")) + .toolkit(new Toolkit()) + .build(); + + Msg message = Msg.builder().textContent("remember this").build(); + AgentState state = + AgentState.builder() + .sessionId("session-1") + .userId("user-1") + .addMessage(message) + .build(); + RuntimeContext ctx = + RuntimeContext.builder() + .sessionId("session-1") + .userId("user-1") + .agentState(state) + .build(); + + CustomEvent downstream = new CustomEvent("downstream"); + List events = + mw.onAgent( + agent, + ctx, + new AgentInput(List.of(message)), + in -> Flux.just(downstream)) + .collectList() + .block(); + + assertNotNull(events); + assertEquals(1, events.size()); + assertSame(downstream, events.get(0)); + verify(workspaceManager) + .updateSessionIndex( + any(), eq("test-agent"), eq("session-1"), eq("conversation offloaded")); + } } From c83af3d579e603bb8529b7b6578373eb887b5dc1 Mon Sep 17 00:00:00 2001 From: guslegend <1670547022@qq.com> Date: Tue, 16 Jun 2026 10:37:44 +0800 Subject: [PATCH 6/7] test: cover runtime context propagation in spawn tool --- .../core/agent/RuntimeContextTest.java | 38 +++ .../AgentSpawnToolRuntimeContextTest.java | 271 ++++++++++++++++++ 2 files changed, 309 insertions(+) create mode 100644 agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolRuntimeContextTest.java diff --git a/agentscope-core/src/test/java/io/agentscope/core/agent/RuntimeContextTest.java b/agentscope-core/src/test/java/io/agentscope/core/agent/RuntimeContextTest.java index 0e42ab6768..ad465326c5 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/agent/RuntimeContextTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/agent/RuntimeContextTest.java @@ -19,6 +19,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; +import io.agentscope.core.state.AgentState; import io.agentscope.core.tool.ToolExecutionContext; import java.util.concurrent.CyclicBarrier; import java.util.concurrent.atomic.AtomicInteger; @@ -101,6 +102,43 @@ void asToolExecutionContextMergePriority() { assertSame(fromRun, merged.get(PojoA.class)); } + @Test + @DisplayName("fork copies extras and keeps call-scoped state isolated") + void forkCopiesMetadataAndLeavesAgentStateNull() { + ToolExecutionContext toolContext = + ToolExecutionContext.builder().register(new PojoB(7)).build(); + RuntimeContext parent = + RuntimeContext.builder() + .sessionId("parent-session") + .userId("parent-user") + .put("traceId", "trace-123") + .put(PojoA.class, new PojoA("typed-parent")) + .toolExecutionContext(toolContext) + .agentState(AgentState.builder().sessionId("agent-state").build()) + .build(); + + RuntimeContext child = parent.fork("child-session", null); + RuntimeContext sibling = parent.fork(null, "child-user"); + + assertEquals("child-session", child.getSessionId()); + assertEquals("parent-user", child.getUserId()); + assertEquals("trace-123", child.get("traceId")); + assertSame(parent.get(PojoA.class), child.get(PojoA.class)); + assertSame(toolContext, child.getToolExecutionContext()); + assertNull(child.getAgentState()); + + assertEquals("parent-session", sibling.getSessionId()); + assertEquals("child-user", sibling.getUserId()); + assertEquals("trace-123", sibling.get("traceId")); + assertSame(toolContext, sibling.getToolExecutionContext()); + assertNull(sibling.getAgentState()); + + parent.put("traceId", "changed-parent"); + child.put("child-only", "yes"); + assertEquals("trace-123", child.get("traceId")); + assertNull(parent.get("child-only")); + } + @Test @DisplayName("concurrent puts on distinct keys from multiple threads") void threadSafety() throws Exception { diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolRuntimeContextTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolRuntimeContextTest.java new file mode 100644 index 0000000000..7a01d78732 --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolRuntimeContextTest.java @@ -0,0 +1,271 @@ +/* + * 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.tool; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.mock; +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.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 java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +class AgentSpawnToolRuntimeContextTest { + + private static final class CapturingTaskRepository implements TaskRepository { + final List contexts = new ArrayList<>(); + final List taskIds = new ArrayList<>(); + final List agentIds = new ArrayList<>(); + final List sessionIds = new ArrayList<>(); + final List specs = new ArrayList<>(); + + @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) { + contexts.add(rc); + taskIds.add(taskId); + agentIds.add(subAgentId); + sessionIds.add(sessionId); + specs.add(spec); + return null; + } + + @Override + public void removeTask(RuntimeContext rc, String sessionId, String taskId) {} + + @Override + public void clear() {} + + @Override + public Collection listTasks( + RuntimeContext rc, + String sessionId, + io.agentscope.harness.agent.subagent.task.TaskStatus filter) { + return List.of(); + } + + @Override + public boolean cancelTask(RuntimeContext rc, String sessionId, String taskId) { + return false; + } + } + + private static RuntimeContext parentContext() { + return RuntimeContext.builder() + .sessionId("parent-session") + .userId("user-1") + .put("traceId", "trace-123") + .build(); + } + + private static Msg assistantReply(String text) { + return Msg.builder() + .role(MsgRole.ASSISTANT) + .content(TextBlock.builder().text(text).build()) + .build(); + } + + private static String extractLineValue(String text, String prefix) { + return text.lines() + .filter(line -> line.startsWith(prefix)) + .map(line -> line.substring(prefix.length())) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Missing line: " + prefix + text)); + } + + @Test + void agentSpawn_asyncLocalTaskUsesParentRuntimeContext() { + CapturingTaskRepository repo = new CapturingTaskRepository(); + DefaultAgentManager manager = mock(DefaultAgentManager.class); + Agent child = mock(Agent.class); + RuntimeContext parent = parentContext(); + + when(manager.createAgentIfPresent(eq("worker"), same(parent))) + .thenReturn(Optional.of(child)); + when(manager.getDeclaration("worker")).thenReturn(Optional.empty()); + when(manager.invokeAgent( + eq(child), + any(RuntimeContext.class), + anyString(), + eq("user-1"), + eq("build something"))) + .thenReturn(Mono.just(assistantReply("spawn-result"))); + + AgentSpawnTool tool = new AgentSpawnTool(manager, repo, 0); + + String response = + tool.agentSpawn(parent, null, "worker", "build something", null, 0, false).block(); + + assertNotNull(response); + assertTrue(response.contains("status: accepted")); + assertEquals(1, repo.specs.size()); + assertSame(parent, repo.contexts.get(0)); + assertEquals("parent-session", repo.sessionIds.get(0)); + + TaskRunSpec.LocalTaskRunSpec spec = (TaskRunSpec.LocalTaskRunSpec) repo.specs.get(0); + assertEquals("spawn-result", spec.execution().get()); + + var ctxCaptor = org.mockito.ArgumentCaptor.forClass(RuntimeContext.class); + var sessionCaptor = org.mockito.ArgumentCaptor.forClass(String.class); + verify(manager) + .invokeAgent( + eq(child), + ctxCaptor.capture(), + sessionCaptor.capture(), + eq("user-1"), + eq("build something")); + assertSame(parent, ctxCaptor.getValue()); + assertEquals("trace-123", ctxCaptor.getValue().get("traceId")); + assertTrue(sessionCaptor.getValue().startsWith("sub-")); + } + + @Test + void agentSend_asyncLocalTaskUsesParentRuntimeContext() { + CapturingTaskRepository repo = new CapturingTaskRepository(); + DefaultAgentManager manager = mock(DefaultAgentManager.class); + Agent child = mock(Agent.class); + RuntimeContext parent = parentContext(); + + when(manager.createAgentIfPresent(eq("worker"), same(parent))) + .thenReturn(Optional.of(child)); + when(manager.getDeclaration("worker")).thenReturn(Optional.empty()); + when(manager.invokeAgent( + eq(child), + any(RuntimeContext.class), + anyString(), + eq("user-1"), + eq("follow-up"))) + .thenReturn(Mono.just(assistantReply("send-result"))); + + AgentSpawnTool tool = new AgentSpawnTool(manager, repo, 0); + + String spawnReply = + tool.agentSpawn(parent, null, "worker", null, "stable", null, false).block(); + String agentKey = extractLineValue(spawnReply, "agent_key: "); + + assertNotNull(agentKey); + assertTrue(repo.specs.isEmpty()); + + String sendReply = tool.agentSend(parent, agentKey, null, "follow-up", 0).block(); + assertNotNull(sendReply); + assertTrue(sendReply.startsWith("status: accepted")); + assertEquals(1, repo.specs.size()); + assertSame(parent, repo.contexts.get(0)); + + TaskRunSpec.LocalTaskRunSpec spec = (TaskRunSpec.LocalTaskRunSpec) repo.specs.get(0); + assertEquals("send-result", spec.execution().get()); + + var ctxCaptor = org.mockito.ArgumentCaptor.forClass(RuntimeContext.class); + var sessionCaptor = org.mockito.ArgumentCaptor.forClass(String.class); + verify(manager) + .invokeAgent( + eq(child), + ctxCaptor.capture(), + sessionCaptor.capture(), + eq("user-1"), + eq("follow-up")); + assertSame(parent, ctxCaptor.getValue()); + assertEquals("trace-123", ctxCaptor.getValue().get("traceId")); + assertTrue(sessionCaptor.getValue().startsWith("sub-")); + } + + @Test + void agentSpawn_reusedPersistentAgentAsyncTaskUsesParentRuntimeContext() { + CapturingTaskRepository repo = new CapturingTaskRepository(); + DefaultAgentManager manager = mock(DefaultAgentManager.class); + Agent child = mock(Agent.class); + RuntimeContext parent = parentContext(); + SubagentDeclaration decl = + SubagentDeclaration.builder() + .name("worker") + .description("persistent worker") + .inlineAgentsBody("worker body") + .persistSession(true) + .build(); + + when(manager.createAgentIfPresent(eq("worker"), same(parent))) + .thenReturn(Optional.of(child)); + when(manager.getDeclaration("worker")).thenReturn(Optional.of(decl)); + when(manager.invokeAgent( + eq(child), + any(RuntimeContext.class), + anyString(), + eq("user-1"), + eq("reused task"))) + .thenReturn(Mono.just(assistantReply("reused-result"))); + + AgentSpawnTool tool = new AgentSpawnTool(manager, repo, 0); + + String initialReply = + tool.agentSpawn(parent, null, "worker", null, "stable", null, false).block(); + assertNotNull(initialReply); + assertTrue(repo.specs.isEmpty(), "spawn-without-task should not create a task spec"); + + String reusedReply = + tool.agentSpawn(parent, null, "worker", "reused task", "stable", 0, false).block(); + + assertNotNull(reusedReply); + assertTrue(reusedReply.contains("status: accepted")); + assertEquals(1, repo.specs.size()); + assertSame(parent, repo.contexts.get(0)); + + TaskRunSpec.LocalTaskRunSpec spec = (TaskRunSpec.LocalTaskRunSpec) repo.specs.get(0); + assertEquals("reused-result", spec.execution().get()); + + var ctxCaptor = org.mockito.ArgumentCaptor.forClass(RuntimeContext.class); + var sessionCaptor = org.mockito.ArgumentCaptor.forClass(String.class); + verify(manager) + .invokeAgent( + eq(child), + ctxCaptor.capture(), + sessionCaptor.capture(), + eq("user-1"), + eq("reused task")); + assertSame(parent, ctxCaptor.getValue()); + assertEquals("trace-123", ctxCaptor.getValue().get("traceId")); + assertTrue(sessionCaptor.getValue().startsWith("sub-")); + } +} From 10bf4b5efb1f01d519c33d2abaea6797500f7f2a Mon Sep 17 00:00:00 2001 From: guslegend <1670547022@qq.com> Date: Tue, 16 Jun 2026 14:39:59 +0800 Subject: [PATCH 7/7] revert(harness): restore async memory flush completion --- .../harness/agent/middleware/MemoryFlushMiddleware.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/MemoryFlushMiddleware.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/MemoryFlushMiddleware.java index cc7865eabc..a13022d52f 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/MemoryFlushMiddleware.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/MemoryFlushMiddleware.java @@ -125,7 +125,7 @@ public Flux onAgent( AgentInput input, Function> next) { final RuntimeContext rc = ctx != null ? ctx : RuntimeContext.empty(); - return next.apply(input).concatWith(doFlush(agent, rc).thenMany(Flux.empty())); + return next.apply(input).doOnComplete(() -> doFlush(agent, rc).subscribe()); } private reactor.core.publisher.Mono doFlush(Agent agent, RuntimeContext rc) {