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.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 {

View File

@ -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 {
* <p>{@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<String, Object> ev = delegationPayload(subagentId, parentSubagentId, depth, childConvId, agentName);
ev.put("success", result.success);
ev.put("durationMs", result.durationMs);
ev.put("promptTokens", result.promptTokens);
ev.put("completionTokens", result.completionTokens);
ev.put("resultPreview",
result.success ? truncate(result.result, 200) : (result.error != null ? result.error : ""));
streamTracker.broadcastObject(rootConvId, "delegation_end", ev);

View File

@ -15,6 +15,7 @@ import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import vip.mate.agent.AgentService;
import vip.mate.agent.AgentService.ChatResult;
import vip.mate.agent.delegation.SubagentRegistry;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
@ -180,9 +181,9 @@ class DelegateAgentToolTest {
// the test thread returns immediately. The orphan keeps sleeping on a
// virtual thread until JVM teardown that's the same behavior as
// 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);
return "should not reach here";
return ChatResult.contentOnly("should not reach here");
});
// Set a conversationId so resolveParentConversationId works
@ -239,13 +240,13 @@ class DelegateAgentToolTest {
when(streamTracker.isRunning(any())).thenReturn(false);
// FastAgent completes immediately
when(agentService.chat(eq(10L), anyString(), anyString(), any()))
.thenReturn("Fast result completed successfully");
when(agentService.chatWithUsage(eq(10L), anyString(), anyString(), any()))
.thenReturn(ChatResult.contentOnly("Fast result completed successfully"));
// 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);
return "should not reach here";
return ChatResult.contentOnly("should not reach here");
});
ToolExecutionContext.set("parent-mixed", "admin");
@ -286,12 +287,12 @@ class DelegateAgentToolTest {
.thenReturn(slowAgent);
when(streamTracker.isRunning(any())).thenReturn(false);
// 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"));
// 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);
return "unreachable";
return ChatResult.contentOnly("unreachable");
});
ToolExecutionContext.set("parent-ff", "admin");
@ -325,10 +326,10 @@ class DelegateAgentToolTest {
.thenReturn(optAgent)
.thenReturn(okAgent);
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"));
when(agentService.chat(eq(31L), anyString(), anyString(), any()))
.thenReturn("ok result done");
when(agentService.chatWithUsage(eq(31L), anyString(), anyString(), any()))
.thenReturn(ChatResult.contentOnly("ok result done"));
ToolExecutionContext.set("parent-opt", "admin");
String json = "[{\"agentName\":\"OptAgent\",\"task\":\"a\",\"optional\":true},"
@ -354,9 +355,9 @@ class DelegateAgentToolTest {
when(agentMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(longAgent);
when(streamTracker.isRunning(any())).thenReturn(false);
// 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);
return "long task finished ok";
return ChatResult.contentOnly("long task finished ok");
});
ToolExecutionContext.set("parent-to", "admin");
@ -368,4 +369,60 @@ class DelegateAgentToolTest {
"long task should complete within the widened budget: " + 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.Spy;
import vip.mate.agent.AgentService;
import vip.mate.agent.AgentService.ChatResult;
import vip.mate.agent.delegation.SubagentRegistry;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
@ -108,14 +109,14 @@ class DelegateEventSequenceTest {
});
// 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 -> {
// The relay listener should have been registered by now fire it
BiConsumer<String, String> relay = relayRef.get();
assertNotNull(relay, "Relay should be registered before child chat starts");
relay.accept("tool_call_started", "{\"name\":\"searchWeb\"}");
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
@ -170,8 +171,10 @@ class DelegateEventSequenceTest {
when(streamTracker.addBatchedEventRelay(anyString(), anyString(), anyInt(), anyLong(), any()))
.thenReturn(() -> {});
when(agentService.chat(eq(101L), anyString(), anyString(), any())).thenReturn("Result A");
when(agentService.chat(eq(102L), anyString(), anyString(), any())).thenReturn("Result B");
when(agentService.chatWithUsage(eq(101L), anyString(), anyString(), any()))
.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\"}]";
@ -203,7 +206,8 @@ class DelegateEventSequenceTest {
ToolExecutionContext.set("inactive-parent", "admin");
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
delegateAgentTool.delegateToAgent("QuietAgent", "quiet task", null, null);
@ -235,7 +239,7 @@ class DelegateEventSequenceTest {
return (Runnable) () -> {};
});
when(agentService.chat(eq(300L), anyString(), anyString(), any()))
when(agentService.chatWithUsage(eq(300L), anyString(), anyString(), any()))
.thenAnswer(invocation -> {
BiConsumer<String, String> relay = relayRef.get();
// These should produce delegation_progress:
@ -244,7 +248,7 @@ class DelegateEventSequenceTest {
// These should be ignored by the relay filter:
relay.accept("heartbeat", "{}");
relay.accept("token", "{\"text\":\"hello\"}");
return "filtered result";
return ChatResult.contentOnly("filtered result");
});
delegateAgentTool.delegateToAgent("FilterAgent", "filter task", null, null);
@ -294,18 +298,19 @@ class DelegateEventSequenceTest {
// ToolExecutionContext to the Child's own conversation. Reproduce that so
// the grandchild's immediate parent resolves to childConv, while its
// 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 -> {
String childConv = inv.getArgument(2);
ToolExecutionContext.set(childConv, "admin");
try {
return delegateAgentTool.delegateToAgent("Grandchild", "gtask", null, null);
return ChatResult.contentOnly(
delegateAgentTool.delegateToAgent("Grandchild", "gtask", null, null));
} finally {
ToolExecutionContext.set(rootConv, "admin");
}
});
when(agentService.chat(eq(200L), anyString(), anyString(), any()))
.thenReturn("grandchild done");
when(agentService.chatWithUsage(eq(200L), anyString(), anyString(), any()))
.thenReturn(ChatResult.contentOnly("grandchild done"));
delegateAgentTool.delegateToAgent("Child", "ctask", null, null);

View File

@ -42,6 +42,13 @@ const progress = computed(() => {
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' {
const p = plan.value
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>
<span class="deleg-node__name">{{ node.agentName }}</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="hasBody"
@ -157,6 +165,10 @@ function stepStatus(i: number): 'pending' | 'running' | 'completed' {
padding: 0 6px;
line-height: 16px;
}
.deleg-node__badge--token {
font-variant-numeric: tabular-nums;
opacity: 0.85;
}
.deleg-node__stale {
flex-shrink: 0;
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. */
// 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,
success: boolean, resultPreview?: string, durationMs?: number): boolean {
success: boolean, resultPreview?: string, durationMs?: number,
promptTokens?: number, completionTokens?: number): boolean {
const seg = findDelegSegment(segs, subagentId, childConvId)
if (seg) {
seg.status = success ? 'completed' : 'error'
seg.toolSuccess = success
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
}
if (subagentId) {
@ -1089,6 +1100,8 @@ export function useChat(options: UseChatOptions): UseChatReturn {
node.status = success ? 'completed' : 'error'
if (resultPreview) node.result = resultPreview
if (durationMs) node.durationMs = durationMs
if (promptTokens) node.promptTokens = promptTokens
if (completionTokens) node.completionTokens = completionTokens
return true
}
}
@ -1232,7 +1245,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
if (isStaleEvent(data)) return
if (!currentAssistantId.value) return
markDelegComplete(currentSegments.value, data.subagentId, data.childConversationId,
!!data.success, data.resultPreview, data.durationMs)
!!data.success, data.resultPreview, data.durationMs, data.promptTokens, data.completionTokens)
flushSegmentsToMessage()
})
@ -1250,7 +1263,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
: !!(cr.subagentId && findNode(segs.flatMap(s => s.childTimeline?.children || []), cr.subagentId)?.status === 'running')
if (stillRunning) {
markDelegComplete(segs, cr.subagentId, cr.childConversationId, !!cr.success,
cr.error || undefined, cr.durationMs)
cr.error || undefined, cr.durationMs, cr.promptTokens, cr.completionTokens)
}
}
} else {
@ -1261,7 +1274,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
}
} else {
markDelegComplete(segs, data.subagentId, data.childConversationId,
!!data.success, data.resultPreview, data.durationMs)
!!data.success, data.resultPreview, data.durationMs, data.promptTokens, data.completionTokens)
}
flushSegmentsToMessage()
})

View File

@ -171,6 +171,9 @@ export interface DelegationNode {
tools?: DelegationToolEntry[]
result?: string
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. */
stale?: boolean
/** Spawned via fire-and-forget delegation: runs detached, result via task_output. */