Nan Gao aafd5077b2
feat(subagents): show effective model and token usage on task cards (#4049)
* feat(subagents): show runtime metadata on task cards

* fix(subagents): stop task-card render loop and dedupe model fetches

Address code review on the runtime-metadata cards:

- P1 render loop: the terminal ToolMessage is re-parsed on every
  MessageList render and always carries modelName/usage, so the
  presence-based setTasks condition fired a fresh state object each
  render -> "Maximum update depth exceeded". computeNextSubtask now
  returns a value-compared `changed` flag and a pure subtaskNotification()
  routes terminal transitions through the deferred after-render path
  while skipping no-op re-parses.

- Per-card useModels refetch: add staleTime: Infinity to the ["models"]
  query so every subtask card shares one /api/models fetch instead of
  refetching on each mount.

* make format

* refactor(subagents): dedupe token-usage validators + tidy event narrowing

Address PR review follow-ups:

- DRY: extract one shared token-usage validator per side. Backend
  status_contract.normalize_token_usage() now backs both the terminal
  ToolMessage metadata and the subagent.step/.end run events
  (step_events.py), and frontend messages/usage.normalizeTokenUsage()
  backs both the live task_running event (lifecycle.ts) and the terminal
  ToolMessage metadata (subtask-result.ts). Prevents the input/output/
  total_tokens validation from drifting across the four former copies.

- Nit: onCustomEvent narrows event.type once instead of re-checking the
  object shape per branch; the redundant task_started early-return
  (already validated by taskEventToSubtaskUpdate) is dropped.
2026-07-11 15:41:57 +08:00

157 lines
4.4 KiB
TypeScript

import type { Message } from "@langchain/langgraph-sdk";
export interface TokenUsage {
inputTokens: number;
outputTokens: number;
totalTokens: number;
}
/**
* Extract usage_metadata from an AI message if present.
* The field is added by the backend (PR #1218) but not typed in the SDK.
*/
export function getUsageMetadata(message: Message): TokenUsage | null {
if (message.type !== "ai") {
return null;
}
const usage =
((message as Record<string, unknown>).usage_metadata as
| {
input_tokens?: number;
output_tokens?: number;
total_tokens?: number;
}
| undefined) ??
(message.additional_kwargs?.usage_metadata as
| {
input_tokens?: number;
output_tokens?: number;
total_tokens?: number;
}
| undefined);
if (!usage) {
return null;
}
return {
inputTokens: usage.input_tokens ?? 0,
outputTokens: usage.output_tokens ?? 0,
totalTokens: usage.total_tokens ?? 0,
};
}
/**
* Accumulate token usage across AI messages.
*
* UI rendering may place the same AI message in more than one group, such as
* when a message contains both reasoning and final answer content. Token usage
* is attached to the AI message itself, so a message id should only contribute
* once to any aggregate.
*/
export function accumulateUsage(messages: Message[]): TokenUsage | null {
const cumulative: TokenUsage = {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
};
let hasUsage = false;
const countedMessageIds = new Set<string>();
for (const message of messages) {
const usage = getUsageMetadata(message);
if (!usage) {
continue;
}
if (message.id) {
if (countedMessageIds.has(message.id)) {
continue;
}
countedMessageIds.add(message.id);
}
hasUsage = true;
cumulative.inputTokens += usage.inputTokens;
cumulative.outputTokens += usage.outputTokens;
cumulative.totalTokens += usage.totalTokens;
}
return hasUsage ? cumulative : null;
}
/**
* Validate a raw `{input,output,total}_tokens` object into {@link TokenUsage}.
*
* The single shared validator for both sub-agent usage surfaces — the live
* `task_running` event (`core/tasks/lifecycle.ts`) and the terminal ToolMessage
* metadata (`core/tasks/subtask-result.ts`). Keeping one function stops the two
* from drifting (e.g. one accepting an extra token field the other rejects).
* Every key must be a finite, non-negative number or the whole snapshot is
* rejected as `undefined`.
*/
export function normalizeTokenUsage(value: unknown): TokenUsage | undefined {
if (typeof value !== "object" || value === null) {
return undefined;
}
const record = value as Record<string, unknown>;
const inputTokens = nonNegativeNumber(record.input_tokens);
const outputTokens = nonNegativeNumber(record.output_tokens);
const totalTokens = nonNegativeNumber(record.total_tokens);
if (
inputTokens === undefined ||
outputTokens === undefined ||
totalTokens === undefined
) {
return undefined;
}
return { inputTokens, outputTokens, totalTokens };
}
function nonNegativeNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value >= 0
? value
: undefined;
}
export function hasNonZeroUsage(
usage: TokenUsage | null | undefined,
): usage is TokenUsage {
return (
usage !== null &&
usage !== undefined &&
(usage.inputTokens > 0 || usage.outputTokens > 0 || usage.totalTokens > 0)
);
}
export function addUsage(base: TokenUsage, delta: TokenUsage): TokenUsage {
return {
inputTokens: base.inputTokens + delta.inputTokens,
outputTokens: base.outputTokens + delta.outputTokens,
totalTokens: base.totalTokens + delta.totalTokens,
};
}
export function selectHeaderTokenUsage({
backendUsage,
messages,
pendingMessages = [],
}: {
backendUsage?: TokenUsage | null;
messages: Message[];
pendingMessages?: Message[];
}): TokenUsage | null {
if (hasNonZeroUsage(backendUsage)) {
const pendingUsage = accumulateUsage(pendingMessages);
return pendingUsage ? addUsage(backendUsage, pendingUsage) : backendUsage;
}
return accumulateUsage(messages);
}
/**
* Format a token count for display: 1234 -> "1,234", 12345 -> "12.3K"
*/
export function formatTokenCount(count: number): string {
if (count < 10_000) {
return count.toLocaleString();
}
return `${(count / 1000).toFixed(1)}K`;
}