From fa4e7018a09a74658a4056d68ed3142479eb00d7 Mon Sep 17 00:00:00 2001 From: matevip Date: Tue, 30 Jun 2026 17:21:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(delegation):=20=E6=9C=AC=E8=BD=AE=20token?= =?UTF-8?q?=20=E6=80=BB=E9=87=8F=E9=A1=B5=E8=84=9A=20+=20=E5=AD=90=20Agent?= =?UTF-8?q?=20=E7=94=A8=E9=87=8F=E5=90=91=E4=B8=8A=E6=BB=9A=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 DelegatedUsageAccumulator:按根会话累加每个完成子 Agent 用量,根 Agent 在 _usage_final 处一次性 drain 整棵子树、中间层得 0,每个子只计一次、无重复计数 - runSingleChild 增 accumulateToParent:同步/并行/计划步骤委派计入父轮,游离异步不计 - ReAct / Plan-Execute 在 _usage_final 处 drain 并加进 token + 附委派分解字段, doFinally 清理防泄漏;该事件同时驱动实时 SSE 与 mate_message 落库,实时/刷新一致 - 前端消息底部新增 Σ tok 徽标(tooltip 含委派分解),总量仅取 message 用量 - 测试:补 DelegatedUsageAccumulator 接线,委派全套 59 绿 --- .../delegation/DelegatedUsageAccumulator.java | 95 +++++++++++++++++++ .../agent/graph/StateGraphReActAgent.java | 41 ++++++-- .../plan/StateGraphPlanExecuteAgent.java | 29 +++++- .../mate/tool/builtin/DelegateAgentTool.java | 22 ++++- .../DelegateAgentToolDenyListTest.java | 3 +- .../tool/builtin/DelegateAgentToolTest.java | 2 + ...elegateAsyncTaskOutputAttributionTest.java | 3 +- .../tool/builtin/DelegateAsyncToolTest.java | 3 +- .../builtin/DelegateEventSequenceTest.java | 2 + .../src/components/chat/MessageBubble.vue | 71 +++++++++++++- mateclaw-ui/src/composables/chat/useChat.ts | 4 + mateclaw-ui/src/i18n/locales/en-US.ts | 2 + mateclaw-ui/src/i18n/locales/zh-CN.ts | 2 + mateclaw-ui/src/types/index.ts | 7 ++ 14 files changed, 269 insertions(+), 17 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/delegation/DelegatedUsageAccumulator.java diff --git a/mateclaw-server/src/main/java/vip/mate/agent/delegation/DelegatedUsageAccumulator.java b/mateclaw-server/src/main/java/vip/mate/agent/delegation/DelegatedUsageAccumulator.java new file mode 100644 index 00000000..57155dca --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/delegation/DelegatedUsageAccumulator.java @@ -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. + * + *

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 root + * (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. + * + *

No double counting across nesting: 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. + * + *

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 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); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java index 6e0fcd01..c74edc28 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java @@ -13,6 +13,7 @@ import reactor.core.publisher.Mono; import vip.mate.agent.AgentService; import vip.mate.agent.AgentState; import vip.mate.agent.BaseAgent; +import vip.mate.agent.delegation.DelegatedUsageAccumulator; import vip.mate.agent.GraphEventPublisher; import vip.mate.agent.StructuredStreamCapable; import vip.mate.agent.context.ConversationWindowManager; @@ -282,10 +283,18 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC return deltas; }) .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( - "promptTokens", finalPromptTokens.get(), - "completionTokens", finalCompletionTokens.get(), + "promptTokens", promptTokens, + "completionTokens", completionTokens, + "delegatedPromptTokens", delegated.promptTokens(), + "delegatedCompletionTokens", delegated.completionTokens(), "runtimeModelName", finalModelName.get(), "runtimeProviderId", finalProviderId.get() )); @@ -304,6 +313,12 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC .doOnError(e -> { log.error("[{}] StateGraph replay stream error: {}", agentName, e.getMessage()); 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) { setState(AgentState.ERROR); @@ -434,10 +449,18 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC }) // 流正常完成后追加内部 usage 事件 .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( - "promptTokens", finalPromptTokens.get(), - "completionTokens", finalCompletionTokens.get(), + "promptTokens", promptTokens, + "completionTokens", completionTokens, + "delegatedPromptTokens", delegated.promptTokens(), + "delegatedCompletionTokens", delegated.completionTokens(), "runtimeModelName", finalModelName.get(), "runtimeProviderId", finalProviderId.get() )); @@ -457,6 +480,12 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC .doOnError(e -> { log.error("[{}] StateGraph structured stream error: {}", agentName, e.getMessage()); 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) { setState(AgentState.ERROR); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java index e134adbe..b2af00a5 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java @@ -11,6 +11,7 @@ import reactor.core.publisher.Mono; import vip.mate.agent.AgentService; import vip.mate.agent.AgentState; import vip.mate.agent.BaseAgent; +import vip.mate.agent.delegation.DelegatedUsageAccumulator; import vip.mate.agent.GraphEventPublisher; import vip.mate.agent.StructuredStreamCapable; import vip.mate.agent.graph.plan.state.PlanStateKeys; @@ -144,6 +145,10 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS AtomicInteger finalCompletionTokens = new AtomicInteger(0); AtomicReference finalModelName = new AtomicReference<>(""); AtomicReference 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 上一步内容 AtomicReference lastPersistedStepResult = new AtomicReference<>(""); AtomicReference lastPersistedStepThinking = new AtomicReference<>(""); @@ -209,10 +214,21 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS return deltas; }) .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( - "promptTokens", finalPromptTokens.get(), - "completionTokens", finalCompletionTokens.get(), + "promptTokens", promptTokens, + "completionTokens", completionTokens, + "delegatedPromptTokens", delegated.promptTokens(), + "delegatedCompletionTokens", delegated.completionTokens(), "runtimeModelName", finalModelName.get(), "runtimeProviderId", finalProviderId.get() )); @@ -223,6 +239,13 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS .doOnError(e -> { log.error("[{}] Plan-Execute stream error: {}", agentName, e.getMessage()); 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); }); } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java index 275cd1c3..e3b56a82 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java @@ -15,6 +15,7 @@ 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.DelegatedUsageAccumulator; import vip.mate.agent.delegation.SubagentRegistry; import vip.mate.agent.model.AgentEntity; import vip.mate.agent.repository.AgentMapper; @@ -152,6 +153,7 @@ public class DelegateAgentTool { private final SubagentRegistry subagentRegistry; private final AuditEventService auditEventService; private final AsyncTaskService asyncTaskService; + private final DelegatedUsageAccumulator delegatedUsageAccumulator; /** Max characters of the task description persisted in {@code request_json}. * Anything longer is truncated — full task is still inside the running @@ -342,7 +344,7 @@ public class DelegateAgentTool { ChildResult result; try { result = runSingleChild(0, target, taskWithContext, parentConversationId, childConversationId, - parentOrigin, rootConversationId, subagentId, childDepth); + parentOrigin, rootConversationId, subagentId, childDepth, true); } finally { // Cleanup relay + registry regardless of how the child returned // (success / exception / interruption) so we never leak entries. @@ -561,7 +563,7 @@ public class DelegateAgentTool { for (PreparedChild p : prepared) { CompletableFuture future = CompletableFuture.supplyAsync( () -> runSingleChild(p.index, p.agent, p.task, parentConversationId, p.childConvId, - parentOriginParallel, rootConvFinal, p.subagentId, childDepth), + parentOriginParallel, rootConvFinal, p.subagentId, childDepth, true), DELEGATION_EXECUTOR); // Broadcast per-child completion as soon as each child finishes @@ -869,9 +871,12 @@ public class DelegateAgentTool { currentUser, () -> { 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, parentConversationId, childConversationId, parentOrigin, - rootConvAsync, subagentId, childDepth); + rootConvAsync, subagentId, childDepth, false); return childResult.toToolResponse(target.getName()); } finally { subagentRegistry.get(subagentId).ifPresent(rec -> { @@ -1081,7 +1086,8 @@ public class DelegateAgentTool { private ChildResult runSingleChild(int taskIndex, AgentEntity target, String task, String parentConversationId, String childConversationId, ChatOrigin parentOrigin, - String rootConversationId, String subagentId, int childDepth) { + String rootConversationId, String subagentId, int childDepth, + boolean accumulateToParent) { boolean relayChildEvents = parentConversationId != null && streamTracker.isRunning(parentConversationId); if (relayChildEvents) { streamTracker.register(childConversationId); @@ -1109,6 +1115,14 @@ public class DelegateAgentTool { target.getId(), task, childConversationId, childOrigin); long durationMs = System.currentTimeMillis() - startTime; 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. return ChildResult.ofSuccess(taskIndex, target.getName(), rawResult, durationMs, MAX_RESULT_LENGTH, chatResult.promptTokens(), chatResult.completionTokens()); diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolDenyListTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolDenyListTest.java index 355e71b6..39fc2adf 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolDenyListTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolDenyListTest.java @@ -57,7 +57,8 @@ class DelegateAgentToolDenyListTest { vip.mate.task.AsyncTaskService asyncTaskService = mock(vip.mate.task.AsyncTaskService.class); tool = new DelegateAgentTool(agentService, agentMapper, streamTracker, conversationService, - objectMapper, registry, auditEventService, asyncTaskService); + objectMapper, registry, auditEventService, asyncTaskService, + new vip.mate.agent.delegation.DelegatedUsageAccumulator()); } @AfterEach diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolTest.java index 3c379571..4e0f47a3 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolTest.java @@ -41,6 +41,8 @@ class DelegateAgentToolTest { @Mock ConversationService conversationService; @Mock AuditEventService auditEventService; @Spy SubagentRegistry subagentRegistry = new SubagentRegistry(); + @Spy vip.mate.agent.delegation.DelegatedUsageAccumulator delegatedUsageAccumulator = + new vip.mate.agent.delegation.DelegatedUsageAccumulator(); @InjectMocks DelegateAgentTool delegateAgentTool; diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncTaskOutputAttributionTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncTaskOutputAttributionTest.java index c336a0ef..35dd9d5e 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncTaskOutputAttributionTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncTaskOutputAttributionTest.java @@ -66,7 +66,8 @@ class DelegateAsyncTaskOutputAttributionTest { void setUp() { tool = new DelegateAgentTool( agentService, agentMapper, streamTracker, conversationService, - objectMapper, subagentRegistry, auditEventService, asyncTaskService); + objectMapper, subagentRegistry, auditEventService, asyncTaskService, + new vip.mate.agent.delegation.DelegatedUsageAccumulator()); } @AfterEach diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncToolTest.java index 2faa923e..c1f5f0ac 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncToolTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncToolTest.java @@ -69,7 +69,8 @@ class DelegateAsyncToolTest { void setUp() { tool = new DelegateAgentTool( agentService, agentMapper, streamTracker, conversationService, - objectMapper, subagentRegistry, auditEventService, asyncTaskService); + objectMapper, subagentRegistry, auditEventService, asyncTaskService, + new vip.mate.agent.delegation.DelegatedUsageAccumulator()); // resolveParentConversationId reads from ToolExecutionContext first; // seed it so the async delegation has a parent to attach the task to. ToolExecutionContext.set("parent-conv-1", "user-1"); diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateEventSequenceTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateEventSequenceTest.java index 4c734217..d2d98d5d 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateEventSequenceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateEventSequenceTest.java @@ -50,6 +50,8 @@ class DelegateEventSequenceTest { @Mock ConversationService conversationService; @Mock AuditEventService auditEventService; @Spy SubagentRegistry subagentRegistry = new SubagentRegistry(); + @Spy vip.mate.agent.delegation.DelegatedUsageAccumulator delegatedUsageAccumulator = + new vip.mate.agent.delegation.DelegatedUsageAccumulator(); @InjectMocks DelegateAgentTool delegateAgentTool; diff --git a/mateclaw-ui/src/components/chat/MessageBubble.vue b/mateclaw-ui/src/components/chat/MessageBubble.vue index 51389eb5..679d3377 100644 --- a/mateclaw-ui/src/components/chat/MessageBubble.vue +++ b/mateclaw-ui/src/components/chat/MessageBubble.vue @@ -408,6 +408,23 @@ class="action-model" :title="replyModelTitle" >{{ replyModel }} + + Σ {{ fmtTokens(tokenUsage.total) }} tok 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 * thinking/tool-calls/content cluster. Falls back to a single ungrouped bucket @@ -1772,6 +1829,18 @@ watch(isGenerating, (generating) => { 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 { font-size: 11px; color: var(--mc-primary, #d96d46); diff --git a/mateclaw-ui/src/composables/chat/useChat.ts b/mateclaw-ui/src/composables/chat/useChat.ts index 6d0658e6..0afec9dc 100644 --- a/mateclaw-ui/src/composables/chat/useChat.ts +++ b/mateclaw-ui/src/composables/chat/useChat.ts @@ -1091,6 +1091,10 @@ export function useChat(options: UseChatOptions): UseChatReturn { if (resultPreview) seg.toolResult = resultPreview const suffix = delegMetaSuffix(durationMs, promptTokens, completionTokens) 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 } if (subagentId) { diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 6eb6f47d..7d4a6bf2 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -196,6 +196,8 @@ export default { downloadFailed: 'Download failed: {reason}', regenerate: 'Regenerate', 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: { kind: { image: 'image', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 76fabb23..a83f1985 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -196,6 +196,8 @@ export default { downloadFailed: '下载失败:{reason}', regenerate: '重新生成', replyModel: '本条回复模型: {model}', + tokenUsageTooltip: '本轮共消耗 {total} tokens(输入 {input} · 输出 {output})', + tokenUsageTooltipDelegated: '本轮共消耗 {total} tokens(输入 {input} · 输出 {output}),其中子 Agent 委派占 {delegated}', routing: { kind: { image: '图片', diff --git a/mateclaw-ui/src/types/index.ts b/mateclaw-ui/src/types/index.ts index 6bd3de32..9af1d633 100644 --- a/mateclaw-ui/src/types/index.ts +++ b/mateclaw-ui/src/types/index.ts @@ -214,6 +214,13 @@ export interface MessageSegment { toolSuccess?: boolean /** LLM-provided tool call id, used to pair tool_call_started ↔ tool_call_completed */ 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 */ text?: string /** type=phase */