feat(agent): add SessionSendTool for multi-turn sub-agent follow-ups

This commit is contained in:
matevip 2026-06-23 18:22:32 +08:00
parent 0d9c2b3532
commit acfac0b56f
6 changed files with 305 additions and 2 deletions

View File

@ -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;
}
/**

View File

@ -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.
*
* <p>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.
*
* <p>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<ConversationEntity>()
.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]";
}
}

View File

@ -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);

View File

@ -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;

View File

@ -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);

View File

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