feat(delegation): 子 Agent 成本透出 + 单任务/计划步骤委派结构化

- 子执行改走 chatWithUsage,捕获并透出每个子 Agent 的 prompt/completion token
  (单任务回复、并行机读头+逐行、delegation_end/child_complete/单路 broadcastEnd 事件)
- 新增 delegateByAgentIdStructured 返回结构化 ChildResult;计划步骤委派改按
  success()/isBlank() 判定成败,替掉脆弱的错误前缀匹配
- 前端委派段与嵌套节点显示紧凑成本后缀/徽标
- 测试:子执行 stub 迁移到 chatWithUsage + 新增 token 回归用例
This commit is contained in:
matevip 2026-06-30 11:06:19 +08:00
parent db11433883
commit d390935763
7 changed files with 239 additions and 68 deletions

View File

@ -30,6 +30,7 @@ import vip.mate.planning.service.PlanningService;
import vip.mate.agent.context.ChatOrigin; import vip.mate.agent.context.ChatOrigin;
import vip.mate.skill.runtime.SkillCatalogRenderer; import vip.mate.skill.runtime.SkillCatalogRenderer;
import vip.mate.tool.builtin.DelegateAgentTool; import vip.mate.tool.builtin.DelegateAgentTool;
import vip.mate.tool.builtin.DelegateAgentTool.ChildResult;
import vip.mate.tool.builtin.DelegationContext; import vip.mate.tool.builtin.DelegationContext;
import vip.mate.tool.builtin.ToolExecutionContext; 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 // Seed the delegation context with the plan's REAL conversation id (from
// graph state) so the delegated child conversation is parented to it and // graph state) so the delegated child conversation is parented to it and
// stays hidden from the user's conversation list. The ChatOrigin in the // 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. // derive the parent on its own we provide it here.
boolean seeded = false; boolean seeded = false;
if (conversationId != null && !conversationId.isBlank() if (conversationId != null && !conversationId.isBlank()
@ -687,20 +688,30 @@ public class StepExecutionNode implements NodeAction {
DelegationContext.enter(conversationId, Set.of(), conversationId, null, 0); DelegationContext.enter(conversationId, Set.of(), conversationId, null, 0);
seeded = true; seeded = true;
} }
String result; ChildResult childResult = null;
String delegateError = null;
try { try {
result = delegateAgentTool.delegateByAgentId(assignedAgentId, step, chatOrigin); childResult = delegateAgentTool.delegateByAgentIdStructured(assignedAgentId, step, chatOrigin);
} catch (Exception e) { } catch (Exception e) {
log.error("[StepExecution] Delegated step {} threw: {}", stepIndex, e.getMessage(), e); log.error("[StepExecution] Delegated step {} threw: {}", stepIndex, e.getMessage(), e);
result = "[错误] 委派执行异常:" + e.getMessage(); delegateError = e.getMessage();
} finally { } finally {
if (seeded) { if (seeded) {
DelegationContext.exit(); DelegationContext.exit();
} }
} }
String finalResult = result != null ? result : ""; // Branch on the structured outcome instead of pattern-matching an error
boolean failed = finalResult.isEmpty() || finalResult.startsWith("[错误]"); // 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) { if (failed) {
planningService.updateSubPlanFailure(planId, stepIndex, finalResult); planningService.updateSubPlanFailure(planId, stepIndex, finalResult);
} else { } else {

View File

@ -13,6 +13,7 @@ import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import vip.mate.agent.AgentService; import vip.mate.agent.AgentService;
import vip.mate.agent.AgentService.ChatResult;
import vip.mate.agent.context.ChatOrigin; import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.delegation.SubagentRegistry; import vip.mate.agent.delegation.SubagentRegistry;
import vip.mate.agent.model.AgentEntity; import vip.mate.agent.model.AgentEntity;
@ -242,6 +243,38 @@ public class DelegateAgentTool {
return "[错误] 未找到名为「" + agentName + "」的已启用 Agent。" + availableAgentsHint(); 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(); String parentConversationId = resolveParentConversationId();
// Root (human-facing) conversation at the top of the delegation tree. // Root (human-facing) conversation at the top of the delegation tree.
// At depth 0 the immediate parent IS the root; deeper layers carry it // At depth 0 the immediate parent IS the root; deeper layers carry it
@ -249,14 +282,15 @@ public class DelegateAgentTool {
String rootConversationId = DelegationContext.rootConversationId(); String rootConversationId = DelegationContext.rootConversationId();
if (rootConversationId == null) rootConversationId = parentConversationId; if (rootConversationId == null) rootConversationId = parentConversationId;
String parentSubagentId = DelegationContext.currentSubagentId(); String parentSubagentId = DelegationContext.currentSubagentId();
int childDepth = depth + 1; int childDepth = DelegationContext.currentDepth() + 1;
// Spawn-pause: short-circuit before creating child state when either the // Spawn-pause: short-circuit before creating child state when either the
// immediate parent or the root tree is paused, so no conversation rows / // immediate parent or the root tree is paused, so no conversation rows /
// relays / registry entries leak. // relays / registry entries leak.
if ((parentConversationId != null && subagentRegistry.isSpawnPaused(parentConversationId)) if ((parentConversationId != null && subagentRegistry.isSpawnPaused(parentConversationId))
|| (rootConversationId != null && subagentRegistry.isSpawnPaused(rootConversationId))) { || (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); String childConversationId = createChildConv(target, parentConversationId);
@ -275,12 +309,12 @@ public class DelegateAgentTool {
} }
log.info("Agent delegation: depth={}, target={}({}), childConv={}, parentConv={}", 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 // Register the live sub-agent first so its stable id rides on every
// event. Disposable is null in the synchronous single-task path because // event. Disposable is null in the synchronous single-task path because
// the executor blocks on AgentService#chat directly there is no Flux // the executor blocks on AgentService#chatWithUsage directly there is
// subscription to dispose. Interrupts here are best-effort (status flip). // no Flux subscription to dispose. Interrupts here are best-effort (status flip).
String subagentId = parentConversationId != null String subagentId = parentConversationId != null
? subagentRegistry.register(parentConversationId, childConversationId, ? subagentRegistry.register(parentConversationId, childConversationId,
target.getId(), task, null, parentSubagentId, childDepth, rootConversationId) target.getId(), task, null, parentSubagentId, childDepth, rootConversationId)
@ -326,16 +360,7 @@ public class DelegateAgentTool {
broadcastEnd(rootConversationId, childConversationId, target.getName(), result, broadcastEnd(rootConversationId, childConversationId, target.getName(), result,
subagentId, parentSubagentId, childDepth); subagentId, parentSubagentId, childDepth);
} }
return new SingleDelegation(result, childConversationId);
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;
} }
/** /**
@ -347,12 +372,30 @@ public class DelegateAgentTool {
* child's reply text, or an error string when the agent is missing/disabled. * child's reply text, or an error string when the agent is missing/disabled.
*/ */
public String delegateByAgentId(Long agentId, String task, ChatOrigin parentOrigin) { 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) { if (agentId == null) {
return "[错误] 未指定委派 Agent。"; return ChildResult.ofError(0, "?", "未指定委派 Agent。");
} }
AgentEntity target = agentMapper.selectById(agentId); AgentEntity target = agentMapper.selectById(agentId);
if (target == null || !Boolean.TRUE.equals(target.getEnabled())) { 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; ChatOrigin origin = parentOrigin != null ? parentOrigin : ChatOrigin.EMPTY;
ToolContext ctx = origin.toToolContext(); ToolContext ctx = origin.toToolContext();
@ -372,7 +415,7 @@ public class DelegateAgentTool {
DelegationContext.enter(parentConvId, Set.of(), parentConvId, null, 0); DelegationContext.enter(parentConvId, Set.of(), parentConvId, null, 0);
} }
try { try {
return delegateToAgent(target.getName(), task, false, ctx); return executeSingleDelegation(target, task, false, ctx).result();
} finally { } finally {
if (seedContext) { if (seedContext) {
DelegationContext.exit(); DelegationContext.exit();
@ -543,6 +586,8 @@ public class DelegateAgentTool {
payload.put("trimmedLength", r.trimmedLength); payload.put("trimmedLength", r.trimmedLength);
payload.put("blank", r.isBlank()); payload.put("blank", r.isBlank());
payload.put("durationMs", r.durationMs); payload.put("durationMs", r.durationMs);
payload.put("promptTokens", r.promptTokens);
payload.put("completionTokens", r.completionTokens);
payload.put("resultPreview", r.success payload.put("resultPreview", r.success
? truncate(r.result, 400) ? truncate(r.result, 400)
: (r.error != null ? r.error : "error")); : (r.error != null ? r.error : "error"));
@ -637,6 +682,8 @@ public class DelegateAgentTool {
m.put("trimmedLength", r.trimmedLength); m.put("trimmedLength", r.trimmedLength);
m.put("blank", r.isBlank()); m.put("blank", r.isBlank());
m.put("durationMs", r.durationMs); m.put("durationMs", r.durationMs);
m.put("promptTokens", r.promptTokens);
m.put("completionTokens", r.completionTokens);
// childConversationId + subagentId for stable frontend tree lookup // childConversationId + subagentId for stable frontend tree lookup
prepared.stream() prepared.stream()
.filter(p -> p.index == r.taskIndex) .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 timeoutCount = results.stream().filter(r -> "timeout".equals(r.outcome)).count();
long cancelledCount = results.stream().filter(r -> "cancelled".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 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(); StringBuilder sb = new StringBuilder();
@ -680,6 +729,8 @@ public class DelegateAgentTool {
.append(" cancelled=").append(cancelledCount) .append(" cancelled=").append(cancelledCount)
.append(" error=").append(errorCount) .append(" error=").append(errorCount)
.append(" durationMs=").append(totalDurationMs) .append(" durationMs=").append(totalDurationMs)
.append(" tokensIn=").append(tokensInTotal)
.append(" tokensOut=").append(tokensOutTotal)
.append("\n\n"); .append("\n\n");
// Important: this result is from the current execution. Any timeout entries in the // 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(" | contentLength=").append(r.trimmedLength).append("chars")
.append(" | rawLength=").append(r.rawLength).append("chars") .append(" | rawLength=").append(r.rawLength).append("chars")
.append(" | duration=").append(r.durationMs / 1000).append("s") .append(" | duration=").append(r.durationMs / 1000).append("s")
.append(" | tokensIn=").append(r.promptTokens)
.append(" | tokensOut=").append(r.completionTokens)
.append("\n\n"); .append("\n\n");
switch (r.outcome) { switch (r.outcome) {
@ -1048,11 +1101,17 @@ public class DelegateAgentTool {
ChatOrigin childOrigin = (parentOrigin != null ? parentOrigin : ChatOrigin.EMPTY) ChatOrigin childOrigin = (parentOrigin != null ? parentOrigin : ChatOrigin.EMPTY)
.withAgent(target.getId()) .withAgent(target.getId())
.withConversationId(childConversationId); .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; long durationMs = System.currentTimeMillis() - startTime;
String rawResult = chatResult.content();
// Measure lengths before truncation so ChildResult carries accurate metadata. // Measure lengths before truncation so ChildResult carries accurate metadata.
return ChildResult.ofSuccess(taskIndex, target.getName(), rawResult, durationMs, return ChildResult.ofSuccess(taskIndex, target.getName(), rawResult, durationMs,
MAX_RESULT_LENGTH); MAX_RESULT_LENGTH, chatResult.promptTokens(), chatResult.completionTokens());
} catch (Exception e) { } catch (Exception e) {
log.error("Child agent failed: taskIndex={}, agent={}, error={}", log.error("Child agent failed: taskIndex={}, agent={}, error={}",
taskIndex, target.getName(), e.getMessage()); taskIndex, target.getName(), e.getMessage());
@ -1079,21 +1138,25 @@ public class DelegateAgentTool {
* <p>{@code rawLength} and {@code trimmedLength} are measured before truncation and reflect the * <p>{@code rawLength} and {@code trimmedLength} are measured before truncation and reflect the
* true content length. * true content length.
*/ */
private record ChildResult( public record ChildResult(
int taskIndex, String agentName, boolean success, int taskIndex, String agentName, boolean success,
String result, String error, long durationMs, String result, String error, long durationMs,
/** "success" | "blank_success" | "timeout" | "error" */ /** "success" | "blank_success" | "timeout" | "cancelled" | "error" */
String outcome, 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). */ /** 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. * Factory for a successful child execution.
* Measures lengths from the raw result before applying the truncation limit. * 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 safe = rawResult != null ? rawResult : "";
String trimmed = safe.trim(); String trimmed = safe.trim();
boolean blank = trimmed.isEmpty(); boolean blank = trimmed.isEmpty();
@ -1102,7 +1165,8 @@ public class DelegateAgentTool {
truncate(safe, maxLen), truncate(safe, maxLen),
null, ms, null, ms,
blank ? "blank_success" : "success", 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"; String msg = err != null ? err : "Unknown error";
boolean isTimeout = msg.contains("超时") || msg.toLowerCase().contains("timeout"); boolean isTimeout = msg.contains("超时") || msg.toLowerCase().contains("timeout");
return new ChildResult(idx, name, false, null, msg, 0, 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). */ /** Factory for an explicit timeout (parallel window exceeded). */
static ChildResult ofTimeout(int idx, String name, int timeoutSec) { static ChildResult ofTimeout(int idx, String name, int timeoutSec) {
String msg = "超时 (" + timeoutSec + "s)"; String msg = "超时 (" + timeoutSec + "s)";
return new ChildResult(idx, name, false, null, msg, (long) timeoutSec * 1000L, 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) { static ChildResult ofCancelled(int idx, String name) {
return new ChildResult(idx, name, false, null, 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 // Legacy shims kept for callers that pre-date the factory methods
@ -1140,15 +1204,19 @@ public class DelegateAgentTool {
String trimmed = safe.trim(); String trimmed = safe.trim();
boolean blank = trimmed.isEmpty(); boolean blank = trimmed.isEmpty();
return new ChildResult(idx, name, true, safe, null, ms, 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) { static ChildResult error(int idx, String name, String err) {
return ofError(idx, name, err); return ofError(idx, name, err);
} }
String toToolResponse(String agentName) { String toToolResponse(String agentName) {
if (success) return "[Agent「" + agentName + "」的回复]\n\n" + (result != null ? result : ""); if (!success) return "[错误] Agent「" + agentName + "」执行失败: " + error;
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) { private static String truncate(String text, int maxLength) {
@ -1372,6 +1440,8 @@ public class DelegateAgentTool {
Map<String, Object> ev = delegationPayload(subagentId, parentSubagentId, depth, childConvId, agentName); Map<String, Object> ev = delegationPayload(subagentId, parentSubagentId, depth, childConvId, agentName);
ev.put("success", result.success); ev.put("success", result.success);
ev.put("durationMs", result.durationMs); ev.put("durationMs", result.durationMs);
ev.put("promptTokens", result.promptTokens);
ev.put("completionTokens", result.completionTokens);
ev.put("resultPreview", ev.put("resultPreview",
result.success ? truncate(result.result, 200) : (result.error != null ? result.error : "")); result.success ? truncate(result.result, 200) : (result.error != null ? result.error : ""));
streamTracker.broadcastObject(rootConvId, "delegation_end", ev); streamTracker.broadcastObject(rootConvId, "delegation_end", ev);

View File

@ -15,6 +15,7 @@ import org.mockito.Mock;
import org.mockito.Spy; import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import vip.mate.agent.AgentService; import vip.mate.agent.AgentService;
import vip.mate.agent.AgentService.ChatResult;
import vip.mate.agent.delegation.SubagentRegistry; import vip.mate.agent.delegation.SubagentRegistry;
import vip.mate.agent.model.AgentEntity; import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper; import vip.mate.agent.repository.AgentMapper;
@ -180,9 +181,9 @@ class DelegateAgentToolTest {
// the test thread returns immediately. The orphan keeps sleeping on a // the test thread returns immediately. The orphan keeps sleeping on a
// virtual thread until JVM teardown that's the same behavior as // virtual thread until JVM teardown that's the same behavior as
// production (cancel is best-effort). // production (cancel is best-effort).
when(agentService.chat(anyLong(), anyString(), anyString(), any())).thenAnswer(invocation -> { when(agentService.chatWithUsage(anyLong(), anyString(), anyString(), any())).thenAnswer(invocation -> {
Thread.sleep(10_000); Thread.sleep(10_000);
return "should not reach here"; return ChatResult.contentOnly("should not reach here");
}); });
// Set a conversationId so resolveParentConversationId works // Set a conversationId so resolveParentConversationId works
@ -239,13 +240,13 @@ class DelegateAgentToolTest {
when(streamTracker.isRunning(any())).thenReturn(false); when(streamTracker.isRunning(any())).thenReturn(false);
// FastAgent completes immediately // FastAgent completes immediately
when(agentService.chat(eq(10L), anyString(), anyString(), any())) when(agentService.chatWithUsage(eq(10L), anyString(), anyString(), any()))
.thenReturn("Fast result completed successfully"); .thenReturn(ChatResult.contentOnly("Fast result completed successfully"));
// SlowAgent blocks longer than the (test-overridden) 3 s budget. // SlowAgent blocks longer than the (test-overridden) 3 s budget.
when(agentService.chat(eq(11L), anyString(), anyString(), any())).thenAnswer(invocation -> { when(agentService.chatWithUsage(eq(11L), anyString(), anyString(), any())).thenAnswer(invocation -> {
Thread.sleep(10_000); Thread.sleep(10_000);
return "should not reach here"; return ChatResult.contentOnly("should not reach here");
}); });
ToolExecutionContext.set("parent-mixed", "admin"); ToolExecutionContext.set("parent-mixed", "admin");
@ -286,12 +287,12 @@ class DelegateAgentToolTest {
.thenReturn(slowAgent); .thenReturn(slowAgent);
when(streamTracker.isRunning(any())).thenReturn(false); when(streamTracker.isRunning(any())).thenReturn(false);
// Required FailAgent errors immediately arms fail-fast. // Required FailAgent errors immediately arms fail-fast.
when(agentService.chat(eq(20L), anyString(), anyString(), any())) when(agentService.chatWithUsage(eq(20L), anyString(), anyString(), any()))
.thenThrow(new RuntimeException("boom")); .thenThrow(new RuntimeException("boom"));
// SlowAgent would block well past the 3 s test budget; fail-fast cancels it. // SlowAgent would block well past the 3 s test budget; fail-fast cancels it.
when(agentService.chat(eq(21L), anyString(), anyString(), any())).thenAnswer(inv -> { when(agentService.chatWithUsage(eq(21L), anyString(), anyString(), any())).thenAnswer(inv -> {
Thread.sleep(10_000); Thread.sleep(10_000);
return "unreachable"; return ChatResult.contentOnly("unreachable");
}); });
ToolExecutionContext.set("parent-ff", "admin"); ToolExecutionContext.set("parent-ff", "admin");
@ -325,10 +326,10 @@ class DelegateAgentToolTest {
.thenReturn(optAgent) .thenReturn(optAgent)
.thenReturn(okAgent); .thenReturn(okAgent);
when(streamTracker.isRunning(any())).thenReturn(false); when(streamTracker.isRunning(any())).thenReturn(false);
when(agentService.chat(eq(30L), anyString(), anyString(), any())) when(agentService.chatWithUsage(eq(30L), anyString(), anyString(), any()))
.thenThrow(new RuntimeException("opt boom")); .thenThrow(new RuntimeException("opt boom"));
when(agentService.chat(eq(31L), anyString(), anyString(), any())) when(agentService.chatWithUsage(eq(31L), anyString(), anyString(), any()))
.thenReturn("ok result done"); .thenReturn(ChatResult.contentOnly("ok result done"));
ToolExecutionContext.set("parent-opt", "admin"); ToolExecutionContext.set("parent-opt", "admin");
String json = "[{\"agentName\":\"OptAgent\",\"task\":\"a\",\"optional\":true}," String json = "[{\"agentName\":\"OptAgent\",\"task\":\"a\",\"optional\":true},"
@ -354,9 +355,9 @@ class DelegateAgentToolTest {
when(agentMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(longAgent); when(agentMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(longAgent);
when(streamTracker.isRunning(any())).thenReturn(false); when(streamTracker.isRunning(any())).thenReturn(false);
// Sleeps 4 s beyond the 3 s test budget, but within the 6 s override. // Sleeps 4 s beyond the 3 s test budget, but within the 6 s override.
when(agentService.chat(eq(40L), anyString(), anyString(), any())).thenAnswer(inv -> { when(agentService.chatWithUsage(eq(40L), anyString(), anyString(), any())).thenAnswer(inv -> {
Thread.sleep(4_000); Thread.sleep(4_000);
return "long task finished ok"; return ChatResult.contentOnly("long task finished ok");
}); });
ToolExecutionContext.set("parent-to", "admin"); ToolExecutionContext.set("parent-to", "admin");
@ -368,4 +369,60 @@ class DelegateAgentToolTest {
"long task should complete within the widened budget: " + result); "long task should complete within the widened budget: " + result);
assertFalse(result.contains("timeout=1"), "should not time out with the override: " + result); assertFalse(result.contains("timeout=1"), "should not time out with the override: " + result);
} }
// ===== token usage surfacing =====
@Test
@DisplayName("delegateToAgent surfaces the child's token usage in the reply")
void delegateToAgentSurfacesTokenUsage() {
AgentEntity agent = new AgentEntity();
agent.setId(50L);
agent.setName("Worker");
agent.setEnabled(true);
agent.setWorkspaceId(1L);
when(agentMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(agent);
when(streamTracker.isRunning(any())).thenReturn(false);
when(agentService.chatWithUsage(eq(50L), anyString(), anyString(), any()))
.thenReturn(new ChatResult("done with work", 120, 45, null, null));
ToolExecutionContext.set("parent-usage", "admin");
String result = delegateAgentTool.delegateToAgent("Worker", "do the thing", null, null);
assertTrue(result.contains("tokensIn=120"), "reply should surface prompt tokens: " + result);
assertTrue(result.contains("tokensOut=45"), "reply should surface completion tokens: " + result);
}
@Test
@DisplayName("delegateParallel aggregates child token usage in the header and per-row lines")
void delegateParallelAggregatesTokenUsage() {
AgentEntity a = new AgentEntity();
a.setId(60L);
a.setName("AgentA");
a.setEnabled(true);
a.setWorkspaceId(1L);
AgentEntity b = new AgentEntity();
b.setId(61L);
b.setName("AgentB");
b.setEnabled(true);
b.setWorkspaceId(1L);
when(agentMapper.selectOne(any(LambdaQueryWrapper.class)))
.thenReturn(a)
.thenReturn(b);
when(streamTracker.isRunning(any())).thenReturn(false);
when(agentService.chatWithUsage(eq(60L), anyString(), anyString(), any()))
.thenReturn(new ChatResult("result A", 100, 30, null, null));
when(agentService.chatWithUsage(eq(61L), anyString(), anyString(), any()))
.thenReturn(new ChatResult("result B", 80, 20, null, null));
ToolExecutionContext.set("parent-usage-parallel", "admin");
String json = "[{\"agentName\":\"AgentA\",\"task\":\"a\"},{\"agentName\":\"AgentB\",\"task\":\"b\"}]";
String result = delegateAgentTool.delegateParallel(json, null);
// Machine header carries the batch totals (180 in, 50 out).
assertTrue(result.contains("tokensIn=180"), "header should aggregate prompt tokens: " + result);
assertTrue(result.contains("tokensOut=50"), "header should aggregate completion tokens: " + result);
// Per-row lines carry each child's own usage.
assertTrue(result.contains("tokensIn=100"), "row A should carry its prompt tokens: " + result);
assertTrue(result.contains("tokensIn=80"), "row B should carry its prompt tokens: " + result);
}
} }

View File

@ -16,6 +16,7 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.Spy; import org.mockito.Spy;
import vip.mate.agent.AgentService; import vip.mate.agent.AgentService;
import vip.mate.agent.AgentService.ChatResult;
import vip.mate.agent.delegation.SubagentRegistry; import vip.mate.agent.delegation.SubagentRegistry;
import vip.mate.agent.model.AgentEntity; import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper; import vip.mate.agent.repository.AgentMapper;
@ -108,14 +109,14 @@ class DelegateEventSequenceTest {
}); });
// During chat(), simulate the child broadcasting a tool_call_started event // During chat(), simulate the child broadcasting a tool_call_started event
when(agentService.chat(eq(100L), eq("summarize the report"), anyString(), any())) when(agentService.chatWithUsage(eq(100L), eq("summarize the report"), anyString(), any()))
.thenAnswer(invocation -> { .thenAnswer(invocation -> {
// The relay listener should have been registered by now fire it // The relay listener should have been registered by now fire it
BiConsumer<String, String> relay = relayRef.get(); BiConsumer<String, String> relay = relayRef.get();
assertNotNull(relay, "Relay should be registered before child chat starts"); assertNotNull(relay, "Relay should be registered before child chat starts");
relay.accept("tool_call_started", "{\"name\":\"searchWeb\"}"); relay.accept("tool_call_started", "{\"name\":\"searchWeb\"}");
relay.accept("tool_call_completed", "{\"name\":\"searchWeb\",\"success\":true}"); relay.accept("tool_call_completed", "{\"name\":\"searchWeb\",\"success\":true}");
return "The report shows growth of 15% YoY."; return ChatResult.contentOnly("The report shows growth of 15% YoY.");
}); });
// Act // Act
@ -170,8 +171,10 @@ class DelegateEventSequenceTest {
when(streamTracker.addBatchedEventRelay(anyString(), anyString(), anyInt(), anyLong(), any())) when(streamTracker.addBatchedEventRelay(anyString(), anyString(), anyInt(), anyLong(), any()))
.thenReturn(() -> {}); .thenReturn(() -> {});
when(agentService.chat(eq(101L), anyString(), anyString(), any())).thenReturn("Result A"); when(agentService.chatWithUsage(eq(101L), anyString(), anyString(), any()))
when(agentService.chat(eq(102L), anyString(), anyString(), any())).thenReturn("Result B"); .thenReturn(ChatResult.contentOnly("Result A"));
when(agentService.chatWithUsage(eq(102L), anyString(), anyString(), any()))
.thenReturn(ChatResult.contentOnly("Result B"));
String json = "[{\"agentName\":\"AgentA\",\"task\":\"task A\"},{\"agentName\":\"AgentB\",\"task\":\"task B\"}]"; String json = "[{\"agentName\":\"AgentA\",\"task\":\"task A\"},{\"agentName\":\"AgentB\",\"task\":\"task B\"}]";
@ -203,7 +206,8 @@ class DelegateEventSequenceTest {
ToolExecutionContext.set("inactive-parent", "admin"); ToolExecutionContext.set("inactive-parent", "admin");
when(streamTracker.isRunning("inactive-parent")).thenReturn(false); when(streamTracker.isRunning("inactive-parent")).thenReturn(false);
when(agentService.chat(eq(200L), anyString(), anyString(), any())).thenReturn("done"); when(agentService.chatWithUsage(eq(200L), anyString(), anyString(), any()))
.thenReturn(ChatResult.contentOnly("done"));
// Act // Act
delegateAgentTool.delegateToAgent("QuietAgent", "quiet task", null, null); delegateAgentTool.delegateToAgent("QuietAgent", "quiet task", null, null);
@ -235,7 +239,7 @@ class DelegateEventSequenceTest {
return (Runnable) () -> {}; return (Runnable) () -> {};
}); });
when(agentService.chat(eq(300L), anyString(), anyString(), any())) when(agentService.chatWithUsage(eq(300L), anyString(), anyString(), any()))
.thenAnswer(invocation -> { .thenAnswer(invocation -> {
BiConsumer<String, String> relay = relayRef.get(); BiConsumer<String, String> relay = relayRef.get();
// These should produce delegation_progress: // These should produce delegation_progress:
@ -244,7 +248,7 @@ class DelegateEventSequenceTest {
// These should be ignored by the relay filter: // These should be ignored by the relay filter:
relay.accept("heartbeat", "{}"); relay.accept("heartbeat", "{}");
relay.accept("token", "{\"text\":\"hello\"}"); relay.accept("token", "{\"text\":\"hello\"}");
return "filtered result"; return ChatResult.contentOnly("filtered result");
}); });
delegateAgentTool.delegateToAgent("FilterAgent", "filter task", null, null); delegateAgentTool.delegateToAgent("FilterAgent", "filter task", null, null);
@ -294,18 +298,19 @@ class DelegateEventSequenceTest {
// ToolExecutionContext to the Child's own conversation. Reproduce that so // ToolExecutionContext to the Child's own conversation. Reproduce that so
// the grandchild's immediate parent resolves to childConv, while its // the grandchild's immediate parent resolves to childConv, while its
// events must still target rootConv (carried via DelegationContext). // events must still target rootConv (carried via DelegationContext).
when(agentService.chat(eq(100L), anyString(), anyString(), any())) when(agentService.chatWithUsage(eq(100L), anyString(), anyString(), any()))
.thenAnswer(inv -> { .thenAnswer(inv -> {
String childConv = inv.getArgument(2); String childConv = inv.getArgument(2);
ToolExecutionContext.set(childConv, "admin"); ToolExecutionContext.set(childConv, "admin");
try { try {
return delegateAgentTool.delegateToAgent("Grandchild", "gtask", null, null); return ChatResult.contentOnly(
delegateAgentTool.delegateToAgent("Grandchild", "gtask", null, null));
} finally { } finally {
ToolExecutionContext.set(rootConv, "admin"); ToolExecutionContext.set(rootConv, "admin");
} }
}); });
when(agentService.chat(eq(200L), anyString(), anyString(), any())) when(agentService.chatWithUsage(eq(200L), anyString(), anyString(), any()))
.thenReturn("grandchild done"); .thenReturn(ChatResult.contentOnly("grandchild done"));
delegateAgentTool.delegateToAgent("Child", "ctask", null, null); delegateAgentTool.delegateToAgent("Child", "ctask", null, null);

View File

@ -42,6 +42,13 @@ const progress = computed(() => {
return n ? `${n} ${n === 1 ? 'tool' : 'tools'}` : '' return n ? `${n} ${n === 1 ? 'tool' : 'tools'}` : ''
}) })
// Compact token cost, shown once the subagent finishes (e.g. "3.2k tok").
const tokenLabel = computed(() => {
const t = (props.node.promptTokens || 0) + (props.node.completionTokens || 0)
if (!t) return ''
return `${t >= 1000 ? (t / 1000).toFixed(1) + 'k' : t} tok`
})
function stepStatus(i: number): 'pending' | 'running' | 'completed' { function stepStatus(i: number): 'pending' | 'running' | 'completed' {
const p = plan.value const p = plan.value
if (!p) return 'pending' if (!p) return 'pending'
@ -63,6 +70,7 @@ function stepStatus(i: number): 'pending' | 'running' | 'completed' {
<el-icon class="deleg-node__icon" :size="11"><Connection /></el-icon> <el-icon class="deleg-node__icon" :size="11"><Connection /></el-icon>
<span class="deleg-node__name">{{ node.agentName }}</span> <span class="deleg-node__name">{{ node.agentName }}</span>
<span v-if="progress" class="deleg-node__badge">{{ progress }}</span> <span v-if="progress" class="deleg-node__badge">{{ progress }}</span>
<span v-if="tokenLabel" class="deleg-node__badge deleg-node__badge--token">{{ tokenLabel }}</span>
<el-icon v-if="isStalled" class="deleg-node__stale" :title="$t('chat.subagentStalled')" :size="11"><WarningFilled /></el-icon> <el-icon v-if="isStalled" class="deleg-node__stale" :title="$t('chat.subagentStalled')" :size="11"><WarningFilled /></el-icon>
<el-icon <el-icon
v-if="hasBody" v-if="hasBody"
@ -157,6 +165,10 @@ function stepStatus(i: number): 'pending' | 'running' | 'completed' {
padding: 0 6px; padding: 0 6px;
line-height: 16px; line-height: 16px;
} }
.deleg-node__badge--token {
font-variant-numeric: tabular-nums;
opacity: 0.85;
}
.deleg-node__stale { .deleg-node__stale {
flex-shrink: 0; flex-shrink: 0;
color: var(--mc-warning, #e6a23c); color: var(--mc-warning, #e6a23c);

View File

@ -1072,14 +1072,25 @@ export function useChat(options: UseChatOptions): UseChatReturn {
} }
/** Mark a subagent (segment or nested node) complete by subagentId. */ /** Mark a subagent (segment or nested node) complete by subagentId. */
// Compact "(12s · 3.2k tok)" meta suffix for a completed delegation segment.
function delegMetaSuffix(durationMs?: number, promptTokens?: number, completionTokens?: number): string {
const parts: string[] = []
if (durationMs) parts.push(`${Math.round(durationMs / 1000)}s`)
const tok = (promptTokens || 0) + (completionTokens || 0)
if (tok > 0) parts.push(`${tok >= 1000 ? (tok / 1000).toFixed(1) + 'k' : tok} tok`)
return parts.length ? ` (${parts.join(' · ')})` : ''
}
function markDelegComplete(segs: MessageSegment[], subagentId: string | undefined, childConvId: string | undefined, function markDelegComplete(segs: MessageSegment[], subagentId: string | undefined, childConvId: string | undefined,
success: boolean, resultPreview?: string, durationMs?: number): boolean { success: boolean, resultPreview?: string, durationMs?: number,
promptTokens?: number, completionTokens?: number): boolean {
const seg = findDelegSegment(segs, subagentId, childConvId) const seg = findDelegSegment(segs, subagentId, childConvId)
if (seg) { if (seg) {
seg.status = success ? 'completed' : 'error' seg.status = success ? 'completed' : 'error'
seg.toolSuccess = success seg.toolSuccess = success
if (resultPreview) seg.toolResult = resultPreview if (resultPreview) seg.toolResult = resultPreview
if (durationMs) seg.toolArgs = (seg.toolArgs || '').trimEnd() + ` (${Math.round(durationMs / 1000)}s)` const suffix = delegMetaSuffix(durationMs, promptTokens, completionTokens)
if (suffix) seg.toolArgs = (seg.toolArgs || '').trimEnd() + suffix
return true return true
} }
if (subagentId) { if (subagentId) {
@ -1089,6 +1100,8 @@ export function useChat(options: UseChatOptions): UseChatReturn {
node.status = success ? 'completed' : 'error' node.status = success ? 'completed' : 'error'
if (resultPreview) node.result = resultPreview if (resultPreview) node.result = resultPreview
if (durationMs) node.durationMs = durationMs if (durationMs) node.durationMs = durationMs
if (promptTokens) node.promptTokens = promptTokens
if (completionTokens) node.completionTokens = completionTokens
return true return true
} }
} }
@ -1232,7 +1245,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
if (isStaleEvent(data)) return if (isStaleEvent(data)) return
if (!currentAssistantId.value) return if (!currentAssistantId.value) return
markDelegComplete(currentSegments.value, data.subagentId, data.childConversationId, markDelegComplete(currentSegments.value, data.subagentId, data.childConversationId,
!!data.success, data.resultPreview, data.durationMs) !!data.success, data.resultPreview, data.durationMs, data.promptTokens, data.completionTokens)
flushSegmentsToMessage() flushSegmentsToMessage()
}) })
@ -1250,7 +1263,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
: !!(cr.subagentId && findNode(segs.flatMap(s => s.childTimeline?.children || []), cr.subagentId)?.status === 'running') : !!(cr.subagentId && findNode(segs.flatMap(s => s.childTimeline?.children || []), cr.subagentId)?.status === 'running')
if (stillRunning) { if (stillRunning) {
markDelegComplete(segs, cr.subagentId, cr.childConversationId, !!cr.success, markDelegComplete(segs, cr.subagentId, cr.childConversationId, !!cr.success,
cr.error || undefined, cr.durationMs) cr.error || undefined, cr.durationMs, cr.promptTokens, cr.completionTokens)
} }
} }
} else { } else {
@ -1261,7 +1274,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
} }
} else { } else {
markDelegComplete(segs, data.subagentId, data.childConversationId, markDelegComplete(segs, data.subagentId, data.childConversationId,
!!data.success, data.resultPreview, data.durationMs) !!data.success, data.resultPreview, data.durationMs, data.promptTokens, data.completionTokens)
} }
flushSegmentsToMessage() flushSegmentsToMessage()
}) })

View File

@ -171,6 +171,9 @@ export interface DelegationNode {
tools?: DelegationToolEntry[] tools?: DelegationToolEntry[]
result?: string result?: string
durationMs?: number durationMs?: number
/** Child token usage relayed from delegation_end / delegation_child_complete. */
promptTokens?: number
completionTokens?: number
/** Heartbeat watchdog flagged this subagent as making no observable progress. */ /** Heartbeat watchdog flagged this subagent as making no observable progress. */
stale?: boolean stale?: boolean
/** Spawned via fire-and-forget delegation: runs detached, result via task_output. */ /** Spawned via fire-and-forget delegation: runs detached, result via task_output. */