Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions src/supervisor/agents/claude/canonicalMapping/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import type { RuntimeEvent, ToolCallProgress, TurnState } from "@/shared/contracts";
import { readFileChangePath, readStringField } from "../../fileChangeSummary";
import type { ClaudeMapperState } from "../sdkCanonicalMappingState";
import { applyActiveGoalMessage, completeActiveGoalEvents, isActiveGoalMessage } from "./goal";
import {
accumulateActiveGoalAssistantSpend,
applyActiveGoalMessage,
completeActiveGoalEvents,
isActiveGoalMessage,
} from "./goal";
import {
extractCompletedStringFields,
extractText,
Expand Down Expand Up @@ -376,15 +381,21 @@ function mapClaudeSdkMessageInner(
state.usageScope && readClaudeAssistantSpendTokens(message) !== undefined
? createClaudeUsageSpentEvent(state.threadId, message, state.usageScope.sample())
: undefined;
// The active goal's running total shares the exact same per-call spend —
// accumulated here so goal tokens and Profile tokens can never diverge.
const goalSpendEvent = accumulateActiveGoalAssistantSpend(state, message);
// Sub-agent (parent-attributed) whole messages must not touch the shared
// main-lane per-index maps — index 0 of a sub-agent message would collide
// with the main thread's streaming block at index 0. Emit self-contained,
// already-complete child items instead (tagParent attaches parentItemId).
if (readParentToolUseId(message)) {
const subAgentEvents = flushSubAgentAssistantMessage(message, state);
return usageSpentEvent ? [usageSpentEvent, ...subAgentEvents] : subAgentEvents;
return [usageSpentEvent, goalSpendEvent, ...subAgentEvents].filter(
(event): event is RuntimeEvent => event !== undefined,
);
}
if (usageSpentEvent) events.push(usageSpentEvent);
if (goalSpendEvent) events.push(goalSpendEvent);
const messageId = readClaudeAssistantMessageId(message.message);
const skipTextSnapshot = messageId ? state.streamedAssistantMessageIds.has(messageId) : false;
const content = (message.message as { content?: unknown }).content;
Expand Down Expand Up @@ -537,7 +548,7 @@ function mapClaudeSdkMessageInner(
const msg = extractResultErrorMessage(message) ?? "Claude turn failed.";
events.push({ type: "error", threadId: state.threadId, message: msg });
}
events.push(...completeActiveGoalEvents(state, message, stateValue));
events.push(...completeActiveGoalEvents(state, stateValue));
if (state.currentTurnId) {
events.push({
type: "turn.completed",
Expand Down
132 changes: 58 additions & 74 deletions src/supervisor/agents/claude/canonicalMapping/goal.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { SDKActiveGoalMessage, SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import type { SDKActiveGoalMessage, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
import type { RuntimeEvent, TurnState } from "@/shared/contracts";
import { readNonNegativeInteger } from "../../contextUsage";
import {
goalPayloadFromProviderState,
startGoalItemEvents,
Expand All @@ -9,7 +8,7 @@ import {
} from "../../goalRuntime";
import type { ClaudeMapperState } from "../sdkCanonicalMappingState";
import { newItemId } from "./helpers";
import { readClaudeResultUsage } from "./result";
import { readClaudeAssistantSpendTokens, readClaudeAssistantUsageSampleId } from "./usageSpent";

type ActiveGoalState = ClaudeMapperState & {
activeGoalItemId: string;
Expand All @@ -26,9 +25,8 @@ function hasActiveGoal(state: ClaudeMapperState): state is ActiveGoalState {
}

export function resetActiveGoalTokenAccounting(state: ClaudeMapperState): void {
delete state.activeGoalCompletedTurnTokensUsed;
delete state.activeGoalLiveApiTokensUsed;
delete state.activeGoalTaskTokensByKey;
delete state.activeGoalTokensUsed;
delete state.activeGoalUsageSampleIds;
}

export function clearActiveGoal(state: ClaudeMapperState): void {
Expand Down Expand Up @@ -75,10 +73,10 @@ export function applyActiveGoalMessage(

const objective = value.condition.trim();
if (!objective) return [];
state.activeGoalIterations = value.iterations;
if (typeof value.last_reason === "string" && value.last_reason.trim().length > 0) {
state.activeGoalLastReason = value.last_reason.trim();
}
const lastReason =
typeof value.last_reason === "string" && value.last_reason.trim().length > 0
? value.last_reason.trim()
: undefined;

if (!hasActiveGoal(state)) {
// A goal can be armed natively without a local `/goal` turn — most
Expand All @@ -89,6 +87,8 @@ export function applyActiveGoalMessage(
state.activeGoalObjective = objective;
state.activeGoalStartedAtMs = epochSecondsToMs(value.set_at) ?? Date.now();
resetActiveGoalTokenAccounting(state);
state.activeGoalIterations = value.iterations;
if (lastReason) state.activeGoalLastReason = lastReason;
if (!hasActiveGoal(state)) return []; // unreachable; re-narrows after mutation
return startGoalItemEvents(
state.threadId,
Expand All @@ -97,8 +97,17 @@ export function applyActiveGoalMessage(
);
}

// A new `/goal` replacing the old one mid-session also lands here.
state.activeGoalObjective = objective;
// A new `/goal` replacing the old one mid-session also lands here. A changed
// condition is a NEW goal: restart its clock and token accounting so the
// dock never carries the previous goal's spend into the replacement.
if (state.activeGoalObjective !== objective) {
state.activeGoalObjective = objective;
state.activeGoalStartedAtMs = epochSecondsToMs(value.set_at) ?? Date.now();
resetActiveGoalTokenAccounting(state);
delete state.activeGoalLastReason;
}
state.activeGoalIterations = value.iterations;
if (lastReason) state.activeGoalLastReason = lastReason;
return [activeGoalUpdatedEvent(state)];
}

Expand All @@ -112,22 +121,22 @@ function completeGoalFromEvaluatorVerdict(state: ClaudeMapperState): RuntimeEven

export function completeActiveGoalEvents(
state: ClaudeMapperState,
message: Extract<SDKMessage, { type: "result" }>,
turnState: TurnState,
): RuntimeEvent[] {
if (!hasActiveGoal(state)) return [];

const usage = readClaudeResultUsage(message);
if (usage !== undefined) {
state.activeGoalCompletedTurnTokensUsed =
(state.activeGoalCompletedTurnTokensUsed ?? 0) + usage;
}

// Goal token spend is NOT read from the turn `result`: the CLI reports
// `result.usage` as a session-cumulative counter (all models, all
// sidechains, since session start), so adding it per turn would count
// pre-goal spend and multiply-count later turns. Per-call assistant-message
// spend is accumulated live by accumulateActiveGoalAssistantSpend instead;
// here we only roll the aggregate/time snapshot forward.
//
// While the native Stop-hook evaluator is live, a turn `result` is not a
// goal outcome — the evaluator keeps starting turns until the condition is
// met and reports that via `active_goal: null`. Only roll the usage/time
// counters forward here. Without native goal frames (older CLI), fall back
// to treating a clean turn end as completion so the dock never sticks.
// met and reports that via `active_goal: null`. Without native goal frames
// (older CLI), fall back to treating a clean turn end as completion so the
// dock never sticks.
if (turnState === "interrupted" || state.sawActiveGoalMessage) {
return [activeGoalUpdatedEvent(state)];
}
Expand Down Expand Up @@ -178,10 +187,13 @@ function hasLiveSubAgentTaskEntries(state: ClaudeMapperState): boolean {
*/
function activeGoalProviderState(state: ActiveGoalState): ProviderGoalState {
const nowMs = Date.now();
const tokensUsed = activeGoalAggregateTokens(state);
return {
objective: state.activeGoalObjective,
...(tokensUsed !== undefined ? { tokensUsed } : {}),
// Always carry the counter (0 before the first spend): the renderer merges
// goal payloads shallowly, so omitting the field would leave a replaced
// goal's total on the dock after resetActiveGoalTokenAccounting. The dock
// hides a zero total (it only renders tokensUsed > 0).
tokensUsed: state.activeGoalTokensUsed ?? 0,
timeUsedSeconds: Math.max(0, Math.round((nowMs - state.activeGoalStartedAtMs) / 1000)),
...(state.activeGoalIterations !== undefined ? { iterations: state.activeGoalIterations } : {}),
...(state.activeGoalLastReason ? { lastReason: state.activeGoalLastReason } : {}),
Expand All @@ -201,67 +213,39 @@ function activeGoalUpdatedEvent(state: ActiveGoalState): RuntimeEvent {
};
}

export function emitActiveGoalTokenUpdate(
/**
* Fold one assistant API message's per-call spend into the active goal's
* running total. Called for every assistant message — main thread and
* subagent sidechain alike — at the same point the Profile usage ledger's
* `usage.spent` event is created, so the goal dock and the Profile token
* stats share one exact spend definition. Emits a dock update on growth.
*/
export function accumulateActiveGoalAssistantSpend(
state: ClaudeMapperState,
tokensUsed: number,
message: SDKAssistantMessage,
): RuntimeEvent | undefined {
if (!hasActiveGoal(state)) return undefined;
state.activeGoalLiveApiTokensUsed = Math.max(state.activeGoalLiveApiTokensUsed ?? 0, tokensUsed);
return emitActiveGoalAggregateTokenUpdate(state);
const spend = readClaudeAssistantSpendTokens(message);
if (spend === undefined || spend <= 0) return undefined;
const sampleId = readClaudeAssistantUsageSampleId(message);
const sampleIds = (state.activeGoalUsageSampleIds ??= new Set<string>());
if (sampleIds.has(sampleId)) return undefined;
sampleIds.add(sampleId);
state.activeGoalTokensUsed = (state.activeGoalTokensUsed ?? 0) + spend;
return activeGoalUpdatedEvent(state);
}

function emitActiveGoalAggregateTokenUpdate(state: ClaudeMapperState): RuntimeEvent | undefined {
/**
* 15s goal-tracking poll tick: re-emit the current totals so the dock's
* elapsed-time display rolls forward even while no assistant message lands.
*/
export function emitActiveGoalTick(state: ClaudeMapperState): RuntimeEvent | undefined {
if (!hasActiveGoal(state)) return undefined;
if (activeGoalAggregateTokens(state) === undefined) return undefined;
return activeGoalUpdatedEvent(state);
}

function activeGoalAggregateTokens(state: ClaudeMapperState): number | undefined {
const baseTokens = Math.max(
state.activeGoalCompletedTurnTokensUsed ?? 0,
state.activeGoalLiveApiTokensUsed ?? 0,
);
const taskTokens = sumActiveGoalTaskTokens(state);
const totalTokens = baseTokens + taskTokens;
return totalTokens > 0 ? totalTokens : undefined;
}

function sumActiveGoalTaskTokens(state: ClaudeMapperState): number {
let total = 0;
for (const tokens of state.activeGoalTaskTokensByKey?.values() ?? []) total += tokens;
return total;
}

function epochSecondsToMs(value: unknown): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined;
// Guard against the field ever arriving in milliseconds.
return value > 1_000_000_000_000 ? value : value * 1000;
}

export function emitActiveGoalTaskUsageUpdate(
state: ClaudeMapperState,
message: { task_id?: unknown; tool_use_id?: unknown },
usage: { total_tokens?: number; tool_uses?: number; duration_ms?: number } | undefined,
): RuntimeEvent | undefined {
if (!hasActiveGoal(state)) return undefined;
const totalTokens = readNonNegativeInteger(usage?.total_tokens);
if (totalTokens === undefined || totalTokens <= 0) return undefined;

const key = activeGoalTaskUsageKey(message);
if (!key) return undefined;

const taskTokens = (state.activeGoalTaskTokensByKey ??= new Map<string, number>());
const previous = taskTokens.get(key) ?? 0;
if (totalTokens <= previous) return undefined;
taskTokens.set(key, totalTokens);
return emitActiveGoalAggregateTokenUpdate(state);
}

function activeGoalTaskUsageKey(message: {
task_id?: unknown;
tool_use_id?: unknown;
}): string | undefined {
const taskId = typeof message.task_id === "string" ? message.task_id : undefined;
const toolUseId = typeof message.tool_use_id === "string" ? message.tool_use_id : undefined;
return taskId ?? toolUseId;
}
22 changes: 6 additions & 16 deletions src/supervisor/agents/claude/canonicalMapping/result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,30 +59,20 @@ export function extractResultErrorMessage(message: SDKMessage): string | undefin
return undefined;
}

export function readClaudeResultUsage(
message: Extract<SDKMessage, { type: "result" }>,
): number | undefined {
const usage = (message as { usage?: unknown }).usage;
return readClaudeUsageSpendTokens(usage, { fallbackToTotalTokens: true });
}

/**
* One API call's token spend: input + output + cache creation + cache read.
* Used for per-assistant-message usage — never for the turn `result`, whose
* usage the CLI reports as a session-cumulative counter.
*/
export function readClaudeApiUsageSpendTokens(usage: unknown): number | undefined {
return readClaudeUsageSpendTokens(usage, { fallbackToTotalTokens: false });
}

function readClaudeUsageSpendTokens(
usage: unknown,
options: { fallbackToTotalTokens: boolean },
): number | undefined {
if (!usage || typeof usage !== "object") return undefined;
const record = usage as Record<string, unknown>;
const input = readNonNegativeInteger(record.input_tokens) ?? 0;
const output = readNonNegativeInteger(record.output_tokens) ?? 0;
const cacheCreation = readNonNegativeInteger(record.cache_creation_input_tokens) ?? 0;
const cacheRead = readNonNegativeInteger(record.cache_read_input_tokens) ?? 0;
const sum = input + output + cacheCreation + cacheRead;
if (sum > 0) return sum;
return options.fallbackToTotalTokens ? readNonNegativeInteger(record.total_tokens) : undefined;
return sum > 0 ? sum : undefined;
}

export function mapResultState(message: Extract<SDKMessage, { type: "result" }>): TurnState {
Expand Down
10 changes: 5 additions & 5 deletions src/supervisor/agents/claude/canonicalMapping/taskLifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import type { RuntimeEvent, ToolCallProgress } from "@/shared/contracts";
import type { ClaudeMapperState, ToolItemState } from "../sdkCanonicalMappingState";
import { emitActiveGoalTaskUsageUpdate } from "./goal";
import { classifyToolItemType, isSubAgentToolName } from "./toolClassification";
import { syncSubAgentModelProgress } from "./toolItems";
import { toolPayload } from "./toolPayload";
Expand Down Expand Up @@ -66,13 +65,16 @@ function mergeTaskProgress(
* Absorb a `task_started` / `task_progress` / `task_notification` system
* message into the parent Task tool_call's progress field. Lets a collapsed
* sub-agent row show its current step without expanding to read the children.
*
* The per-task `usage` is reflected on the tool progress only — it must NOT
* feed goal/session token totals: sidechain spend is already counted exactly
* once from the sub-agent's own assistant messages, and task usage is a
* cumulative-per-task counter that would double-count it.
*/
export function applyTaskLifecycle(message: SDKMessage, state: ClaudeMapperState): RuntimeEvent[] {
const events: RuntimeEvent[] = [];
const obj = message as TaskLifecycleMessage;
const usage = readTaskUsage(obj);
const goalUsage = emitActiveGoalTaskUsageUpdate(state, obj, usage);
if (goalUsage) events.push(goalUsage);

const toolUseId = typeof obj.tool_use_id === "string" ? obj.tool_use_id : undefined;
if (!toolUseId) return events;
Expand Down Expand Up @@ -180,8 +182,6 @@ export function applyTaskNotification(

const events: RuntimeEvent[] = [];
const usage = readTaskUsage(obj);
const goalUsage = emitActiveGoalTaskUsageUpdate(state, obj, usage);
if (goalUsage) events.push(goalUsage);

const tool = state.toolItemsById.get(registeredToolUseId);
if (!tool) {
Expand Down
24 changes: 14 additions & 10 deletions src/supervisor/agents/claude/canonicalMapping/usageSpent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,25 +53,29 @@ export function readClaudeAssistantSpendTokens(message: SDKAssistantMessage): nu
return readClaudeApiUsageSpendTokens(message.message?.usage);
}

export function createClaudeUsageSpentEvent(
threadId: string,
message: SDKAssistantMessage,
meta: { scopeId: string; epoch: number; fresh?: boolean },
): RuntimeEvent | undefined {
const counter = readClaudeAssistantSpendTokens(message);
if (counter === undefined) return undefined;
export function readClaudeAssistantUsageSampleId(message: SDKAssistantMessage): string {
const apiMessageId = readClaudeAssistantMessageId(message.message);
const requestId =
typeof message.request_id === "string" && message.request_id.length > 0
? message.request_id
: undefined;
// Stable per API message (`msg_…:req_…`) so replays dedup exactly once in
// the ledger; fall back to the envelope uuid when the payload has no id.
const sampleId = apiMessageId
return apiMessageId
? requestId
? `${apiMessageId}:${requestId}`
: apiMessageId
: `uuid:${message.uuid}`;
}

export function createClaudeUsageSpentEvent(
threadId: string,
message: SDKAssistantMessage,
meta: { scopeId: string; epoch: number; fresh?: boolean },
): RuntimeEvent | undefined {
const counter = readClaudeAssistantSpendTokens(message);
if (counter === undefined) return undefined;
// Stable per API message (`msg_…:req_…`) so replays dedup exactly once in
// the ledger; fall back to the envelope uuid when the payload has no id.
const sampleId = readClaudeAssistantUsageSampleId(message);
const model = typeof message.message?.model === "string" ? message.message.model : undefined;
return {
type: "usage.spent",
Expand Down
Loading