mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +08:00
feat(agent): add SessionListTool for enumerating live sub-agents
This commit is contained in:
parent
14ee45a30d
commit
d3d86d5481
@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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<SubagentRecord> 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 + "\"";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -62,10 +62,10 @@ class SubagentRunContextTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RFC 08 G1 guardrail: a context passed explicitly carries its depth across
|
* Guardrail: a context passed explicitly carries its depth across a fresh
|
||||||
* a fresh executor thread, where a size-based thread-local stack would reset
|
* executor thread, where a size-based thread-local stack would reset to 1.
|
||||||
* to 1. Reconstructing the layer via {@link DelegationContext#push} on the
|
* Reconstructing the layer via {@link DelegationContext#push} on the child
|
||||||
* child thread reproduces the real tree depth.
|
* thread reproduces the real tree depth.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void explicitContextCarriesDepthAcrossThreadHop() throws Exception {
|
void explicitContextCarriesDepthAcrossThreadHop() throws Exception {
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user