mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +08:00
feat(agent): SessionListTool discovers persisted sub-agent sessions for send
This commit is contained in:
parent
acfac0b56f
commit
dd3dcc55ee
@ -1,5 +1,6 @@
|
|||||||
package vip.mate.tool.builtin;
|
package vip.mate.tool.builtin;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.ai.tool.annotation.Tool;
|
import org.springframework.ai.tool.annotation.Tool;
|
||||||
@ -8,25 +9,30 @@ import org.springframework.ai.chat.model.ToolContext;
|
|||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import vip.mate.agent.delegation.SubagentRegistry;
|
import vip.mate.agent.delegation.SubagentRegistry;
|
||||||
import vip.mate.agent.delegation.SubagentRegistry.SubagentRecord;
|
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.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read-only session listing tool: enumerates the live sub-agents spawned from
|
* Read-only session listing tool: enumerates the sub-agents the current
|
||||||
* the current conversation's delegation tree.
|
* conversation has delegated to — both the ones still running and the ones that
|
||||||
|
* have finished and can be followed up on.
|
||||||
*
|
*
|
||||||
* <p>Completes the spawn / send / list triad alongside {@link DelegateAgentTool}
|
* <p>Completes the spawn / send / list triad alongside {@link DelegateAgentTool}
|
||||||
* (spawn). Where {@code delegateParallel} fans children out and blocks for their
|
* (spawn) and {@link SessionSendTool} (send). It is the discovery surface for
|
||||||
* combined result, this lets the parent agent inspect the tree mid-reasoning —
|
* send: each row carries the {@code session_id} (the child's persisted
|
||||||
* which children are still running, what phase / tool each is on, how many tool
|
* conversation id) so the parent can pick a finished child and continue it,
|
||||||
* calls each has made — so it can decide whether to wait, follow up, or move on
|
* which the live in-memory registry alone cannot show (it drops a child the
|
||||||
* instead of re-dispatching roles it already spawned.
|
* moment it completes).
|
||||||
*
|
*
|
||||||
* <p>Resolves the human-facing root conversation the same way the delegation
|
* <p>Source of truth is the persisted direct children of the caller's
|
||||||
* relay does ({@link DelegationContext#rootConversationId()} when running inside
|
* conversation (so finished sessions stay discoverable), overlaid with live
|
||||||
* a delegated layer, otherwise the current {@link ToolExecutionContext}
|
* status from {@link SubagentRegistry} for any child still running. Resolves the
|
||||||
* conversation), then reads the in-memory {@link SubagentRegistry}. It never
|
* caller conversation the same way the delegation relay does. Read-only, so it
|
||||||
* mutates state, so it is safe for children to call as well.
|
* is safe for children to call.
|
||||||
*
|
*
|
||||||
* @author MateClaw Team
|
* @author MateClaw Team
|
||||||
*/
|
*/
|
||||||
@ -35,46 +41,68 @@ import java.util.List;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class SessionListTool {
|
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 SubagentRegistry subagentRegistry;
|
||||||
|
private final ConversationMapper conversationMapper;
|
||||||
|
|
||||||
@Tool(description = """
|
@Tool(description = """
|
||||||
List the live sub-agents spawned from the current conversation, including each one's
|
List the sub-agents this conversation has delegated to — both running and finished —
|
||||||
id, target agent, tree depth, status (running/completed/interrupted/stale/timeout),
|
with each one's session_id, target agent, status, and goal/title. Use it to discover a
|
||||||
current phase, tool-call count, elapsed time, and goal. Read-only: use it to check on
|
session_id to follow up on via send_to_subagent, or to check whether a child you
|
||||||
children you delegated before deciding to wait, follow up, or proceed — do NOT re-spawn
|
delegated is still running before deciding to wait or proceed. Read-only.""")
|
||||||
a role that is already listed as running.""")
|
|
||||||
public String listSubagents(@Nullable ToolContext ctx) {
|
public String listSubagents(@Nullable ToolContext ctx) {
|
||||||
String rootConversationId = resolveRootConversationId();
|
String callerConversationId = resolveCallerConversationId();
|
||||||
if (rootConversationId == null || rootConversationId.isBlank()) {
|
if (callerConversationId == null || callerConversationId.isBlank()) {
|
||||||
return "No active sub-agents (no conversation context).";
|
return "No sub-agent sessions (no conversation context).";
|
||||||
}
|
}
|
||||||
|
|
||||||
List<SubagentRecord> records = subagentRegistry.snapshotTree(rootConversationId);
|
// Persisted direct children — the sendable sessions, including finished ones.
|
||||||
if (records.isEmpty()) {
|
List<ConversationEntity> children = conversationMapper.selectList(
|
||||||
return "No active sub-agents for this conversation.";
|
new LambdaQueryWrapper<ConversationEntity>()
|
||||||
|
.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<String, SubagentRecord> 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();
|
long now = System.currentTimeMillis();
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
sb.append("Active sub-agents (").append(records.size()).append("):\n");
|
sb.append("Sub-agent sessions for this conversation (").append(
|
||||||
// Stable, human-readable order: shallow layers first, then by spawn time
|
Math.max(children.size(), liveByConv.size())).append("):\n");
|
||||||
// so a parent reads its direct children before their descendants.
|
|
||||||
records.stream()
|
for (ConversationEntity child : children) {
|
||||||
.sorted((a, b) -> {
|
String convId = child.getConversationId();
|
||||||
int byDepth = Integer.compare(a.depth(), b.depth());
|
SubagentRecord live = liveByConv.remove(convId);
|
||||||
return byDepth != 0 ? byDepth : Long.compare(a.startedAt(), b.startedAt());
|
sb.append(live != null ? formatLive(convId, live, now) : formatPersisted(child)).append('\n');
|
||||||
})
|
}
|
||||||
.forEach(r -> sb.append(formatRecord(r, now)).append('\n'));
|
// Any live record whose persisted row wasn't returned (e.g. just spawned,
|
||||||
return sb.toString().stripTrailing();
|
// outside the LIMIT window) still gets listed so nothing in flight hides.
|
||||||
|
for (Map.Entry<String, SubagentRecord> 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
|
* Caller conversation to scope the listing to: the relay-carried root when
|
||||||
* happens inside a delegated layer, falling back to the current conversation
|
* this runs inside a delegated layer, otherwise the current conversation.
|
||||||
* (the top-level agent's own conversation, which is the tree root for the
|
|
||||||
* children it spawned).
|
|
||||||
*/
|
*/
|
||||||
private String resolveRootConversationId() {
|
private String resolveCallerConversationId() {
|
||||||
String root = DelegationContext.rootConversationId();
|
String root = DelegationContext.rootConversationId();
|
||||||
if (root != null && !root.isBlank()) {
|
if (root != null && !root.isBlank()) {
|
||||||
return root;
|
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);
|
long elapsedSec = Math.max(0, (now - r.startedAt()) / 1000);
|
||||||
String goal = r.goal() == null ? "" : r.goal();
|
return "- session_id=" + convId
|
||||||
if (goal.length() > 80) {
|
+ " | agent=" + r.agentId()
|
||||||
goal = goal.substring(0, 80) + "…";
|
+ " | " + 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()
|
return text.length() <= max ? text : text.substring(0, max) + "…";
|
||||||
+ " depth=" + r.depth()
|
|
||||||
+ " status=" + r.status().get()
|
|
||||||
+ " phase=" + r.currentPhase().get()
|
|
||||||
+ " tools=" + r.toolCount().get()
|
|
||||||
+ " elapsed=" + elapsedSec + "s"
|
|
||||||
+ " goal=\"" + goal + "\"";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,29 +1,53 @@
|
|||||||
package vip.mate.tool.builtin;
|
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.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
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.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.assertFalse;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
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
|
* 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
|
* @author MateClaw Team
|
||||||
*/
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
class SessionListToolTest {
|
class SessionListToolTest {
|
||||||
|
|
||||||
|
@Mock ConversationMapper conversationMapper;
|
||||||
|
|
||||||
private SubagentRegistry registry;
|
private SubagentRegistry registry;
|
||||||
private SessionListTool tool;
|
private SessionListTool tool;
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void initMyBatisPlusCache() {
|
||||||
|
TableInfoHelper.initTableInfo(
|
||||||
|
new MapperBuilderAssistant(new org.apache.ibatis.session.Configuration(), ""),
|
||||||
|
ConversationEntity.class);
|
||||||
|
}
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
registry = new SubagentRegistry();
|
registry = new SubagentRegistry();
|
||||||
tool = new SessionListTool(registry);
|
tool = new SessionListTool(registry, conversationMapper);
|
||||||
// Drain any delegation frames a prior test may have leaked on this thread.
|
|
||||||
while (DelegationContext.currentDepth() > 0) {
|
while (DelegationContext.currentDepth() > 0) {
|
||||||
DelegationContext.exit();
|
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
|
@Test
|
||||||
void reportsNoContextWhenConversationUnknown() {
|
void reportsNoContextWhenConversationUnknown() {
|
||||||
// No ToolExecutionContext conversation and no delegation frame.
|
|
||||||
String out = tool.listSubagents(null);
|
String out = tool.listSubagents(null);
|
||||||
assertTrue(out.contains("no conversation context"),
|
assertTrue(out.contains("no conversation context"), out);
|
||||||
"expected a no-context message, got: " + out);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void reportsEmptyTreeWhenNoSubagents() {
|
void reportsEmptyWhenNoSessions() {
|
||||||
ToolExecutionContext.set("conv-root", "tester");
|
ToolExecutionContext.set("conv-root", "tester");
|
||||||
|
when(conversationMapper.selectList(any())).thenReturn(List.of());
|
||||||
String out = tool.listSubagents(null);
|
String out = tool.listSubagents(null);
|
||||||
assertTrue(out.contains("No active sub-agents for this conversation"),
|
assertTrue(out.contains("No sub-agent sessions for this conversation"), out);
|
||||||
"expected an empty-tree message, got: " + out);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void listsActiveSubagentsForCurrentConversation() {
|
void listsPersistedSessionsWithSessionIds() {
|
||||||
ToolExecutionContext.set("conv-root", "tester");
|
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);
|
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);
|
String out = tool.listSubagents(null);
|
||||||
|
|
||||||
assertTrue(out.contains("Active sub-agents (2)"), "expected exactly two children, got: " + out);
|
assertTrue(out.contains("session_id=child-2"), out);
|
||||||
assertTrue(out.contains("agent=11"), out);
|
assertTrue(out.contains("running"), "live child should show running status: " + out);
|
||||||
assertTrue(out.contains("agent=22"), out);
|
// child-1 (no live record) renders as idle, child-2 as running — child-2 not duplicated.
|
||||||
assertTrue(out.contains("research the topic"), out);
|
assertEquals(1, out.split("session_id=child-2", -1).length - 1, "child-2 listed once: " + out);
|
||||||
assertTrue(out.contains("status=running"), out);
|
|
||||||
assertFalse(out.contains("agent=99"), "other conversation's subagent leaked: " + out);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void prefersDelegationRootOverCurrentConversation() {
|
void prefersDelegationRootOverCurrentConversation() {
|
||||||
// Inside a delegated layer, the tree root is the human-facing conversation
|
// 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");
|
ToolExecutionContext.set("child-conv", "tester");
|
||||||
DelegationContext.enter("child-conv", java.util.Set.of(), "conv-root", "sa-1", 1);
|
DelegationContext.enter("child-conv", java.util.Set.of(), "conv-root", "sa-1", 1);
|
||||||
try {
|
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);
|
String out = tool.listSubagents(null);
|
||||||
assertTrue(out.contains("Active sub-agents (1)"), out);
|
assertTrue(out.contains("session_id=child-1"), out);
|
||||||
assertTrue(out.contains("agent=11"), out);
|
assertFalse(out.contains("no conversation context"), out);
|
||||||
} finally {
|
} finally {
|
||||||
DelegationContext.exit();
|
DelegationContext.exit();
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user