mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +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
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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