feat(agent): introduce SubagentRunContext value object for delegation runtime identity

This commit is contained in:
matevip 2026-06-23 18:21:59 +08:00
parent 68ec8f04c0
commit 14ee45a30d
3 changed files with 214 additions and 34 deletions

View File

@ -0,0 +1,75 @@
package vip.mate.agent.delegation;
import java.util.Set;
/**
* Immutable snapshot of one delegation layer's runtime identity.
*
* <p>This is the canonical value object that carries "who am I in the delegation
* tree" down a single child agent run: tree depth, the immediate parent
* conversation, the human-facing root conversation, the subagent id of the layer
* currently executing, and the tool deny set in force for this layer.
*
* <p>It exists as a first-class, named record (rather than an anonymous frame
* buried in a ThreadLocal stack) so the same identity can later be passed
* explicitly through the call chain instead of being reconstructed from
* thread-local state explicit passing survives virtual-thread and reactive
* hops, where a thread-confined stack does not. {@link vip.mate.tool.builtin.DelegationContext}
* currently holds a stack of these per thread; callers that already have a
* context in hand should prefer threading it explicitly.
*
* @param depth 1-based tree depth; {@code 0} means the top-level
* (non-delegated) call.
* @param parentConversationId the immediate parent conversation that spawned
* this layer, or {@code null} at the top level.
* @param rootConversationId the human-facing conversation at the top of the
* whole delegation tree; every layer carries it
* unchanged so a deep child's progress events can
* broadcast to the stream the user is watching.
* @param currentSubagentId the subagent id of the layer executing now; a
* deeper child reads it as its own parent id to
* reconstruct the spawn tree.
* @param deniedTools tool names this layer's agent may not call;
* normalised to a non-null immutable set.
*
* @author MateClaw Team
*/
public record SubagentRunContext(
int depth,
String parentConversationId,
String rootConversationId,
String currentSubagentId,
Set<String> deniedTools
) {
/** The top-level context: not inside any delegation. */
public static final SubagentRunContext ROOT = new SubagentRunContext(0, null, null, null, Set.of());
public SubagentRunContext {
// Normalise the deny set so every read site gets a non-null immutable
// view without re-checking mirrors the old accessor's null guard.
deniedTools = (deniedTools == null) ? Set.of() : Set.copyOf(deniedTools);
}
/** True when this context represents a delegated (sub-agent) layer. */
public boolean isDelegated() {
return depth > 0;
}
/**
* Build the context for the next layer spawned beneath this one. The root
* conversation is inherited unchanged (falling back to the child's parent
* conversation when this is the first delegation), and depth advances by one.
*
* @param childParentConversationId the spawning conversation for the child
* @param childSubagentId the subagent id assigned to the child
* @param childDeniedTools tool deny set for the child
*/
public SubagentRunContext childFrame(String childParentConversationId,
String childSubagentId,
Set<String> childDeniedTools) {
String inheritedRoot = (rootConversationId != null) ? rootConversationId : childParentConversationId;
return new SubagentRunContext(depth + 1, childParentConversationId,
inheritedRoot, childSubagentId, childDeniedTools);
}
}

View File

@ -1,38 +1,45 @@
package vip.mate.tool.builtin;
import vip.mate.agent.delegation.SubagentRunContext;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Set;
/**
* Tracks Agent delegation call context to prevent infinite recursion and carry parent session info.
* <p>
* Uses a ThreadLocal stack so that nested delegations correctly restore the previous layer's
* parentConversationId and childDeniedTools on exit.
* Each {@link DelegateAgentTool} delegation calls enter() before and exit() after execution.
*
* <p>Thin thread-local adapter over {@link SubagentRunContext}: each layer's
* identity is an immutable {@link SubagentRunContext}, and this class keeps a
* per-thread stack of them so nested delegations correctly restore the previous
* layer's context on {@link #exit()}. Each {@link DelegateAgentTool} delegation
* calls {@link #enter} before and {@link #exit} after execution.
*
* <p>The value object is the canonical carrier; this adapter only manages its
* thread-confined lifecycle. Call sites that already hold a
* {@link SubagentRunContext} should prefer passing it explicitly a thread-local
* stack does not survive virtual-thread / reactive hops, which is why the
* explicit-depth {@link #enter(String, Set, String, String, int)} overload
* exists for async / parallel children that start on a fresh executor thread.
*
* @author MateClaw Team
*/
public final class DelegationContext {
/**
* Snapshot of one delegation layer's state.
*
* <p>{@code rootConversationId} is the human-facing conversation at the top
* of the delegation tree every layer carries it unchanged so that a
* grandchild's progress events can be broadcast to the same stream the user
* is watching, rather than to its immediate (machine-only) parent.
* {@code currentSubagentId} is the id of the subagent running THIS layer; a
* deeper child reads it as its own {@code parentSubagentId} to reconstruct
* the spawn tree.
*/
private record Frame(String parentConversationId, Set<String> childDeniedTools,
String rootConversationId, String currentSubagentId, int depth) {}
private static final ThreadLocal<Deque<Frame>> STACK = ThreadLocal.withInitial(ArrayDeque::new);
private static final ThreadLocal<Deque<SubagentRunContext>> STACK =
ThreadLocal.withInitial(ArrayDeque::new);
private DelegationContext() {}
/**
* The context of the layer currently executing on this thread, or
* {@link SubagentRunContext#ROOT} when not inside any delegation.
*/
public static SubagentRunContext current() {
SubagentRunContext top = STACK.get().peek();
return top != null ? top : SubagentRunContext.ROOT;
}
/**
* Current delegation depth (0 = top-level call, not inside any delegation).
* <p>Read from the TOP frame's recorded depth, NOT the thread-local stack
@ -42,38 +49,32 @@ public final class DelegationContext {
* depth is carried in via {@link #enter(String, Set, String, String, int)}.
*/
public static int currentDepth() {
Frame top = STACK.get().peek();
return top != null ? top.depth : 0;
return current().depth();
}
/** Depth for the next layer when the caller doesn't pass one explicitly. */
private static int nextDepth() {
Frame top = STACK.get().peek();
return (top != null ? top.depth : 0) + 1;
return current().depth() + 1;
}
/** Parent conversation ID for event relay (from the current frame) */
public static String parentConversationId() {
Frame top = STACK.get().peek();
return top != null ? top.parentConversationId : null;
return current().parentConversationId();
}
/** Denied tools set for the child Agent (from the current frame) */
public static Set<String> childDeniedTools() {
Frame top = STACK.get().peek();
return top != null && top.childDeniedTools != null ? top.childDeniedTools : Set.of();
return current().deniedTools();
}
/** Root (human-facing) conversation ID for the whole tree, or null at top level. */
public static String rootConversationId() {
Frame top = STACK.get().peek();
return top != null ? top.rootConversationId : null;
return current().rootConversationId();
}
/** Subagent id of the layer currently executing, or null at top level. */
public static String currentSubagentId() {
Frame top = STACK.get().peek();
return top != null ? top.currentSubagentId : null;
return current().currentSubagentId();
}
/** Enter the next delegation layer (with parent conversation ID and child tool restrictions) */
@ -100,8 +101,8 @@ public final class DelegationContext {
*/
public static void enter(String parentConversationId, Set<String> deniedTools,
String rootConversationId, String currentSubagentId, int depth) {
STACK.get().push(new Frame(parentConversationId, deniedTools,
rootConversationId, currentSubagentId, depth));
push(new SubagentRunContext(depth, parentConversationId, rootConversationId,
currentSubagentId, deniedTools));
}
/** Enter the next delegation layer (backward-compatible overload) */
@ -109,9 +110,19 @@ public final class DelegationContext {
enter(null, null, null, null, nextDepth());
}
/**
* Push a pre-built context onto this thread's stack. Preferred when the
* caller already holds an explicit {@link SubagentRunContext} (e.g. one
* reconstructed on a fresh executor thread), so the identity is threaded
* as a value rather than reassembled from positional arguments.
*/
public static void push(SubagentRunContext context) {
STACK.get().push(context);
}
/** Exit the current delegation layer, restoring the previous layer's context */
public static void exit() {
Deque<Frame> stack = STACK.get();
Deque<SubagentRunContext> stack = STACK.get();
if (!stack.isEmpty()) {
stack.pop();
}

View File

@ -0,0 +1,94 @@
package vip.mate.agent.delegation;
import org.junit.jupiter.api.Test;
import vip.mate.tool.builtin.DelegationContext;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Unit tests for the {@link SubagentRunContext} value object and its use as an
* explicitly-threaded carrier across executor threads.
*
* @author MateClaw Team
*/
class SubagentRunContextTest {
@Test
void normalisesNullDenySetToEmptyImmutable() {
SubagentRunContext ctx = new SubagentRunContext(1, "p", "r", "sa", null);
assertEquals(Set.of(), ctx.deniedTools());
assertThrows(UnsupportedOperationException.class, () -> ctx.deniedTools().add("x"));
}
@Test
void copiesDenySetSoLaterMutationDoesNotLeak() {
Set<String> mutable = new HashSet<>(Set.of("toolA"));
SubagentRunContext ctx = new SubagentRunContext(1, "p", "r", "sa", mutable);
mutable.add("toolB");
// The context took an immutable copy at construction; the later add must not leak in.
assertEquals(Set.of("toolA"), ctx.deniedTools());
}
@Test
void rootContextIsNotDelegated() {
assertFalse(SubagentRunContext.ROOT.isDelegated());
assertEquals(0, SubagentRunContext.ROOT.depth());
assertNull(SubagentRunContext.ROOT.parentConversationId());
assertTrue(new SubagentRunContext(1, "p", "r", "sa", Set.of()).isDelegated());
}
@Test
void childFrameAdvancesDepthAndInheritsRoot() {
SubagentRunContext l1 = SubagentRunContext.ROOT.childFrame("conv-root", "sa-1", Set.of("delegateToAgent"));
assertEquals(1, l1.depth());
assertEquals("conv-root", l1.parentConversationId());
// First delegation: root falls back to the spawning conversation.
assertEquals("conv-root", l1.rootConversationId());
SubagentRunContext l2 = l1.childFrame("conv-child", "sa-2", Set.of());
assertEquals(2, l2.depth());
assertEquals("conv-child", l2.parentConversationId());
// Deeper layers keep broadcasting to the same human-facing root.
assertEquals("conv-root", l2.rootConversationId());
}
/**
* RFC 08 G1 guardrail: a context passed explicitly carries its depth across
* a fresh executor thread, where a size-based thread-local stack would reset
* to 1. Reconstructing the layer via {@link DelegationContext#push} on the
* child thread reproduces the real tree depth.
*/
@Test
void explicitContextCarriesDepthAcrossThreadHop() throws Exception {
// Build a depth-3 context on the dispatching thread without touching the
// current thread's stack.
SubagentRunContext dispatched = new SubagentRunContext(3, "conv", "root", "sa", Set.of("execute_code"));
AtomicInteger observedDepth = new AtomicInteger(-1);
AtomicReference<String> observedDenied = new AtomicReference<>();
Thread worker = Thread.ofVirtual().start(() -> {
// Fresh thread: stack starts empty.
assertEquals(0, DelegationContext.currentDepth());
DelegationContext.push(dispatched);
try {
observedDepth.set(DelegationContext.currentDepth());
observedDenied.set(String.join(",", DelegationContext.childDeniedTools()));
} finally {
DelegationContext.exit();
}
});
worker.join();
assertEquals(3, observedDepth.get());
assertEquals("execute_code", observedDenied.get());
}
}