package vip.mate.tool.builtin;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
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 org.springframework.beans.factory.annotation.Value;
import vip.mate.agent.AgentService;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.delegation.SubagentRegistry;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.audit.service.AuditEventService;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.task.AsyncTaskService;
import vip.mate.task.model.AsyncTaskEntity;
import vip.mate.workspace.conversation.ConversationService;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.*;
import java.util.stream.Collectors;
/**
* Built-in tool: Agent delegation (multi-agent collaboration).
*
* Two modes:
*
* - {@link #delegateToAgent} — single-task serial delegation
* - {@link #delegateParallel} — parallel delegation to up to 3 child agents simultaneously
*
* Each delegated agent runs in an isolated child conversation (parent-child relationship is
* persisted). Progress is relayed to the parent session via SSE events in real time.
* Child agents have a narrowed tool set — recursive delegation and agent-discovery tools are
* blocked.
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DelegateAgentTool {
private 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
* because real multi-role evaluations commonly cover 5-8 perspectives
* (architecture / backend / frontend / security / cost / ops / contrarian /
* progressive-alternative); a lower cap forces the parent to split into
* batches, and once an older batch's tool result gets compacted by
* ConversationWindowManager the parent can no longer reconstruct what each
* child said and starts re-dispatching the same roles in a loop.
*/
private static final int MAX_PARALLEL_CHILDREN = 8;
/**
* RFC-03 Lane C2 — caps for the parent-context prefix when
* {@code inheritParentContext=true} is set on {@link #delegateToAgent}.
*
* {@link #INHERITED_CONTEXT_MAX_MESSAGES} bounds the prefix length so a
* 1000-turn parent doesn't dump 1000 turns into the child prompt; 10 is
* empirically enough for "what were we just talking about" without
* blowing past typical 8k system-prompt budgets. {@link #INHERITED_CONTEXT_PER_MESSAGE_CHARS}
* truncates each individual message — useful when the parent has a
* long tool result or pasted document.
*/
static final int INHERITED_CONTEXT_MAX_MESSAGES = 10;
static final int INHERITED_CONTEXT_PER_MESSAGE_CHARS = 1000;
/**
* Wall-clock budget for one delegateParallel batch — applies to all children
* together, not per child (they run concurrently on virtual threads).
*
*
Configurable via {@code mateclaw.delegation.parallel-timeout-seconds};
* default 300 s (5 minutes). Earlier defaults (60 s → 120 s) were
* structurally too tight for thinking models: a single LLM turn against
* Kimi / GLM / MiniMax routinely takes 90–290 s when the child must
* produce multi-section structured output, so the parent gave up while the
* children were still happily streaming. 300 s matches the per-prompt
* ceiling used by ACP delegation and keeps headroom for one tool-call
* round trip on top of a single LLM turn.
*/
@Value("${mateclaw.delegation.parallel-timeout-seconds:300}")
private int parallelTimeoutSeconds;
/**
* Default deny list for child agents. Names are matched against the
* canonical tool names exposed by the runtime, so they MUST mirror the
* actual {@code @Tool}-annotated method names.
*
*
Categories:
*
* - Recursion guards (delegate*, listAvailableAgents) — prevent a
* child from spawning another child or enumerating sibling agents.
* - Memory writers (remember, *_structured) — children must not
* persist into the parent's shared MEMORY.md / SOUL.md surface;
* the parent owns long-term memory.
*
*
* {@code execute_shell_command} is intentionally NOT in the default
* deny list because legitimate dev-tooling agents rely on shell access.
* Operators that need a stricter posture can append it via
* {@code mateclaw.delegation.child-denied-tools}.
*/
static final Set DEFAULT_CHILD_DENIED_TOOLS = Set.of(
// Recursion guards.
"delegateToAgent",
"delegateParallel",
"listAvailableAgents",
// Memory writes from children would pollute the parent's shared
// long-term memory surface.
"remember",
"remember_structured",
"forget_structured",
// RFC 48 — goal ownership is bound to the parent conversation.
// A child mutating the parent's goal would let sub-agents
// declare the parent's goal "completed" or replace its budget.
"setGoal",
"addGoalCriterion",
"completeGoal",
"getGoalStatus"
);
/** Executor for parallel delegation — one JDK 21 virtual thread per child agent. */
private static final ExecutorService DELEGATION_EXECUTOR =
Executors.newVirtualThreadPerTaskExecutor();
private final AgentService agentService;
private final AgentMapper agentMapper;
private final ChatStreamTracker streamTracker;
private final ConversationService conversationService;
private final ObjectMapper objectMapper;
private final SubagentRegistry subagentRegistry;
private final AuditEventService auditEventService;
private final AsyncTaskService asyncTaskService;
/** Max characters of the task description persisted in {@code request_json}.
* Anything longer is truncated — full task is still inside the running
* child's conversation context. */
private static final int ASYNC_TASK_REQUEST_MAX_CHARS = 8000;
/** Max label length carried inside {@code request_json} and surfaced on
* spawn-event payloads. Picked to fit a short UI badge without wrapping. */
private static final int ASYNC_LABEL_MAX_CHARS = 32;
/** Default {@code block=true} wait when caller omits {@code timeoutSeconds}. */
private static final int TASK_OUTPUT_DEFAULT_TIMEOUT_S = 30;
/** Upper bound on {@code block=true} wait. Picked to be longer than the
* typical ReAct turn latency yet short enough that the parent agent
* doesn't burn its own LLM budget blocked on a stalled child. */
private static final int TASK_OUTPUT_MAX_TIMEOUT_S = 120;
/** Polling interval inside {@code block=true} wait. */
private static final long TASK_OUTPUT_POLL_INTERVAL_MS = 500L;
/**
* Operator-supplied deny-list extension. Configured via
* {@code mateclaw.delegation.child-denied-tools} as a comma-separated
* list. Empty by default — the {@link #DEFAULT_CHILD_DENIED_TOOLS} set
* already covers the recursion + memory cases that matter for safety.
*/
@Value("${mateclaw.delegation.child-denied-tools:}")
private List additionalDeniedTools;
/**
* Effective deny list = defaults ∪ operator additions. Computed on each
* delegation entry rather than cached because Spring applies
* {@code @Value} after construction and we want operator overrides to
* take effect on the next delegation, not on the next restart.
*/
Set deniedToolsForChild() {
if (additionalDeniedTools == null || additionalDeniedTools.isEmpty()) {
return DEFAULT_CHILD_DENIED_TOOLS;
}
Set merged = new HashSet<>(DEFAULT_CHILD_DENIED_TOOLS);
for (String name : additionalDeniedTools) {
if (name != null && !name.isBlank()) {
merged.add(name.trim());
}
}
return Set.copyOf(merged);
}
// ==================== Single-task delegation ====================
@vip.mate.tool.ConcurrencyUnsafe("spawns a child agent session and writes to mate_conversation; serialize to keep session graph deterministic")
@Tool(description = """
Delegate a task to another Agent for multi-agent collaboration. \
Target Agent executes in an independent session and returns its final reply. \
Parent receives real-time progress updates during execution. \
For multiple parallel tasks, use delegateParallel instead.""")
public String delegateToAgent(
@ToolParam(description = "Target Agent name (exact match)") String agentName,
@ToolParam(description = "Task description with complete context information") String task,
// RFC-03 Lane C2 — when true, the child agent receives the recent
// N messages from the parent conversation as a context prefix.
// Default false (the original isolated-child behavior). Set true
// only when the task genuinely depends on parent's recent
// exchanges; otherwise the cleaner isolated execution is faster
// and avoids prompt bloat.
@ToolParam(description = "Whether the child should see recent parent conversation messages as background context. Default false. Set true ONLY when the task requires conversational continuity (e.g. 'follow up on what we just discussed').", required = false)
Boolean inheritParentContext,
// RFC-063r §2.5 改动点 5: parent ChatOrigin (channel binding /
// workspace) propagates into the delegated child so a sub-agent
// creating a cron job still binds back to the originating channel.
// Hidden from the LLM by JsonSchemaGenerator.
@Nullable ToolContext ctx) {
if (agentName == null || agentName.isBlank()) {
return "[错误] 请指定目标 Agent 名称。" + availableAgentsHint();
}
if (task == null || task.isBlank()) {
return "[错误] 请提供任务描述。";
}
int depth = DelegationContext.currentDepth();
if (depth >= MAX_DELEGATION_DEPTH) {
return "[错误] 委派层级已达上限(" + MAX_DELEGATION_DEPTH + " 层),请直接处理任务。";
}
AgentEntity target = findAgent(agentName);
if (target == null) {
return "[错误] 未找到名为「" + agentName + "」的已启用 Agent。" + availableAgentsHint();
}
String parentConversationId = resolveParentConversationId();
// Spawn-pause: when the operator paused this conversation's tree
// (via /api/v1/subagents/spawn-pause), short-circuit before creating
// child state so no conversation rows / relays / registry entries leak.
if (parentConversationId != null && subagentRegistry.isSpawnPaused(parentConversationId)) {
return "[错误] Spawning paused for this conversation; resume via /api/v1/subagents/spawn-pause";
}
String childConversationId = createChildConv(target, parentConversationId);
// RFC-03 Lane C2: optionally prepend a parent-context prefix to the task.
// Bounded at INHERITED_CONTEXT_MAX_MESSAGES messages * INHERITED_CONTEXT_PER_MESSAGE_CHARS
// chars so a chatty parent doesn't blow past the child's context window.
String taskWithContext = task;
if (Boolean.TRUE.equals(inheritParentContext) && parentConversationId != null) {
String prefix = buildInheritedContextPrefix(parentConversationId);
if (!prefix.isEmpty()) {
taskWithContext = prefix + "\n\n---\n\nYour task:\n" + task;
log.info("Inheriting parent context: parentConv={}, prefixChars={}",
parentConversationId, prefix.length());
}
}
log.info("Agent delegation: depth={}, target={}({}), childConv={}, parentConv={}",
depth + 1, target.getName(), target.getId(), childConversationId, parentConversationId);
// Broadcast delegation_start + register event relay to parent session
boolean hasParent = parentConversationId != null && streamTracker.isRunning(parentConversationId);
if (hasParent) {
streamTracker.broadcastObject(parentConversationId, "delegation_start", Map.of(
"childConversationId", childConversationId,
"childAgentName", target.getName(),
"task", truncate(task, 200)));
}
Runnable stopRelay = hasParent ? registerBatchedRelay(childConversationId, parentConversationId, target.getName()) : null;
// Register the live sub-agent so the operator UI / heartbeat watchdog
// can observe it. Disposable is null in the synchronous single-task
// path because the executor blocks on AgentService#chat directly —
// there is no Flux subscription to dispose. Interrupts in this path
// are best-effort (status flip; no underlying cancel).
String subagentId = parentConversationId != null
? subagentRegistry.register(parentConversationId, childConversationId,
target.getId(), task, null)
: null;
// Execute child agent — RFC-063r §2.5 改动点 5: inherit the parent
// ChatOrigin and only swap the agentId, so channel binding /
// workspace / requester all flow into the child.
ChatOrigin parentOrigin = ChatOrigin.from(ctx);
ChildResult result;
try {
result = runSingleChild(0, target, taskWithContext, parentConversationId, childConversationId, parentOrigin);
} finally {
// Cleanup relay + registry regardless of how the child returned
// (success / exception / interruption) so we never leak entries.
if (stopRelay != null) stopRelay.run();
if (subagentId != null) {
subagentRegistry.get(subagentId).ifPresent(rec -> {
if ("running".equals(rec.status().get())) {
rec.status().set("completed");
}
});
subagentRegistry.unregister(subagentId);
}
}
if (hasParent) {
broadcastEnd(parentConversationId, childConversationId, target.getName(), result);
}
return result.toToolResponse(target.getName());
}
// ==================== Parallel delegation ====================
@vip.mate.tool.ConcurrencyUnsafe("internally fans out to its own thread pool; outer executor must not double-parallelize")
@Tool(description = """
Delegate multiple tasks to different Agents in parallel (max 3). \
Each task runs concurrently in an independent child session. \
Use this when you have multiple independent sub-tasks that can run simultaneously. \
Input is a JSON array: [{"agentName":"Agent名称","task":"任务描述"}, ...]""")
public String delegateParallel(
@ToolParam(description = "JSON array of tasks: [{\"agentName\":\"X\",\"task\":\"Y\"}, ...]")
String tasksJson,
// RFC-063r §2.5 改动点 5: hidden from LLM, used to inherit ChatOrigin into children.
@Nullable ToolContext ctx) {
// 1. Parse task list
List