From acfac0b56fb1af49f12ed6c7fb131516c6162ee3 Mon Sep 17 00:00:00 2001 From: matevip Date: Tue, 23 Jun 2026 18:22:32 +0800 Subject: [PATCH] feat(agent): add SessionSendTool for multi-turn sub-agent follow-ups --- .../mate/tool/builtin/DelegateAgentTool.java | 18 ++- .../mate/tool/builtin/SessionSendTool.java | 136 ++++++++++++++++++ .../h2/V158__register_session_send_tool.sql | 9 ++ .../V158__register_session_send_tool.sql | 9 ++ .../V158__register_session_send_tool.sql | 9 ++ .../tool/builtin/SessionSendToolTest.java | 126 ++++++++++++++++ 6 files changed, 305 insertions(+), 2 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/builtin/SessionSendTool.java create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V158__register_session_send_tool.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/kingbase/V158__register_session_send_tool.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V158__register_session_send_tool.sql create mode 100644 mateclaw-server/src/test/java/vip/mate/tool/builtin/SessionSendToolTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java index b12a0ec8..a4f2955a 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java @@ -48,7 +48,9 @@ import java.util.stream.Collectors; @RequiredArgsConstructor public class DelegateAgentTool { - private static final int MAX_DELEGATION_DEPTH = 3; + // Package-private so sibling session tools (e.g. SessionSendTool, which + // re-enters a child run) share one source of truth for the recursion cap. + static final int MAX_DELEGATION_DEPTH = 3; private static final int MAX_RESULT_LENGTH = 4000; /** * Cap on children dispatched in a single delegateParallel call. Set to 8 @@ -114,6 +116,10 @@ public class DelegateAgentTool { "delegateToAgent", "delegateParallel", "listAvailableAgents", + // A child following up on its own grand-children via send would be + // horizontal dispatch that bypasses the spawn depth gate, so the + // continuation tool stays with the parent (same stance as delegate*). + "sendToSubagent", // Memory writes from children would pollute the parent's shared // long-term memory surface. "remember", @@ -321,7 +327,15 @@ public class DelegateAgentTool { subagentId, parentSubagentId, childDepth); } - return result.toToolResponse(target.getName()); + String response = result.toToolResponse(target.getName()); + // Surface the child's session handle so the parent can follow up on this + // exact sub-agent (its conversation persists past this call) via + // send_to_subagent, instead of re-spawning a fresh, context-less child. + if (result.success() && childConversationId != null) { + response += "\n\n[session_id: " + childConversationId + + " — to follow up with this sub-agent, call send_to_subagent(session_id, message)]"; + } + return response; } /** diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SessionSendTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SessionSendTool.java new file mode 100644 index 00000000..af1b2559 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SessionSendTool.java @@ -0,0 +1,136 @@ +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.chat.model.ToolContext; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import vip.mate.agent.AgentService; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.workspace.conversation.model.ConversationEntity; +import vip.mate.workspace.conversation.repository.ConversationMapper; + +/** + * Multi-turn "send" leg of the spawn / send / list delegation triad: continues a + * sub-agent's existing session with a follow-up message instead of spawning a + * fresh, context-less child. + * + *

The session handle is the child's own (persisted) {@code conversationId}, + * surfaced by {@link DelegateAgentTool#delegateToAgent} in its result. Because + * the child conversation persists past the original delegation call, the parent + * can later ask it to refine / expand / correct its earlier output and the child + * still sees its own prior context. + * + *

Guards mirror the spawn path: the recursion depth cap is shared with + * {@link DelegateAgentTool}, a child may only be continued by the conversation + * that spawned it, and the continued run is re-entered under the standard child + * deny set so it cannot delegate or send onward. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SessionSendTool { + + private static final int MAX_RESULT_LENGTH = 4000; + + private final AgentService agentService; + private final ConversationMapper conversationMapper; + + @Tool(description = """ + Send a follow-up message to a sub-agent you previously delegated to, continuing its + existing session (so it still remembers the earlier task) rather than starting fresh. + Pass the session_id that delegateToAgent returned. Use it to ask a child to refine, + expand, or correct its earlier result.""") + public String sendToSubagent( + @ToolParam(description = "The session_id returned by a prior delegateToAgent call") String sessionId, + @ToolParam(description = "Follow-up message / instruction for the sub-agent") String message, + @Nullable ToolContext ctx) { + + if (sessionId == null || sessionId.isBlank()) { + return "[Error] session_id is required."; + } + if (message == null || message.isBlank()) { + return "[Error] message is required."; + } + + // Depth guard: a send re-enters a child run one level below the caller, + // so refuse if that would breach the shared recursion cap. + int callerDepth = DelegationContext.currentDepth(); + if (callerDepth >= DelegateAgentTool.MAX_DELEGATION_DEPTH) { + return "[Error] Delegation depth limit (" + DelegateAgentTool.MAX_DELEGATION_DEPTH + + ") reached; cannot follow up on a sub-agent from here."; + } + + ConversationEntity child = conversationMapper.selectOne( + new LambdaQueryWrapper() + .eq(ConversationEntity::getConversationId, sessionId)); + if (child == null) { + return "[Error] Unknown session_id: " + sessionId; + } + if (child.getParentConversationId() == null) { + return "[Error] " + sessionId + " is not a sub-agent session."; + } + + // Tenant / ownership: only the conversation that spawned the child may + // continue it, so a sibling or another tenant cannot drive someone + // else's sub-agent. + String callerConversationId = resolveCallerConversationId(); + if (callerConversationId == null || !callerConversationId.equals(child.getParentConversationId())) { + return "[Error] session " + sessionId + " does not belong to this conversation."; + } + if (child.getAgentId() == null) { + return "[Error] session " + sessionId + " has no bound agent."; + } + + Long agentId = child.getAgentId(); + ChatOrigin origin = ChatOrigin.from(ctx).withAgent(agentId).withConversationId(sessionId); + + // Re-enter the delegation context one level below the caller so the + // continued child stays gated (cannot delegate / send onward) and the + // depth cap keeps holding for anything it tries to spawn. + DelegationContext.enter(callerConversationId, DelegateAgentTool.DEFAULT_CHILD_DENIED_TOOLS, + resolveRootConversationId(callerConversationId), null, callerDepth + 1); + try { + String raw = agentService.chat(agentId, message, sessionId, origin); + return "[Sub-agent reply | session " + sessionId + "]\n\n" + + truncate(raw != null ? raw : "", MAX_RESULT_LENGTH); + } catch (Exception e) { + log.error("send_to_subagent failed: session={}, error={}", sessionId, e.getMessage()); + return "[Error] Sub-agent follow-up failed: " + e.getMessage(); + } finally { + DelegationContext.exit(); + } + } + + private String resolveCallerConversationId() { + try { + String c = ToolExecutionContext.conversationId(); + if (c != null && !c.isBlank()) { + return c; + } + } catch (Exception ignored) { + // fall through to the delegation frame below + } + return DelegationContext.parentConversationId(); + } + + private String resolveRootConversationId(String callerConversationId) { + String root = DelegationContext.rootConversationId(); + return (root != null && !root.isBlank()) ? root : callerConversationId; + } + + private static String truncate(String text, int maxLength) { + if (text == null) { + return ""; + } + if (text.length() <= maxLength) { + return text; + } + return text.substring(0, maxLength) + "\n... [truncated, original " + text.length() + " chars]"; + } +} diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V158__register_session_send_tool.sql b/mateclaw-server/src/main/resources/db/migration/h2/V158__register_session_send_tool.sql new file mode 100644 index 00000000..f8c3cb6e --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V158__register_session_send_tool.sql @@ -0,0 +1,9 @@ +-- V158: Register SessionSendTool as a built-in tool. +-- Completes the spawn/send/list delegation triad in the tool picker alongside +-- DelegateAgentTool (spawn) and SessionListTool (list). Core-tier and already +-- auto-available without this row; the row only adds UI metadata. +-- Idempotent: MERGE INTO updates existing rows when id matches. + +MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +KEY (id) +VALUES (1000000025, 'SessionSendTool', 'Sub-Agent Send', 'Send a follow-up message to a sub-agent you previously delegated to, continuing its existing session (so it still remembers the earlier task) instead of starting fresh. Pass the session_id returned by delegateToAgent.', 'builtin', 'sessionSendTool', '✉️', TRUE, TRUE, NOW(), NOW(), 0); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V158__register_session_send_tool.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V158__register_session_send_tool.sql new file mode 100644 index 00000000..244837e1 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V158__register_session_send_tool.sql @@ -0,0 +1,9 @@ +-- V158: Register SessionSendTool as a built-in tool. +-- Completes the spawn/send/list delegation triad in the tool picker alongside +-- DelegateAgentTool (spawn) and SessionListTool (list). Core-tier and already +-- auto-available without this row; the row only adds UI metadata. +-- Idempotent: ON CONFLICT keeps the row in sync if it already exists. + +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000025, 'SessionSendTool', 'Sub-Agent Send', 'Send a follow-up message to a sub-agent you previously delegated to, continuing its existing session (so it still remembers the earlier task) instead of starting fresh. Pass the session_id returned by delegateToAgent.', 'builtin', 'sessionSendTool', '✉️', TRUE, TRUE, NOW(), NOW(), 0) +ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V158__register_session_send_tool.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V158__register_session_send_tool.sql new file mode 100644 index 00000000..e6b6bc2a --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V158__register_session_send_tool.sql @@ -0,0 +1,9 @@ +-- V158: Register SessionSendTool as a built-in tool. +-- Completes the spawn/send/list delegation triad in the tool picker alongside +-- DelegateAgentTool (spawn) and SessionListTool (list). Core-tier and already +-- auto-available without this row; the row only adds UI metadata. +-- Idempotent: ON DUPLICATE KEY UPDATE keeps the row in sync if it already exists. + +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000025, 'SessionSendTool', 'Sub-Agent Send', 'Send a follow-up message to a sub-agent you previously delegated to, continuing its existing session (so it still remembers the earlier task) instead of starting fresh. Pass the session_id returned by delegateToAgent.', 'builtin', 'sessionSendTool', '✉️', TRUE, TRUE, NOW(), NOW(), 0) +ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted); diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/SessionSendToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/SessionSendToolTest.java new file mode 100644 index 00000000..25870853 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/SessionSendToolTest.java @@ -0,0 +1,126 @@ +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import vip.mate.agent.AgentService; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.workspace.conversation.model.ConversationEntity; +import vip.mate.workspace.conversation.repository.ConversationMapper; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link SessionSendTool} — the multi-turn "send" leg of the + * spawn / send / list delegation triad. + * + * @author MateClaw Team + */ +@ExtendWith(MockitoExtension.class) +class SessionSendToolTest { + + @Mock AgentService agentService; + @Mock ConversationMapper conversationMapper; + + @InjectMocks SessionSendTool tool; + + @BeforeAll + static void initMyBatisPlusCache() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new org.apache.ibatis.session.Configuration(), ""), + ConversationEntity.class); + } + + @BeforeEach + void setUp() { + ToolExecutionContext.clear(); + while (DelegationContext.currentDepth() > 0) { + DelegationContext.exit(); + } + } + + @AfterEach + void tearDown() { + ToolExecutionContext.clear(); + while (DelegationContext.currentDepth() > 0) { + DelegationContext.exit(); + } + } + + private static ConversationEntity child(String conversationId, String parentConversationId, Long agentId) { + ConversationEntity c = new ConversationEntity(); + c.setConversationId(conversationId); + c.setParentConversationId(parentConversationId); + c.setAgentId(agentId); + return c; + } + + @Test + void rejectsUnknownSession() { + ToolExecutionContext.set("conv-root", "tester"); + when(conversationMapper.selectOne(any())).thenReturn(null); + String out = tool.sendToSubagent("child-x", "do more", null); + assertTrue(out.contains("Unknown session_id"), out); + verify(agentService, never()).chat(any(), any(), any(), any()); + } + + @Test + void rejectsNonSubagentSession() { + ToolExecutionContext.set("conv-root", "tester"); + when(conversationMapper.selectOne(any())).thenReturn(child("conv-root", null, 7L)); + String out = tool.sendToSubagent("conv-root", "do more", null); + assertTrue(out.contains("not a sub-agent session"), out); + verify(agentService, never()).chat(any(), any(), any(), any()); + } + + @Test + void rejectsSessionOwnedByAnotherConversation() { + ToolExecutionContext.set("conv-root", "tester"); + // Child's parent is a different conversation than the caller. + when(conversationMapper.selectOne(any())).thenReturn(child("child-1", "other-conv", 7L)); + String out = tool.sendToSubagent("child-1", "do more", null); + assertTrue(out.contains("does not belong to this conversation"), out); + verify(agentService, never()).chat(any(), any(), any(), any()); + } + + @Test + void rejectsWhenDepthLimitReached() { + ToolExecutionContext.set("conv-root", "tester"); + // Simulate being already at the max delegation depth. + DelegationContext.enter("conv", java.util.Set.of(), "root", "sa", DelegateAgentTool.MAX_DELEGATION_DEPTH); + try { + String out = tool.sendToSubagent("child-1", "do more", null); + assertTrue(out.contains("depth limit"), out); + verify(conversationMapper, never()).selectOne(any()); + } finally { + DelegationContext.exit(); + } + } + + @Test + void continuesChildSessionForOwningConversation() { + ToolExecutionContext.set("conv-root", "tester"); + when(conversationMapper.selectOne(any())).thenReturn(child("child-1", "conv-root", 7L)); + when(agentService.chat(eq(7L), eq("refine it"), eq("child-1"), any(ChatOrigin.class))) + .thenReturn("refined result"); + + String out = tool.sendToSubagent("child-1", "refine it", null); + + assertTrue(out.contains("Sub-agent reply"), out); + assertTrue(out.contains("child-1"), out); + assertTrue(out.contains("refined result"), out); + verify(agentService).chat(eq(7L), eq("refine it"), eq("child-1"), any(ChatOrigin.class)); + } +}