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));
+ }
+}