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 */