feat(delegation): 本轮 token 总量页脚 + 子 Agent 用量向上滚加

- 新增 DelegatedUsageAccumulator:按根会话累加每个完成子 Agent 用量,根 Agent
  在 _usage_final 处一次性 drain 整棵子树、中间层得 0,每个子只计一次、无重复计数
- runSingleChild 增 accumulateToParent:同步/并行/计划步骤委派计入父轮,游离异步不计
- ReAct / Plan-Execute 在 _usage_final 处 drain 并加进 token + 附委派分解字段,
  doFinally 清理防泄漏;该事件同时驱动实时 SSE 与 mate_message 落库,实时/刷新一致
- 前端消息底部新增 Σ<total> tok 徽标(tooltip 含委派分解),总量仅取 message 用量
- 测试:补 DelegatedUsageAccumulator 接线,委派全套 59 绿
This commit is contained in:
matevip 2026-06-30 17:21:10 +08:00
parent 6854f8cc44
commit fa4e7018a0
14 changed files with 269 additions and 17 deletions

View File

@ -0,0 +1,95 @@
package vip.mate.agent.delegation;
import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
/**
* Per-conversation accumulator for delegated sub-agent token usage.
*
* <p>When a parent turn delegates work to sub-agents, each child runs as its own
* agent invocation in a separate conversation, so its token usage never lands in
* the parent graph's own usage counters. This accumulator lets the delegation
* layer record each completed child's usage keyed by the <em>root</em>
* (user-facing) conversation, so the parent turn's {@code _usage_final} emission
* can roll the whole sub-tree up into the turn total surfaced live on the SSE
* stream and persisted on the assistant message.
*
* <p><b>No double counting across nesting:</b> every descendant (child,
* grandchild, ) records against the same root conversation, because the
* delegation context carries the original root forward. The root agent drains
* the full tree exactly once at its {@code _usage_final}; intermediate agents
* drain their own conversation key, which holds nothing. A child agent's own
* usage (returned to its parent and recorded once by the parent's delegation
* call) is therefore counted a single time.
*
* <p>Exposed via a static accessor because the StateGraph agents that emit
* {@code _usage_final} are built per-config and are not Spring-managed beans, so
* they cannot receive this singleton by constructor injection.
*/
@Component
public class DelegatedUsageAccumulator {
private static volatile DelegatedUsageAccumulator instance;
@PostConstruct
void register() {
instance = this;
}
/** Returns the singleton, or {@code null} before the context is ready. */
public static DelegatedUsageAccumulator getInstance() {
return instance;
}
private record Usage(AtomicLong prompt, AtomicLong completion) {
Usage() {
this(new AtomicLong(), new AtomicLong());
}
}
private final Map<String, Usage> byConversation = new ConcurrentHashMap<>();
/** Record one completed child's usage against its root conversation. */
public void add(String rootConversationId, int promptTokens, int completionTokens) {
if (rootConversationId == null || rootConversationId.isBlank()) {
return;
}
if (promptTokens <= 0 && completionTokens <= 0) {
return;
}
Usage u = byConversation.computeIfAbsent(rootConversationId, k -> new Usage());
if (promptTokens > 0) {
u.prompt().addAndGet(promptTokens);
}
if (completionTokens > 0) {
u.completion().addAndGet(completionTokens);
}
}
/** Token pair carrier for a drained accumulation. */
public record Drained(long promptTokens, long completionTokens) {
public boolean isEmpty() {
return promptTokens <= 0 && completionTokens <= 0;
}
}
/** Atomically read and remove the accumulated delegated usage for a conversation. */
public Drained drain(String conversationId) {
if (conversationId == null) {
return new Drained(0, 0);
}
Usage u = byConversation.remove(conversationId);
return u == null ? new Drained(0, 0) : new Drained(u.prompt().get(), u.completion().get());
}
/** Discard any accumulation for a conversation — leak guard on error/cancel. */
public void clear(String conversationId) {
if (conversationId != null) {
byConversation.remove(conversationId);
}
}
}

View File

@ -13,6 +13,7 @@ import reactor.core.publisher.Mono;
import vip.mate.agent.AgentService; import vip.mate.agent.AgentService;
import vip.mate.agent.AgentState; import vip.mate.agent.AgentState;
import vip.mate.agent.BaseAgent; import vip.mate.agent.BaseAgent;
import vip.mate.agent.delegation.DelegatedUsageAccumulator;
import vip.mate.agent.GraphEventPublisher; import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.StructuredStreamCapable; import vip.mate.agent.StructuredStreamCapable;
import vip.mate.agent.context.ConversationWindowManager; import vip.mate.agent.context.ConversationWindowManager;
@ -282,10 +283,18 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
return deltas; return deltas;
}) })
.concatWith(Mono.fromSupplier(() -> { .concatWith(Mono.fromSupplier(() -> {
if (finalPromptTokens.get() > 0 || finalCompletionTokens.get() > 0) { DelegatedUsageAccumulator acc = DelegatedUsageAccumulator.getInstance();
DelegatedUsageAccumulator.Drained delegated = acc != null
? acc.drain(conversationId)
: new DelegatedUsageAccumulator.Drained(0, 0);
long promptTokens = finalPromptTokens.get() + delegated.promptTokens();
long completionTokens = finalCompletionTokens.get() + delegated.completionTokens();
if (promptTokens > 0 || completionTokens > 0) {
return AgentService.StreamDelta.event("_usage_final", Map.of( return AgentService.StreamDelta.event("_usage_final", Map.of(
"promptTokens", finalPromptTokens.get(), "promptTokens", promptTokens,
"completionTokens", finalCompletionTokens.get(), "completionTokens", completionTokens,
"delegatedPromptTokens", delegated.promptTokens(),
"delegatedCompletionTokens", delegated.completionTokens(),
"runtimeModelName", finalModelName.get(), "runtimeModelName", finalModelName.get(),
"runtimeProviderId", finalProviderId.get() "runtimeProviderId", finalProviderId.get()
)); ));
@ -304,6 +313,12 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
.doOnError(e -> { .doOnError(e -> {
log.error("[{}] StateGraph replay stream error: {}", agentName, e.getMessage()); log.error("[{}] StateGraph replay stream error: {}", agentName, e.getMessage());
setState(AgentState.ERROR); setState(AgentState.ERROR);
})
// Leak guard: discard delegated usage if the turn ends without
// emitting _usage_final (error / cancel).
.doFinally(sig -> {
DelegatedUsageAccumulator acc = DelegatedUsageAccumulator.getInstance();
if (acc != null) acc.clear(conversationId);
}); });
} catch (Exception e) { } catch (Exception e) {
setState(AgentState.ERROR); setState(AgentState.ERROR);
@ -434,10 +449,18 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
}) })
// 流正常完成后追加内部 usage 事件 // 流正常完成后追加内部 usage 事件
.concatWith(Mono.fromSupplier(() -> { .concatWith(Mono.fromSupplier(() -> {
if (finalPromptTokens.get() > 0 || finalCompletionTokens.get() > 0) { DelegatedUsageAccumulator acc = DelegatedUsageAccumulator.getInstance();
DelegatedUsageAccumulator.Drained delegated = acc != null
? acc.drain(conversationId)
: new DelegatedUsageAccumulator.Drained(0, 0);
long promptTokens = finalPromptTokens.get() + delegated.promptTokens();
long completionTokens = finalCompletionTokens.get() + delegated.completionTokens();
if (promptTokens > 0 || completionTokens > 0) {
return AgentService.StreamDelta.event("_usage_final", Map.of( return AgentService.StreamDelta.event("_usage_final", Map.of(
"promptTokens", finalPromptTokens.get(), "promptTokens", promptTokens,
"completionTokens", finalCompletionTokens.get(), "completionTokens", completionTokens,
"delegatedPromptTokens", delegated.promptTokens(),
"delegatedCompletionTokens", delegated.completionTokens(),
"runtimeModelName", finalModelName.get(), "runtimeModelName", finalModelName.get(),
"runtimeProviderId", finalProviderId.get() "runtimeProviderId", finalProviderId.get()
)); ));
@ -457,6 +480,12 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
.doOnError(e -> { .doOnError(e -> {
log.error("[{}] StateGraph structured stream error: {}", agentName, e.getMessage()); log.error("[{}] StateGraph structured stream error: {}", agentName, e.getMessage());
setState(AgentState.ERROR); setState(AgentState.ERROR);
})
// Leak guard: discard delegated usage if the turn ends without
// emitting _usage_final (error / cancel).
.doFinally(sig -> {
DelegatedUsageAccumulator acc = DelegatedUsageAccumulator.getInstance();
if (acc != null) acc.clear(conversationId);
}); });
} catch (Exception e) { } catch (Exception e) {
setState(AgentState.ERROR); setState(AgentState.ERROR);

View File

@ -11,6 +11,7 @@ import reactor.core.publisher.Mono;
import vip.mate.agent.AgentService; import vip.mate.agent.AgentService;
import vip.mate.agent.AgentState; import vip.mate.agent.AgentState;
import vip.mate.agent.BaseAgent; import vip.mate.agent.BaseAgent;
import vip.mate.agent.delegation.DelegatedUsageAccumulator;
import vip.mate.agent.GraphEventPublisher; import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.StructuredStreamCapable; import vip.mate.agent.StructuredStreamCapable;
import vip.mate.agent.graph.plan.state.PlanStateKeys; import vip.mate.agent.graph.plan.state.PlanStateKeys;
@ -144,6 +145,10 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
AtomicInteger finalCompletionTokens = new AtomicInteger(0); AtomicInteger finalCompletionTokens = new AtomicInteger(0);
AtomicReference<String> finalModelName = new AtomicReference<>(""); AtomicReference<String> finalModelName = new AtomicReference<>("");
AtomicReference<String> finalProviderId = new AtomicReference<>(""); AtomicReference<String> finalProviderId = new AtomicReference<>("");
// Root conversation for this turn used to roll delegated sub-agent
// token usage into the turn's _usage_final and to clear the accumulator
// on terminal so an errored turn never leaks an entry.
final String usageConversationId = (String) inputs.get(MateClawStateKeys.CONVERSATION_ID);
// 去重记录上一次已持久化的 step 结果和 thinking防止 PlanSummaryNode 重复 emit 上一步内容 // 去重记录上一次已持久化的 step 结果和 thinking防止 PlanSummaryNode 重复 emit 上一步内容
AtomicReference<String> lastPersistedStepResult = new AtomicReference<>(""); AtomicReference<String> lastPersistedStepResult = new AtomicReference<>("");
AtomicReference<String> lastPersistedStepThinking = new AtomicReference<>(""); AtomicReference<String> lastPersistedStepThinking = new AtomicReference<>("");
@ -209,10 +214,21 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
return deltas; return deltas;
}) })
.concatWith(Mono.fromSupplier(() -> { .concatWith(Mono.fromSupplier(() -> {
if (finalPromptTokens.get() > 0 || finalCompletionTokens.get() > 0) { // Roll delegated sub-agent usage (whole sub-tree, keyed by this
// root conversation) into the turn total so the assistant
// message reflects what the orchestrator + all children cost.
DelegatedUsageAccumulator acc = DelegatedUsageAccumulator.getInstance();
DelegatedUsageAccumulator.Drained delegated = acc != null
? acc.drain(usageConversationId)
: new DelegatedUsageAccumulator.Drained(0, 0);
long promptTokens = finalPromptTokens.get() + delegated.promptTokens();
long completionTokens = finalCompletionTokens.get() + delegated.completionTokens();
if (promptTokens > 0 || completionTokens > 0) {
return AgentService.StreamDelta.event("_usage_final", Map.of( return AgentService.StreamDelta.event("_usage_final", Map.of(
"promptTokens", finalPromptTokens.get(), "promptTokens", promptTokens,
"completionTokens", finalCompletionTokens.get(), "completionTokens", completionTokens,
"delegatedPromptTokens", delegated.promptTokens(),
"delegatedCompletionTokens", delegated.completionTokens(),
"runtimeModelName", finalModelName.get(), "runtimeModelName", finalModelName.get(),
"runtimeProviderId", finalProviderId.get() "runtimeProviderId", finalProviderId.get()
)); ));
@ -223,6 +239,13 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
.doOnError(e -> { .doOnError(e -> {
log.error("[{}] Plan-Execute stream error: {}", agentName, e.getMessage()); log.error("[{}] Plan-Execute stream error: {}", agentName, e.getMessage());
setState(AgentState.ERROR); setState(AgentState.ERROR);
})
// Leak guard: if the turn ends without emitting _usage_final
// (error / cancel), discard any delegated usage left for this
// conversation so it can't bleed into a later turn.
.doFinally(sig -> {
DelegatedUsageAccumulator acc = DelegatedUsageAccumulator.getInstance();
if (acc != null) acc.clear(usageConversationId);
}); });
} }

View File

@ -15,6 +15,7 @@ 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.AgentService.ChatResult;
import vip.mate.agent.context.ChatOrigin; import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.delegation.DelegatedUsageAccumulator;
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;
@ -152,6 +153,7 @@ public class DelegateAgentTool {
private final SubagentRegistry subagentRegistry; private final SubagentRegistry subagentRegistry;
private final AuditEventService auditEventService; private final AuditEventService auditEventService;
private final AsyncTaskService asyncTaskService; private final AsyncTaskService asyncTaskService;
private final DelegatedUsageAccumulator delegatedUsageAccumulator;
/** Max characters of the task description persisted in {@code request_json}. /** Max characters of the task description persisted in {@code request_json}.
* Anything longer is truncated full task is still inside the running * Anything longer is truncated full task is still inside the running
@ -342,7 +344,7 @@ public class DelegateAgentTool {
ChildResult result; ChildResult result;
try { try {
result = runSingleChild(0, target, taskWithContext, parentConversationId, childConversationId, result = runSingleChild(0, target, taskWithContext, parentConversationId, childConversationId,
parentOrigin, rootConversationId, subagentId, childDepth); parentOrigin, rootConversationId, subagentId, childDepth, true);
} finally { } finally {
// Cleanup relay + registry regardless of how the child returned // Cleanup relay + registry regardless of how the child returned
// (success / exception / interruption) so we never leak entries. // (success / exception / interruption) so we never leak entries.
@ -561,7 +563,7 @@ public class DelegateAgentTool {
for (PreparedChild p : prepared) { for (PreparedChild p : prepared) {
CompletableFuture<ChildResult> future = CompletableFuture.supplyAsync( CompletableFuture<ChildResult> future = CompletableFuture.supplyAsync(
() -> runSingleChild(p.index, p.agent, p.task, parentConversationId, p.childConvId, () -> runSingleChild(p.index, p.agent, p.task, parentConversationId, p.childConvId,
parentOriginParallel, rootConvFinal, p.subagentId, childDepth), parentOriginParallel, rootConvFinal, p.subagentId, childDepth, true),
DELEGATION_EXECUTOR); DELEGATION_EXECUTOR);
// Broadcast per-child completion as soon as each child finishes // Broadcast per-child completion as soon as each child finishes
@ -869,9 +871,12 @@ public class DelegateAgentTool {
currentUser, currentUser,
() -> { () -> {
try { try {
// Detached async child: its usage belongs to the later
// task_output retrieval, not the spawning turn, so do not
// roll it into the parent's _usage_final.
ChildResult childResult = runSingleChild(0, target, task, ChildResult childResult = runSingleChild(0, target, task,
parentConversationId, childConversationId, parentOrigin, parentConversationId, childConversationId, parentOrigin,
rootConvAsync, subagentId, childDepth); rootConvAsync, subagentId, childDepth, false);
return childResult.toToolResponse(target.getName()); return childResult.toToolResponse(target.getName());
} finally { } finally {
subagentRegistry.get(subagentId).ifPresent(rec -> { subagentRegistry.get(subagentId).ifPresent(rec -> {
@ -1081,7 +1086,8 @@ public class DelegateAgentTool {
private ChildResult runSingleChild(int taskIndex, AgentEntity target, String task, private ChildResult runSingleChild(int taskIndex, AgentEntity target, String task,
String parentConversationId, String childConversationId, String parentConversationId, String childConversationId,
ChatOrigin parentOrigin, ChatOrigin parentOrigin,
String rootConversationId, String subagentId, int childDepth) { String rootConversationId, String subagentId, int childDepth,
boolean accumulateToParent) {
boolean relayChildEvents = parentConversationId != null && streamTracker.isRunning(parentConversationId); boolean relayChildEvents = parentConversationId != null && streamTracker.isRunning(parentConversationId);
if (relayChildEvents) { if (relayChildEvents) {
streamTracker.register(childConversationId); streamTracker.register(childConversationId);
@ -1109,6 +1115,14 @@ public class DelegateAgentTool {
target.getId(), task, childConversationId, childOrigin); target.getId(), task, childConversationId, childOrigin);
long durationMs = System.currentTimeMillis() - startTime; long durationMs = System.currentTimeMillis() - startTime;
String rawResult = chatResult.content(); String rawResult = chatResult.content();
// Roll this child's usage up to the root (user-facing) turn so the
// parent's _usage_final reflects the whole delegation sub-tree.
// Skipped for detached async children, whose result belongs to a
// later task_output retrieval, not the spawning turn.
if (accumulateToParent) {
delegatedUsageAccumulator.add(rootConversationId,
chatResult.promptTokens(), chatResult.completionTokens());
}
// 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, chatResult.promptTokens(), chatResult.completionTokens()); MAX_RESULT_LENGTH, chatResult.promptTokens(), chatResult.completionTokens());

View File

@ -57,7 +57,8 @@ class DelegateAgentToolDenyListTest {
vip.mate.task.AsyncTaskService asyncTaskService = mock(vip.mate.task.AsyncTaskService.class); vip.mate.task.AsyncTaskService asyncTaskService = mock(vip.mate.task.AsyncTaskService.class);
tool = new DelegateAgentTool(agentService, agentMapper, streamTracker, conversationService, tool = new DelegateAgentTool(agentService, agentMapper, streamTracker, conversationService,
objectMapper, registry, auditEventService, asyncTaskService); objectMapper, registry, auditEventService, asyncTaskService,
new vip.mate.agent.delegation.DelegatedUsageAccumulator());
} }
@AfterEach @AfterEach

View File

@ -41,6 +41,8 @@ class DelegateAgentToolTest {
@Mock ConversationService conversationService; @Mock ConversationService conversationService;
@Mock AuditEventService auditEventService; @Mock AuditEventService auditEventService;
@Spy SubagentRegistry subagentRegistry = new SubagentRegistry(); @Spy SubagentRegistry subagentRegistry = new SubagentRegistry();
@Spy vip.mate.agent.delegation.DelegatedUsageAccumulator delegatedUsageAccumulator =
new vip.mate.agent.delegation.DelegatedUsageAccumulator();
@InjectMocks DelegateAgentTool delegateAgentTool; @InjectMocks DelegateAgentTool delegateAgentTool;

View File

@ -66,7 +66,8 @@ class DelegateAsyncTaskOutputAttributionTest {
void setUp() { void setUp() {
tool = new DelegateAgentTool( tool = new DelegateAgentTool(
agentService, agentMapper, streamTracker, conversationService, agentService, agentMapper, streamTracker, conversationService,
objectMapper, subagentRegistry, auditEventService, asyncTaskService); objectMapper, subagentRegistry, auditEventService, asyncTaskService,
new vip.mate.agent.delegation.DelegatedUsageAccumulator());
} }
@AfterEach @AfterEach

View File

@ -69,7 +69,8 @@ class DelegateAsyncToolTest {
void setUp() { void setUp() {
tool = new DelegateAgentTool( tool = new DelegateAgentTool(
agentService, agentMapper, streamTracker, conversationService, agentService, agentMapper, streamTracker, conversationService,
objectMapper, subagentRegistry, auditEventService, asyncTaskService); objectMapper, subagentRegistry, auditEventService, asyncTaskService,
new vip.mate.agent.delegation.DelegatedUsageAccumulator());
// resolveParentConversationId reads from ToolExecutionContext first; // resolveParentConversationId reads from ToolExecutionContext first;
// seed it so the async delegation has a parent to attach the task to. // seed it so the async delegation has a parent to attach the task to.
ToolExecutionContext.set("parent-conv-1", "user-1"); ToolExecutionContext.set("parent-conv-1", "user-1");

View File

@ -50,6 +50,8 @@ class DelegateEventSequenceTest {
@Mock ConversationService conversationService; @Mock ConversationService conversationService;
@Mock AuditEventService auditEventService; @Mock AuditEventService auditEventService;
@Spy SubagentRegistry subagentRegistry = new SubagentRegistry(); @Spy SubagentRegistry subagentRegistry = new SubagentRegistry();
@Spy vip.mate.agent.delegation.DelegatedUsageAccumulator delegatedUsageAccumulator =
new vip.mate.agent.delegation.DelegatedUsageAccumulator();
@InjectMocks DelegateAgentTool delegateAgentTool; @InjectMocks DelegateAgentTool delegateAgentTool;

View File

@ -408,6 +408,23 @@
class="action-model" class="action-model"
:title="replyModelTitle" :title="replyModelTitle"
>{{ replyModel }}</span> >{{ replyModel }}</span>
<!-- Turn token total (assistant only): own usage + delegated sub-agents -->
<span
v-if="role === 'assistant' && tokenUsage"
class="action-tokens"
:title="tokenUsage.delegated > 0
? $t('chat.tokenUsageTooltipDelegated', {
total: tokenUsage.total.toLocaleString(),
input: tokenUsage.input.toLocaleString(),
output: tokenUsage.output.toLocaleString(),
delegated: tokenUsage.delegated.toLocaleString(),
})
: $t('chat.tokenUsageTooltip', {
total: tokenUsage.total.toLocaleString(),
input: tokenUsage.input.toLocaleString(),
output: tokenUsage.output.toLocaleString(),
})"
>Σ {{ fmtTokens(tokenUsage.total) }} tok</span>
<!-- Multimodal sidecar routing badge (assistant only, when sidecar fired) --> <!-- Multimodal sidecar routing badge (assistant only, when sidecar fired) -->
<span <span
v-if="role === 'assistant' && routingBadge" v-if="role === 'assistant' && routingBadge"
@ -457,7 +474,7 @@ import { storeToRefs } from 'pinia'
import PlanStepsPanel from './PlanStepsPanel.vue' import PlanStepsPanel from './PlanStepsPanel.vue'
import UserMessageContent from './UserMessageContent.vue' import UserMessageContent from './UserMessageContent.vue'
import type { BrowserAction } from './BrowserTimeline.vue' import type { BrowserAction } from './BrowserTimeline.vue'
import type { Message, MessageSegment, ChatAttachment, ToolCallMeta, PlanMeta } from '@/types' import type { Message, MessageSegment, ChatAttachment, ToolCallMeta, PlanMeta, DelegationNode } from '@/types'
import type { ChatErrorInfo } from '@/types/chatError' import type { ChatErrorInfo } from '@/types/chatError'
const { t, locale } = useI18n() const { t, locale } = useI18n()
@ -1001,6 +1018,46 @@ const useSegmentedView = computed(() =>
segments.value.some(s => s.type === 'tool_call' && (s.toolName || '').startsWith('→')) segments.value.some(s => s.type === 'tool_call' && (s.toolName || '').startsWith('→'))
) )
/**
* Total token consumption for this assistant turn, rolled up the way a
* multi-agent orchestrator should report it: the parent message's own usage
* PLUS every delegated sub-agent's usage (depth-1 delegation segments and
* their nested children). Returns null when nothing is known yet.
*/
const tokenUsage = computed(() => {
const m = props.message
if (m.role !== 'assistant') return null
// Total comes solely from the message usage, which the backend already rolls
// delegated sub-agent tokens into (so live and reloaded values match and there
// is no double counting against the segment sum below).
const input = m.promptTokens || 0
const output = m.completionTokens || 0
const total = input + output
if (total <= 0) return null
// Informational breakdown for the tooltip: how much of that total came from
// delegated sub-agents. Derived from the delegation segments, so it is present
// live and degrades to 0 after reload (the segments are not persisted).
let delegated = 0
const addNodes = (nodes?: DelegationNode[]) => {
if (!nodes) return
for (const n of nodes) {
delegated += (n.promptTokens || 0) + (n.completionTokens || 0)
addNodes(n.children)
}
}
for (const s of segments.value) {
if (s.type !== 'tool_call') continue
delegated += (s.delegPromptTokens || 0) + (s.delegCompletionTokens || 0)
addNodes(s.childTimeline?.children)
}
return { input, output, total, delegated: Math.min(delegated, total) }
})
/** Compact token count, e.g. 67890 → "67.9k". */
function fmtTokens(n: number): string {
return n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n)
}
/** /**
* Group segments by iterationIndex so each ReAct iteration renders as its own * Group segments by iterationIndex so each ReAct iteration renders as its own
* thinking/tool-calls/content cluster. Falls back to a single ungrouped bucket * thinking/tool-calls/content cluster. Falls back to a single ungrouped bucket
@ -1772,6 +1829,18 @@ watch(isGenerating, (generating) => {
white-space: nowrap; white-space: nowrap;
} }
.action-tokens {
font-size: 11px;
color: var(--mc-text-tertiary, #94a3b8);
margin-left: 4px;
padding: 1px 6px;
border-radius: 4px;
background: var(--mc-fill-2, rgba(100, 116, 139, 0.08));
font-family: var(--mc-mono-font, ui-monospace, "SF Mono", Menlo, monospace);
user-select: text;
white-space: nowrap;
}
.action-routing { .action-routing {
font-size: 11px; font-size: 11px;
color: var(--mc-primary, #d96d46); color: var(--mc-primary, #d96d46);

View File

@ -1091,6 +1091,10 @@ export function useChat(options: UseChatOptions): UseChatReturn {
if (resultPreview) seg.toolResult = resultPreview if (resultPreview) seg.toolResult = resultPreview
const suffix = delegMetaSuffix(durationMs, promptTokens, completionTokens) const suffix = delegMetaSuffix(durationMs, promptTokens, completionTokens)
if (suffix) seg.toolArgs = (seg.toolArgs || '').trimEnd() + suffix if (suffix) seg.toolArgs = (seg.toolArgs || '').trimEnd() + suffix
// Keep tokens as numbers too so the message footer can roll this child
// up into the turn total (the suffix above is display-only).
if (promptTokens) seg.delegPromptTokens = promptTokens
if (completionTokens) seg.delegCompletionTokens = completionTokens
return true return true
} }
if (subagentId) { if (subagentId) {

View File

@ -196,6 +196,8 @@ export default {
downloadFailed: 'Download failed: {reason}', downloadFailed: 'Download failed: {reason}',
regenerate: 'Regenerate', regenerate: 'Regenerate',
replyModel: 'Reply model: {model}', replyModel: 'Reply model: {model}',
tokenUsageTooltip: 'This turn used {total} tokens ({input} in · {output} out)',
tokenUsageTooltipDelegated: 'This turn used {total} tokens ({input} in · {output} out), of which {delegated} came from delegated sub-agents',
routing: { routing: {
kind: { kind: {
image: 'image', image: 'image',

View File

@ -196,6 +196,8 @@ export default {
downloadFailed: '下载失败:{reason}', downloadFailed: '下载失败:{reason}',
regenerate: '重新生成', regenerate: '重新生成',
replyModel: '本条回复模型: {model}', replyModel: '本条回复模型: {model}',
tokenUsageTooltip: '本轮共消耗 {total} tokens输入 {input} · 输出 {output}',
tokenUsageTooltipDelegated: '本轮共消耗 {total} tokens输入 {input} · 输出 {output}),其中子 Agent 委派占 {delegated}',
routing: { routing: {
kind: { kind: {
image: '图片', image: '图片',

View File

@ -214,6 +214,13 @@ export interface MessageSegment {
toolSuccess?: boolean toolSuccess?: boolean
/** LLM-provided tool call id, used to pair tool_call_started ↔ tool_call_completed */ /** LLM-provided tool call id, used to pair tool_call_started ↔ tool_call_completed */
toolCallId?: string toolCallId?: string
/**
* For a top-level delegation segment (toolName starts with "→"): the depth-1
* child agent's own token usage, kept as numbers (alongside the human-readable
* suffix in toolArgs) so the message footer can roll children up into a turn total.
*/
delegPromptTokens?: number
delegCompletionTokens?: number
/** type=content */ /** type=content */
text?: string text?: string
/** type=phase */ /** type=phase */