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 @@ -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,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;
Comment thread
guslegend0510 marked this conversation as resolved.
Outdated
Comment thread
guslegend0510 marked this conversation as resolved.
Outdated
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();
}
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 @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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() {
Comment thread
guslegend0510 marked this conversation as resolved.
Comment thread
guslegend0510 marked this conversation as resolved.
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<RuntimeContext> 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<RuntimeContext> 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();
}
}
Loading