From dd3dcc55ee8951f7202bc10bd769e5629371836b Mon Sep 17 00:00:00 2001 From: matevip Date: Tue, 23 Jun 2026 18:22:45 +0800 Subject: [PATCH] feat(agent): SessionListTool discovers persisted sub-agent sessions for send --- .../mate/tool/builtin/SessionListTool.java | 139 ++++++++++++------ .../tool/builtin/SessionListToolTest.java | 94 +++++++++--- 2 files changed, 160 insertions(+), 73 deletions(-) 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 index 6ceba1db..83e899f2 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SessionListTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SessionListTool.java @@ -1,5 +1,6 @@ package vip.mate.tool.builtin; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.tool.annotation.Tool; @@ -8,25 +9,30 @@ 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 vip.mate.workspace.conversation.model.ConversationEntity; +import vip.mate.workspace.conversation.repository.ConversationMapper; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; /** - * Read-only session listing tool: enumerates the live sub-agents spawned from - * the current conversation's delegation tree. + * Read-only session listing tool: enumerates the sub-agents the current + * conversation has delegated to — both the ones still running and the ones that + * have finished and can be followed up on. * *

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. + * (spawn) and {@link SessionSendTool} (send). It is the discovery surface for + * send: each row carries the {@code session_id} (the child's persisted + * conversation id) so the parent can pick a finished child and continue it, + * which the live in-memory registry alone cannot show (it drops a child the + * moment it completes). * - *

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. + *

Source of truth is the persisted direct children of the caller's + * conversation (so finished sessions stay discoverable), overlaid with live + * status from {@link SubagentRegistry} for any child still running. Resolves the + * caller conversation the same way the delegation relay does. Read-only, so it + * is safe for children to call. * * @author MateClaw Team */ @@ -35,46 +41,68 @@ import java.util.List; @RequiredArgsConstructor public class SessionListTool { + /** Cap on rows so a conversation with a long delegation history stays readable. */ + private static final int MAX_ROWS = 30; + private final SubagentRegistry subagentRegistry; + private final ConversationMapper conversationMapper; @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.""") + List the sub-agents this conversation has delegated to — both running and finished — + with each one's session_id, target agent, status, and goal/title. Use it to discover a + session_id to follow up on via send_to_subagent, or to check whether a child you + delegated is still running before deciding to wait or proceed. Read-only.""") public String listSubagents(@Nullable ToolContext ctx) { - String rootConversationId = resolveRootConversationId(); - if (rootConversationId == null || rootConversationId.isBlank()) { - return "No active sub-agents (no conversation context)."; + String callerConversationId = resolveCallerConversationId(); + if (callerConversationId == null || callerConversationId.isBlank()) { + return "No sub-agent sessions (no conversation context)."; } - List records = subagentRegistry.snapshotTree(rootConversationId); - if (records.isEmpty()) { - return "No active sub-agents for this conversation."; + // Persisted direct children — the sendable sessions, including finished ones. + List children = conversationMapper.selectList( + new LambdaQueryWrapper() + .eq(ConversationEntity::getParentConversationId, callerConversationId) + .eq(ConversationEntity::getDeleted, 0) + .orderByDesc(ConversationEntity::getLastActiveTime) + .last("LIMIT " + MAX_ROWS)); + + // Live status overlay, keyed by the child conversation id. + Map liveByConv = new LinkedHashMap<>(); + for (SubagentRecord r : subagentRegistry.snapshot(callerConversationId)) { + if (r.childConversationId() != null) { + liveByConv.put(r.childConversationId(), r); + } + } + + if (children.isEmpty() && liveByConv.isEmpty()) { + return "No sub-agent sessions 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(); + sb.append("Sub-agent sessions for this conversation (").append( + Math.max(children.size(), liveByConv.size())).append("):\n"); + + for (ConversationEntity child : children) { + String convId = child.getConversationId(); + SubagentRecord live = liveByConv.remove(convId); + sb.append(live != null ? formatLive(convId, live, now) : formatPersisted(child)).append('\n'); + } + // Any live record whose persisted row wasn't returned (e.g. just spawned, + // outside the LIMIT window) still gets listed so nothing in flight hides. + for (Map.Entry e : liveByConv.entrySet()) { + sb.append(formatLive(e.getKey(), e.getValue(), now)).append('\n'); + } + + sb.append("Follow up with send_to_subagent(session_id, message)."); + return sb.toString(); } /** - * 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). + * Caller conversation to scope the listing to: the relay-carried root when + * this runs inside a delegated layer, otherwise the current conversation. */ - private String resolveRootConversationId() { + private String resolveCallerConversationId() { String root = DelegationContext.rootConversationId(); if (root != null && !root.isBlank()) { return root; @@ -86,18 +114,31 @@ public class SessionListTool { } } - private String formatRecord(SubagentRecord r, long now) { + private String formatLive(String convId, 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 "- session_id=" + convId + + " | agent=" + r.agentId() + + " | " + r.status().get() + + " | phase=" + r.currentPhase().get() + + " | tools=" + r.toolCount().get() + + " | elapsed=" + elapsedSec + "s" + + " | goal=\"" + clip(r.goal(), 80) + "\""; + } + + private String formatPersisted(ConversationEntity child) { + String title = child.getTitle(); + String when = child.getLastActiveTime() != null ? child.getLastActiveTime().toString() : ""; + return "- session_id=" + child.getConversationId() + + " | agent=" + child.getAgentId() + + " | idle" + + (when.isEmpty() ? "" : " | last active " + when) + + (title == null || title.isBlank() ? "" : " | \"" + clip(title, 80) + "\""); + } + + private static String clip(String text, int max) { + if (text == null) { + return ""; } - 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 + "\""; + return text.length() <= max ? text : text.substring(0, max) + "…"; } } 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 index 2f5e1f33..a7aa80c5 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/SessionListToolTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/SessionListToolTest.java @@ -1,29 +1,53 @@ package vip.mate.tool.builtin; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; import vip.mate.agent.delegation.SubagentRegistry; +import vip.mate.workspace.conversation.model.ConversationEntity; +import vip.mate.workspace.conversation.repository.ConversationMapper; +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; /** * Unit tests for {@link SessionListTool} — the read-only "list" leg of the - * spawn / send / list triad. + * spawn / send / list triad, with DB-backed discovery of persisted child + * sessions overlaid by live registry status. * * @author MateClaw Team */ +@ExtendWith(MockitoExtension.class) class SessionListToolTest { + @Mock ConversationMapper conversationMapper; + private SubagentRegistry registry; private SessionListTool tool; + @BeforeAll + static void initMyBatisPlusCache() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new org.apache.ibatis.session.Configuration(), ""), + ConversationEntity.class); + } + @BeforeEach void setUp() { registry = new SubagentRegistry(); - tool = new SessionListTool(registry); - // Drain any delegation frames a prior test may have leaked on this thread. + tool = new SessionListTool(registry, conversationMapper); while (DelegationContext.currentDepth() > 0) { DelegationContext.exit(); } @@ -38,51 +62,73 @@ class SessionListToolTest { } } + private static ConversationEntity child(String conversationId, Long agentId, String title) { + ConversationEntity c = new ConversationEntity(); + c.setConversationId(conversationId); + c.setParentConversationId("conv-root"); + c.setAgentId(agentId); + c.setTitle(title); + c.setLastActiveTime(LocalDateTime.now()); + return c; + } + @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); + assertTrue(out.contains("no conversation context"), out); } @Test - void reportsEmptyTreeWhenNoSubagents() { + void reportsEmptyWhenNoSessions() { ToolExecutionContext.set("conv-root", "tester"); + when(conversationMapper.selectList(any())).thenReturn(List.of()); String out = tool.listSubagents(null); - assertTrue(out.contains("No active sub-agents for this conversation"), - "expected an empty-tree message, got: " + out); + assertTrue(out.contains("No sub-agent sessions for this conversation"), out); } @Test - void listsActiveSubagentsForCurrentConversation() { + void listsPersistedSessionsWithSessionIds() { ToolExecutionContext.set("conv-root", "tester"); - registry.register("conv-root", "child-1", 11L, "research the topic", null); + when(conversationMapper.selectList(any())).thenReturn(List.of( + child("child-1", 11L, "research the topic"), + child("child-2", 22L, "draft the summary"))); + + String out = tool.listSubagents(null); + + assertTrue(out.contains("session_id=child-1"), out); + assertTrue(out.contains("session_id=child-2"), out); + assertTrue(out.contains("send_to_subagent"), out); + // Finished sessions stay discoverable even though the live registry is empty. + assertTrue(out.contains("idle"), out); + } + + @Test + void overlaysLiveStatusOnPersistedSession() { + ToolExecutionContext.set("conv-root", "tester"); + when(conversationMapper.selectList(any())).thenReturn(List.of( + child("child-1", 11L, "research the topic"), + child("child-2", 22L, "draft the summary"))); + // child-2 is still running according to the live registry. 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); + assertTrue(out.contains("session_id=child-2"), out); + assertTrue(out.contains("running"), "live child should show running status: " + out); + // child-1 (no live record) renders as idle, child-2 as running — child-2 not duplicated. + assertEquals(1, out.split("session_id=child-2", -1).length - 1, "child-2 listed once: " + 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. + // Inside a delegated layer, the tree root is the human-facing 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); + when(conversationMapper.selectList(any())).thenReturn(List.of(child("child-1", 11L, "research"))); String out = tool.listSubagents(null); - assertTrue(out.contains("Active sub-agents (1)"), out); - assertTrue(out.contains("agent=11"), out); + assertTrue(out.contains("session_id=child-1"), out); + assertFalse(out.contains("no conversation context"), out); } finally { DelegationContext.exit(); }