mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(agent): introduce SubagentRunContext value object for delegation runtime identity
This commit is contained in:
parent
68ec8f04c0
commit
14ee45a30d
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user