diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java index 59c4ca8a..4c33ff59 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java @@ -30,6 +30,7 @@ import vip.mate.planning.service.PlanningService; import vip.mate.agent.context.ChatOrigin; import vip.mate.skill.runtime.SkillCatalogRenderer; import vip.mate.tool.builtin.DelegateAgentTool; +import vip.mate.tool.builtin.DelegateAgentTool.ChildResult; import vip.mate.tool.builtin.DelegationContext; import vip.mate.tool.builtin.ToolExecutionContext; @@ -678,7 +679,7 @@ public class StepExecutionNode implements NodeAction { // Seed the delegation context with the plan's REAL conversation id (from // graph state) so the delegated child conversation is parented to it and // stays hidden from the user's conversation list. The ChatOrigin in the - // plan-execute path carries no conversationId, so delegateByAgentId can't + // plan-execute path carries no conversationId, so the delegation can't // derive the parent on its own — we provide it here. boolean seeded = false; if (conversationId != null && !conversationId.isBlank() @@ -687,20 +688,30 @@ public class StepExecutionNode implements NodeAction { DelegationContext.enter(conversationId, Set.of(), conversationId, null, 0); seeded = true; } - String result; + ChildResult childResult = null; + String delegateError = null; try { - result = delegateAgentTool.delegateByAgentId(assignedAgentId, step, chatOrigin); + childResult = delegateAgentTool.delegateByAgentIdStructured(assignedAgentId, step, chatOrigin); } catch (Exception e) { log.error("[StepExecution] Delegated step {} threw: {}", stepIndex, e.getMessage(), e); - result = "[错误] 委派执行异常:" + e.getMessage(); + delegateError = e.getMessage(); } finally { if (seeded) { DelegationContext.exit(); } } - String finalResult = result != null ? result : ""; - boolean failed = finalResult.isEmpty() || finalResult.startsWith("[错误]"); + // Branch on the structured outcome instead of pattern-matching an error + // prefix out of the reply text: a successful child with non-empty content + // is the only "ok" case; blank / error / missing all count as failure. + boolean ok = childResult != null && childResult.success() && !childResult.isBlank(); + String finalResult = ok + ? (childResult.result() != null ? childResult.result() : "") + : "[错误] 委派执行失败:" + (delegateError != null ? delegateError + : childResult != null && childResult.error() != null ? childResult.error() + : childResult != null && childResult.isBlank() ? "子 Agent 返回内容为空" + : "未知错误"); + boolean failed = !ok; if (failed) { planningService.updateSubPlanFailure(planId, stepIndex, finalResult); } else { 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 476d461d..275cd1c3 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 @@ -13,6 +13,7 @@ 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.AgentService.ChatResult; import vip.mate.agent.context.ChatOrigin; import vip.mate.agent.delegation.SubagentRegistry; import vip.mate.agent.model.AgentEntity; @@ -242,6 +243,38 @@ public class DelegateAgentTool { return "[错误] 未找到名为「" + agentName + "」的已启用 Agent。" + availableAgentsHint(); } + // Execute via the shared single-task core (registry, relay, broadcast, + // child run). Returns the structured ChildResult plus the child's + // session handle so the parent can follow up on this exact sub-agent. + SingleDelegation sd = executeSingleDelegation(target, task, inheritParentContext, ctx); + ChildResult result = sd.result(); + + 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() && sd.childConversationId() != null) { + response += "\n\n[session_id: " + sd.childConversationId() + + " — to follow up with this sub-agent, call send_to_subagent(session_id, message)]"; + } + return response; + } + + /** Carrier for a single-task delegation: the structured child result plus + * the child conversation handle (null when spawning was short-circuited). */ + private record SingleDelegation(ChildResult result, String childConversationId) {} + + /** + * Shared execution core for single-task delegation, used by both the + * LLM-facing {@link #delegateToAgent} tool and the id-based + * {@link #delegateByAgentIdStructured} (per-step plan delegation). Handles + * spawn-pause, child conversation creation, optional parent-context + * inheritance, sub-agent registry, event relay/broadcast, and the child + * run — returning the structured {@link ChildResult} so callers decide how + * to format it (tool string vs. plan step bookkeeping). + */ + private SingleDelegation executeSingleDelegation(AgentEntity target, String task, + Boolean inheritParentContext, ToolContext ctx) { String parentConversationId = resolveParentConversationId(); // Root (human-facing) conversation at the top of the delegation tree. // At depth 0 the immediate parent IS the root; deeper layers carry it @@ -249,14 +282,15 @@ public class DelegateAgentTool { String rootConversationId = DelegationContext.rootConversationId(); if (rootConversationId == null) rootConversationId = parentConversationId; String parentSubagentId = DelegationContext.currentSubagentId(); - int childDepth = depth + 1; + int childDepth = DelegationContext.currentDepth() + 1; // Spawn-pause: short-circuit before creating child state when either the // immediate parent or the root tree is paused, so no conversation rows / // relays / registry entries leak. if ((parentConversationId != null && subagentRegistry.isSpawnPaused(parentConversationId)) || (rootConversationId != null && subagentRegistry.isSpawnPaused(rootConversationId))) { - return "[错误] Spawning paused for this conversation; resume via /api/v1/subagents/spawn-pause"; + return new SingleDelegation(ChildResult.ofError(0, target.getName(), + "Spawning paused for this conversation; resume via /api/v1/subagents/spawn-pause"), null); } String childConversationId = createChildConv(target, parentConversationId); @@ -275,12 +309,12 @@ public class DelegateAgentTool { } log.info("Agent delegation: depth={}, target={}({}), childConv={}, parentConv={}", - depth + 1, target.getName(), target.getId(), childConversationId, parentConversationId); + childDepth, target.getName(), target.getId(), childConversationId, parentConversationId); // Register the live sub-agent first so its stable id rides on every // event. 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 here are best-effort (status flip). + // the executor blocks on AgentService#chatWithUsage directly — there is + // no Flux subscription to dispose. Interrupts here are best-effort (status flip). String subagentId = parentConversationId != null ? subagentRegistry.register(parentConversationId, childConversationId, target.getId(), task, null, parentSubagentId, childDepth, rootConversationId) @@ -326,16 +360,7 @@ public class DelegateAgentTool { broadcastEnd(rootConversationId, childConversationId, target.getName(), result, subagentId, parentSubagentId, childDepth); } - - 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; + return new SingleDelegation(result, childConversationId); } /** @@ -347,12 +372,30 @@ public class DelegateAgentTool { * child's reply text, or an error string when the agent is missing/disabled. */ public String delegateByAgentId(Long agentId, String task, ChatOrigin parentOrigin) { + ChildResult result = delegateByAgentIdStructured(agentId, task, parentOrigin); + return result.toToolResponse(result.agentName()); + } + + /** + * Structured variant of {@link #delegateByAgentId}: runs the same isolated + * child execution but returns the full {@link ChildResult} instead of a + * formatted string. Used by per-step plan delegation so the plan graph can + * branch on {@code success()} / {@code isBlank()} / {@code outcome()} and + * read token usage, rather than pattern-matching an error prefix out of a + * string. Never throws — agent-resolution and depth-guard failures come + * back as {@code outcome="error"} results. + */ + public ChildResult delegateByAgentIdStructured(Long agentId, String task, ChatOrigin parentOrigin) { if (agentId == null) { - return "[错误] 未指定委派 Agent。"; + return ChildResult.ofError(0, "?", "未指定委派 Agent。"); } AgentEntity target = agentMapper.selectById(agentId); if (target == null || !Boolean.TRUE.equals(target.getEnabled())) { - return "[错误] 未找到 id=" + agentId + " 的已启用 Agent。"; + return ChildResult.ofError(0, "id=" + agentId, "未找到 id=" + agentId + " 的已启用 Agent。"); + } + if (DelegationContext.currentDepth() >= MAX_DELEGATION_DEPTH) { + return ChildResult.ofError(0, target.getName(), + "委派层级已达上限(" + MAX_DELEGATION_DEPTH + " 层)"); } ChatOrigin origin = parentOrigin != null ? parentOrigin : ChatOrigin.EMPTY; ToolContext ctx = origin.toToolContext(); @@ -372,7 +415,7 @@ public class DelegateAgentTool { DelegationContext.enter(parentConvId, Set.of(), parentConvId, null, 0); } try { - return delegateToAgent(target.getName(), task, false, ctx); + return executeSingleDelegation(target, task, false, ctx).result(); } finally { if (seedContext) { DelegationContext.exit(); @@ -543,6 +586,8 @@ public class DelegateAgentTool { payload.put("trimmedLength", r.trimmedLength); payload.put("blank", r.isBlank()); payload.put("durationMs", r.durationMs); + payload.put("promptTokens", r.promptTokens); + payload.put("completionTokens", r.completionTokens); payload.put("resultPreview", r.success ? truncate(r.result, 400) : (r.error != null ? r.error : "error")); @@ -637,6 +682,8 @@ public class DelegateAgentTool { m.put("trimmedLength", r.trimmedLength); m.put("blank", r.isBlank()); m.put("durationMs", r.durationMs); + m.put("promptTokens", r.promptTokens); + m.put("completionTokens", r.completionTokens); // childConversationId + subagentId for stable frontend tree lookup prepared.stream() .filter(p -> p.index == r.taskIndex) @@ -666,6 +713,8 @@ public class DelegateAgentTool { long timeoutCount = results.stream().filter(r -> "timeout".equals(r.outcome)).count(); long cancelledCount = results.stream().filter(r -> "cancelled".equals(r.outcome)).count(); long errorCount = results.stream().filter(r -> "error".equals(r.outcome)).count(); + long tokensInTotal = results.stream().mapToLong(r -> r.promptTokens).sum(); + long tokensOutTotal = results.stream().mapToLong(r -> r.completionTokens).sum(); StringBuilder sb = new StringBuilder(); @@ -680,6 +729,8 @@ public class DelegateAgentTool { .append(" cancelled=").append(cancelledCount) .append(" error=").append(errorCount) .append(" durationMs=").append(totalDurationMs) + .append(" tokensIn=").append(tokensInTotal) + .append(" tokensOut=").append(tokensOutTotal) .append("\n\n"); // Important: this result is from the current execution. Any timeout entries in the @@ -700,6 +751,8 @@ public class DelegateAgentTool { .append(" | contentLength=").append(r.trimmedLength).append("chars") .append(" | rawLength=").append(r.rawLength).append("chars") .append(" | duration=").append(r.durationMs / 1000).append("s") + .append(" | tokensIn=").append(r.promptTokens) + .append(" | tokensOut=").append(r.completionTokens) .append("\n\n"); switch (r.outcome) { @@ -1048,11 +1101,17 @@ public class DelegateAgentTool { ChatOrigin childOrigin = (parentOrigin != null ? parentOrigin : ChatOrigin.EMPTY) .withAgent(target.getId()) .withConversationId(childConversationId); - String rawResult = agentService.chat(target.getId(), task, childConversationId, childOrigin); + // chatWithUsage runs the same StateGraph as chat() (so the child + // message is persisted identically) but also surfaces the child's + // token usage from the graph's _usage_final event, so the parent can + // see what each sub-agent cost. + ChatResult chatResult = agentService.chatWithUsage( + target.getId(), task, childConversationId, childOrigin); long durationMs = System.currentTimeMillis() - startTime; + String rawResult = chatResult.content(); // Measure lengths before truncation so ChildResult carries accurate metadata. return ChildResult.ofSuccess(taskIndex, target.getName(), rawResult, durationMs, - MAX_RESULT_LENGTH); + MAX_RESULT_LENGTH, chatResult.promptTokens(), chatResult.completionTokens()); } catch (Exception e) { log.error("Child agent failed: taskIndex={}, agent={}, error={}", taskIndex, target.getName(), e.getMessage()); @@ -1079,21 +1138,25 @@ public class DelegateAgentTool { *
{@code rawLength} and {@code trimmedLength} are measured before truncation and reflect the
* true content length.
*/
- private record ChildResult(
+ public record ChildResult(
int taskIndex, String agentName, boolean success,
String result, String error, long durationMs,
- /** "success" | "blank_success" | "timeout" | "error" */
+ /** "success" | "blank_success" | "timeout" | "cancelled" | "error" */
String outcome,
- int rawLength, int trimmedLength) {
+ int rawLength, int trimmedLength,
+ /** Child token usage captured from the graph's _usage_final event;
+ * 0 for non-success outcomes (timeout / error / cancelled). */
+ int promptTokens, int completionTokens) {
/** Whether the child returned no usable content (blank_success). */
- boolean isBlank() { return "blank_success".equals(outcome); }
+ public boolean isBlank() { return "blank_success".equals(outcome); }
/**
* Factory for a successful child execution.
* Measures lengths from the raw result before applying the truncation limit.
*/
- static ChildResult ofSuccess(int idx, String name, String rawResult, long ms, int maxLen) {
+ static ChildResult ofSuccess(int idx, String name, String rawResult, long ms, int maxLen,
+ int promptTokens, int completionTokens) {
String safe = rawResult != null ? rawResult : "";
String trimmed = safe.trim();
boolean blank = trimmed.isEmpty();
@@ -1102,7 +1165,8 @@ public class DelegateAgentTool {
truncate(safe, maxLen),
null, ms,
blank ? "blank_success" : "success",
- safe.length(), trimmed.length());
+ safe.length(), trimmed.length(),
+ Math.max(0, promptTokens), Math.max(0, completionTokens));
}
/**
@@ -1113,14 +1177,14 @@ public class DelegateAgentTool {
String msg = err != null ? err : "Unknown error";
boolean isTimeout = msg.contains("超时") || msg.toLowerCase().contains("timeout");
return new ChildResult(idx, name, false, null, msg, 0,
- isTimeout ? "timeout" : "error", 0, 0);
+ isTimeout ? "timeout" : "error", 0, 0, 0, 0);
}
/** Factory for an explicit timeout (parallel window exceeded). */
static ChildResult ofTimeout(int idx, String name, int timeoutSec) {
String msg = "超时 (" + timeoutSec + "s)";
return new ChildResult(idx, name, false, null, msg, (long) timeoutSec * 1000L,
- "timeout", 0, 0);
+ "timeout", 0, 0, 0, 0);
}
/**
@@ -1130,7 +1194,7 @@ public class DelegateAgentTool {
*/
static ChildResult ofCancelled(int idx, String name) {
return new ChildResult(idx, name, false, null,
- "已取消(必需子任务失败,触发提前收束)", 0, "cancelled", 0, 0);
+ "已取消(必需子任务失败,触发提前收束)", 0, "cancelled", 0, 0, 0, 0);
}
// Legacy shims — kept for callers that pre-date the factory methods
@@ -1140,15 +1204,19 @@ public class DelegateAgentTool {
String trimmed = safe.trim();
boolean blank = trimmed.isEmpty();
return new ChildResult(idx, name, true, safe, null, ms,
- blank ? "blank_success" : "success", safe.length(), trimmed.length());
+ blank ? "blank_success" : "success", safe.length(), trimmed.length(), 0, 0);
}
static ChildResult error(int idx, String name, String err) {
return ofError(idx, name, err);
}
String toToolResponse(String agentName) {
- if (success) return "[Agent「" + agentName + "」的回复]\n\n" + (result != null ? result : "");
- return "[错误] Agent「" + agentName + "」执行失败: " + error;
+ if (!success) return "[错误] Agent「" + agentName + "」执行失败: " + error;
+ String body = "[Agent「" + agentName + "」的回复]\n\n" + (result != null ? result : "");
+ if (promptTokens > 0 || completionTokens > 0) {
+ body += "\n\n[usage: tokensIn=" + promptTokens + " tokensOut=" + completionTokens + "]";
+ }
+ return body;
}
private static String truncate(String text, int maxLength) {
@@ -1372,6 +1440,8 @@ public class DelegateAgentTool {
Map