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());
+ }
+}