mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 11:58:34 +08:00
feat(agent): add SessionSendTool for multi-turn sub-agent follow-ups
This commit is contained in:
parent
0d9c2b3532
commit
acfac0b56f
@ -48,7 +48,9 @@ import java.util.stream.Collectors;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class DelegateAgentTool {
|
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;
|
private static final int MAX_RESULT_LENGTH = 4000;
|
||||||
/**
|
/**
|
||||||
* Cap on children dispatched in a single delegateParallel call. Set to 8
|
* Cap on children dispatched in a single delegateParallel call. Set to 8
|
||||||
@ -114,6 +116,10 @@ public class DelegateAgentTool {
|
|||||||
"delegateToAgent",
|
"delegateToAgent",
|
||||||
"delegateParallel",
|
"delegateParallel",
|
||||||
"listAvailableAgents",
|
"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
|
// Memory writes from children would pollute the parent's shared
|
||||||
// long-term memory surface.
|
// long-term memory surface.
|
||||||
"remember",
|
"remember",
|
||||||
@ -321,7 +327,15 @@ public class DelegateAgentTool {
|
|||||||
subagentId, parentSubagentId, childDepth);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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]";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
@ -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;
|
||||||
@ -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);
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user