Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,33 @@ private RuntimeContext(Builder builder) {
}
}

private RuntimeContext(
String sessionId,
String userId,
Map<String, Object> stringAttributes,
Map<Class<?>, ? extends Map<String, Object>> 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<Class<?>, ? extends Map<String, Object>> e :
typedAttributes.entrySet()) {
Map<String, Object> 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).
*/
Expand Down Expand Up @@ -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.
*
* <p>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) {
Comment thread
guslegend0510 marked this conversation as resolved.
Comment thread
guslegend0510 marked this conversation as resolved.
return new RuntimeContext(
sessionId != null ? sessionId : this.sessionId,
userId != null ? userId : this.userId,
this.stringAttributes,
this.typedAttributes,
this.toolExecutionContext);
}
Comment thread
guslegend0510 marked this conversation as resolved.

/**
* 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,17 +167,33 @@ 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).
*
* <p>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.
* <p>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
* @param userId the parent's user-id (may be {@code null})
* @param prompt the user message to send
*/
public Mono<Msg> 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.
*
* <p>This is the preferred entry point for harness subagent delegation because it preserves
* the parent runtime metadata on the child call.
*/
public Mono<Msg> 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);
}
Expand Down Expand Up @@ -213,9 +229,25 @@ public Flux<Event> 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.
*
* <p>This overload preserves the parent runtime context for harness subagents.
*/
public Flux<Event> invokeAgentStream(
Agent agent,
RuntimeContext parentContext,
String sessionId,
String userId,
String prompt,
EventSource source,
StreamOptions options) {
Flux<Event> 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) {
Expand All @@ -230,6 +262,14 @@ public WorkspaceManager getWorkspaceManager() {
return workspaceManager;
}

private static RuntimeContext childContext(
RuntimeContext parentContext, String sessionId, String userId) {
if (parentContext != null) {
return parentContext.fork(sessionId, userId);
}
return RuntimeContext.builder().sessionId(sessionId).userId(userId).build();
}

private static Msg userMessage(String prompt) {
return Msg.builder().role(MsgRole.USER).textContent(prompt).build();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,7 @@ public Mono<String> agentSpawn(
agentManager
.invokeAgent(
agent,
runtimeContext,
sessionId,
currentUserId,
capturedTask)
Expand Down Expand Up @@ -516,6 +517,7 @@ public Mono<String> agentSend(
agentManager
.invokeAgent(
spawned.agent(),
runtimeContext,
spawned.sessionId(),
currentUserId,
capturedMessage)
Expand Down Expand Up @@ -639,7 +641,7 @@ private Mono<Msg> execLocalSync(
.withSource(sourcePath));

return agentManager
.invokeAgent(agent, sessionId, userId, prompt)
.invokeAgent(agent, parentCtx, sessionId, userId, prompt)
.contextWrite(
c ->
c.put(
Expand All @@ -660,6 +662,7 @@ private Mono<Msg> execLocalSync(
return agentManager
.invokeAgentStream(
agent,
parentCtx,
sessionId,
userId,
prompt,
Expand All @@ -682,11 +685,12 @@ private Mono<Msg> 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);
});
}

Expand Down Expand Up @@ -892,6 +896,7 @@ private Mono<String> execSpawnTask(
agentManager
.invokeAgent(
spawned.agent(),
runtimeContext,
spawned.sessionId(),
currentUserId,
capturedTask)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}

/**
Expand All @@ -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");
}
}

/**
Expand All @@ -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");
}
}

// -------------------------------------------------------------------------
Expand Down
Loading
Loading