From d3d86d54813936df17bdeebf9538e2728aff379b Mon Sep 17 00:00:00 2001 From: matevip Date: Tue, 23 Jun 2026 18:22:10 +0800 Subject: [PATCH] feat(agent): add SessionListTool for enumerating live sub-agents --- .../mate/tool/builtin/SessionListTool.java | 103 ++++++++++++++++++ .../delegation/SubagentRunContextTest.java | 8 +- .../tool/builtin/SessionListToolTest.java | 90 +++++++++++++++ 3 files changed, 197 insertions(+), 4 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/builtin/SessionListTool.java create mode 100644 mateclaw-server/src/test/java/vip/mate/tool/builtin/SessionListToolTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SessionListTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SessionListTool.java new file mode 100644 index 00000000..6ceba1db --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SessionListTool.java @@ -0,0 +1,103 @@ +package vip.mate.tool.builtin; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.lang.Nullable; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.stereotype.Component; +import vip.mate.agent.delegation.SubagentRegistry; +import vip.mate.agent.delegation.SubagentRegistry.SubagentRecord; + +import java.util.List; + +/** + * Read-only session listing tool: enumerates the live sub-agents spawned from + * the current conversation's delegation tree. + * + *

Completes the spawn / send / list triad alongside {@link DelegateAgentTool} + * (spawn). Where {@code delegateParallel} fans children out and blocks for their + * combined result, this lets the parent agent inspect the tree mid-reasoning — + * which children are still running, what phase / tool each is on, how many tool + * calls each has made — so it can decide whether to wait, follow up, or move on + * instead of re-dispatching roles it already spawned. + * + *

Resolves the human-facing root conversation the same way the delegation + * relay does ({@link DelegationContext#rootConversationId()} when running inside + * a delegated layer, otherwise the current {@link ToolExecutionContext} + * conversation), then reads the in-memory {@link SubagentRegistry}. It never + * mutates state, so it is safe for children to call as well. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SessionListTool { + + private final SubagentRegistry subagentRegistry; + + @Tool(description = """ + List the live sub-agents spawned from the current conversation, including each one's + id, target agent, tree depth, status (running/completed/interrupted/stale/timeout), + current phase, tool-call count, elapsed time, and goal. Read-only: use it to check on + children you delegated before deciding to wait, follow up, or proceed — do NOT re-spawn + a role that is already listed as running.""") + public String listSubagents(@Nullable ToolContext ctx) { + String rootConversationId = resolveRootConversationId(); + if (rootConversationId == null || rootConversationId.isBlank()) { + return "No active sub-agents (no conversation context)."; + } + + List records = subagentRegistry.snapshotTree(rootConversationId); + if (records.isEmpty()) { + return "No active sub-agents for this conversation."; + } + + long now = System.currentTimeMillis(); + StringBuilder sb = new StringBuilder(); + sb.append("Active sub-agents (").append(records.size()).append("):\n"); + // Stable, human-readable order: shallow layers first, then by spawn time + // so a parent reads its direct children before their descendants. + records.stream() + .sorted((a, b) -> { + int byDepth = Integer.compare(a.depth(), b.depth()); + return byDepth != 0 ? byDepth : Long.compare(a.startedAt(), b.startedAt()); + }) + .forEach(r -> sb.append(formatRecord(r, now)).append('\n')); + return sb.toString().stripTrailing(); + } + + /** + * Root of the delegation tree to list: the relay-carried root when this call + * happens inside a delegated layer, falling back to the current conversation + * (the top-level agent's own conversation, which is the tree root for the + * children it spawned). + */ + private String resolveRootConversationId() { + String root = DelegationContext.rootConversationId(); + if (root != null && !root.isBlank()) { + return root; + } + try { + return ToolExecutionContext.conversationId(); + } catch (Exception ignored) { + return null; + } + } + + private String formatRecord(SubagentRecord r, long now) { + long elapsedSec = Math.max(0, (now - r.startedAt()) / 1000); + String goal = r.goal() == null ? "" : r.goal(); + if (goal.length() > 80) { + goal = goal.substring(0, 80) + "…"; + } + return "- [" + r.subagentId() + "] agent=" + r.agentId() + + " depth=" + r.depth() + + " status=" + r.status().get() + + " phase=" + r.currentPhase().get() + + " tools=" + r.toolCount().get() + + " elapsed=" + elapsedSec + "s" + + " goal=\"" + goal + "\""; + } +} 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 index a345ede7..6bcde48a 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/delegation/SubagentRunContextTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/delegation/SubagentRunContextTest.java @@ -62,10 +62,10 @@ class SubagentRunContextTest { } /** - * 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. + * 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 { diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/SessionListToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/SessionListToolTest.java new file mode 100644 index 00000000..2f5e1f33 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/SessionListToolTest.java @@ -0,0 +1,90 @@ +package vip.mate.tool.builtin; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import vip.mate.agent.delegation.SubagentRegistry; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for {@link SessionListTool} — the read-only "list" leg of the + * spawn / send / list triad. + * + * @author MateClaw Team + */ +class SessionListToolTest { + + private SubagentRegistry registry; + private SessionListTool tool; + + @BeforeEach + void setUp() { + registry = new SubagentRegistry(); + tool = new SessionListTool(registry); + // Drain any delegation frames a prior test may have leaked on this thread. + while (DelegationContext.currentDepth() > 0) { + DelegationContext.exit(); + } + ToolExecutionContext.clear(); + } + + @AfterEach + void tearDown() { + ToolExecutionContext.clear(); + while (DelegationContext.currentDepth() > 0) { + DelegationContext.exit(); + } + } + + @Test + void reportsNoContextWhenConversationUnknown() { + // No ToolExecutionContext conversation and no delegation frame. + String out = tool.listSubagents(null); + assertTrue(out.contains("no conversation context"), + "expected a no-context message, got: " + out); + } + + @Test + void reportsEmptyTreeWhenNoSubagents() { + ToolExecutionContext.set("conv-root", "tester"); + String out = tool.listSubagents(null); + assertTrue(out.contains("No active sub-agents for this conversation"), + "expected an empty-tree message, got: " + out); + } + + @Test + void listsActiveSubagentsForCurrentConversation() { + ToolExecutionContext.set("conv-root", "tester"); + registry.register("conv-root", "child-1", 11L, "research the topic", null); + registry.register("conv-root", "child-2", 22L, "draft the summary", null); + // A subagent under a different root must not leak into this listing. + registry.register("other-conv", "child-x", 99L, "unrelated work", null); + + String out = tool.listSubagents(null); + + assertTrue(out.contains("Active sub-agents (2)"), "expected exactly two children, got: " + out); + assertTrue(out.contains("agent=11"), out); + assertTrue(out.contains("agent=22"), out); + assertTrue(out.contains("research the topic"), out); + assertTrue(out.contains("status=running"), out); + assertFalse(out.contains("agent=99"), "other conversation's subagent leaked: " + out); + } + + @Test + void prefersDelegationRootOverCurrentConversation() { + // Inside a delegated layer, the tree root is the human-facing conversation + // carried by the delegation frame, not the child's own conversation. + ToolExecutionContext.set("child-conv", "tester"); + DelegationContext.enter("child-conv", java.util.Set.of(), "conv-root", "sa-1", 1); + try { + registry.register("conv-root", "child-1", 11L, "research the topic", null); + String out = tool.listSubagents(null); + assertTrue(out.contains("Active sub-agents (1)"), out); + assertTrue(out.contains("agent=11"), out); + } finally { + DelegationContext.exit(); + } + } +}