mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 11:58:34 +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;
|
package vip.mate.tool.builtin;
|
||||||
|
|
||||||
|
import vip.mate.agent.delegation.SubagentRunContext;
|
||||||
|
|
||||||
import java.util.ArrayDeque;
|
import java.util.ArrayDeque;
|
||||||
import java.util.Deque;
|
import java.util.Deque;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tracks Agent delegation call context to prevent infinite recursion and carry parent session info.
|
* 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
|
* <p>Thin thread-local adapter over {@link SubagentRunContext}: each layer's
|
||||||
* parentConversationId and childDeniedTools on exit.
|
* identity is an immutable {@link SubagentRunContext}, and this class keeps a
|
||||||
* Each {@link DelegateAgentTool} delegation calls enter() before and exit() after execution.
|
* 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
|
* @author MateClaw Team
|
||||||
*/
|
*/
|
||||||
public final class DelegationContext {
|
public final class DelegationContext {
|
||||||
|
|
||||||
/**
|
private static final ThreadLocal<Deque<SubagentRunContext>> STACK =
|
||||||
* Snapshot of one delegation layer's state.
|
ThreadLocal.withInitial(ArrayDeque::new);
|
||||||
*
|
|
||||||
* <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 DelegationContext() {}
|
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).
|
* 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
|
* <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)}.
|
* depth is carried in via {@link #enter(String, Set, String, String, int)}.
|
||||||
*/
|
*/
|
||||||
public static int currentDepth() {
|
public static int currentDepth() {
|
||||||
Frame top = STACK.get().peek();
|
return current().depth();
|
||||||
return top != null ? top.depth : 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Depth for the next layer when the caller doesn't pass one explicitly. */
|
/** Depth for the next layer when the caller doesn't pass one explicitly. */
|
||||||
private static int nextDepth() {
|
private static int nextDepth() {
|
||||||
Frame top = STACK.get().peek();
|
return current().depth() + 1;
|
||||||
return (top != null ? top.depth : 0) + 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parent conversation ID for event relay (from the current frame) */
|
/** Parent conversation ID for event relay (from the current frame) */
|
||||||
public static String parentConversationId() {
|
public static String parentConversationId() {
|
||||||
Frame top = STACK.get().peek();
|
return current().parentConversationId();
|
||||||
return top != null ? top.parentConversationId : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Denied tools set for the child Agent (from the current frame) */
|
/** Denied tools set for the child Agent (from the current frame) */
|
||||||
public static Set<String> childDeniedTools() {
|
public static Set<String> childDeniedTools() {
|
||||||
Frame top = STACK.get().peek();
|
return current().deniedTools();
|
||||||
return top != null && top.childDeniedTools != null ? top.childDeniedTools : Set.of();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Root (human-facing) conversation ID for the whole tree, or null at top level. */
|
/** Root (human-facing) conversation ID for the whole tree, or null at top level. */
|
||||||
public static String rootConversationId() {
|
public static String rootConversationId() {
|
||||||
Frame top = STACK.get().peek();
|
return current().rootConversationId();
|
||||||
return top != null ? top.rootConversationId : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Subagent id of the layer currently executing, or null at top level. */
|
/** Subagent id of the layer currently executing, or null at top level. */
|
||||||
public static String currentSubagentId() {
|
public static String currentSubagentId() {
|
||||||
Frame top = STACK.get().peek();
|
return current().currentSubagentId();
|
||||||
return top != null ? top.currentSubagentId : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Enter the next delegation layer (with parent conversation ID and child tool restrictions) */
|
/** 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,
|
public static void enter(String parentConversationId, Set<String> deniedTools,
|
||||||
String rootConversationId, String currentSubagentId, int depth) {
|
String rootConversationId, String currentSubagentId, int depth) {
|
||||||
STACK.get().push(new Frame(parentConversationId, deniedTools,
|
push(new SubagentRunContext(depth, parentConversationId, rootConversationId,
|
||||||
rootConversationId, currentSubagentId, depth));
|
currentSubagentId, deniedTools));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Enter the next delegation layer (backward-compatible overload) */
|
/** Enter the next delegation layer (backward-compatible overload) */
|
||||||
@ -109,9 +110,19 @@ public final class DelegationContext {
|
|||||||
enter(null, null, null, null, nextDepth());
|
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 */
|
/** Exit the current delegation layer, restoring the previous layer's context */
|
||||||
public static void exit() {
|
public static void exit() {
|
||||||
Deque<Frame> stack = STACK.get();
|
Deque<SubagentRunContext> stack = STACK.get();
|
||||||
if (!stack.isEmpty()) {
|
if (!stack.isEmpty()) {
|
||||||
stack.pop();
|
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