mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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:
parent
6854f8cc44
commit
fa4e7018a0
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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<String> finalModelName = 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 上一步内容
|
||||
AtomicReference<String> lastPersistedStepResult = new AtomicReference<>("");
|
||||
AtomicReference<String> 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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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<ChildResult> 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());
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -408,6 +408,23 @@
|
||||
class="action-model"
|
||||
:title="replyModelTitle"
|
||||
>{{ 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) -->
|
||||
<span
|
||||
v-if="role === 'assistant' && routingBadge"
|
||||
@ -457,7 +474,7 @@ import { storeToRefs } from 'pinia'
|
||||
import PlanStepsPanel from './PlanStepsPanel.vue'
|
||||
import UserMessageContent from './UserMessageContent.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'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
@ -1001,6 +1018,46 @@ const useSegmentedView = computed(() =>
|
||||
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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: '图片',
|
||||
|
||||
@ -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 */
|
||||
|
||||
Loading…
Reference in New Issue
Block a user