mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +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.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);
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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());
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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: '图片',
|
||||||
|
|||||||
@ -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 */
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user