diff --git a/mateclaw-server/src/main/java/vip/mate/agent/delegation/SubagentRunContext.java b/mateclaw-server/src/main/java/vip/mate/agent/delegation/SubagentRunContext.java new file mode 100644 index 00000000..6a9920e9 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/delegation/SubagentRunContext.java @@ -0,0 +1,75 @@ +package vip.mate.agent.delegation; + +import java.util.Set; + +/** + * Immutable snapshot of one delegation layer's runtime identity. + * + *

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. + * + *

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 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 childDeniedTools) { + String inheritedRoot = (rootConversationId != null) ? rootConversationId : childParentConversationId; + return new SubagentRunContext(depth + 1, childParentConversationId, + inheritedRoot, childSubagentId, childDeniedTools); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegationContext.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegationContext.java index 85481626..5153bb54 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegationContext.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegationContext.java @@ -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. - *

- * 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. + * + *

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. + * + *

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. - * - *

{@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 childDeniedTools, - String rootConversationId, String currentSubagentId, int depth) {} - - private static final ThreadLocal> STACK = ThreadLocal.withInitial(ArrayDeque::new); + private static final ThreadLocal> 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). *

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 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 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 stack = STACK.get(); + Deque stack = STACK.get(); if (!stack.isEmpty()) { stack.pop(); } diff --git a/mateclaw-server/src/test/java/vip/mate/agent/delegation/SubagentRunContextTest.java b/mateclaw-server/src/test/java/vip/mate/agent/delegation/SubagentRunContextTest.java new file mode 100644 index 00000000..a345ede7 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/delegation/SubagentRunContextTest.java @@ -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 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 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()); + } +}