From 100164dc503f080bcc20c28da66b3722afd9b9ab Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 27 Jun 2026 19:37:39 -0700 Subject: [PATCH] FN-7150: show ephemeral agent token counts Surface task-derived token usage for ephemeral agents in dashboard agent views. - Attribute task token totals across assigned, source, and checkout agent links. - Backfill zero-valued agent list token totals from linked task usage. - Allow Agent Detail token usage windows for ephemeral/task-worker agents and cover the routes with tests. - Document the dashboard behavior and add a patch changeset. Files changed: .changeset/fn-7150-ephemeral-agent-token-counts.md | 7 ++ docs/dashboard-guide.md | 1 + .../core/src/__tests__/agent-token-usage.test.ts | 99 ++++++++++++++++++++-- packages/core/src/__tests__/team-analytics.test.ts | 48 +++++++++++ packages/core/src/agent-token-usage.ts | 83 +++++++++++++++++- packages/core/src/index.ts | 4 +- .../__tests__/AgentTokenStatsPanel.test.tsx | 21 +++++ .../src/__tests__/routes-agent-token-usage.test.ts | 20 ++++- .../dashboard/src/__tests__/routes-agents.test.ts | 87 +++++++++++++++++++ .../src/routes/register-agent-core-routes.ts | 38 ++++++++- .../src/routes/register-agent-runtime-routes.ts | 5 +- 11 files changed, 395 insertions(+), 18 deletions(-) Fusion-Task-Id: FN-7150 Fusion-Task-Lineage: ce2661ce-5ba5-43b0-aa5c-7fddee90a915 Co-authored-by: Fusion (runfusion.ai) --- .../fn-7150-ephemeral-agent-token-counts.md | 7 ++ docs/dashboard-guide.md | 1 + .../src/__tests__/agent-token-usage.test.ts | 99 ++++++++++++++++++- .../core/src/__tests__/team-analytics.test.ts | 48 +++++++++ packages/core/src/agent-token-usage.ts | 83 +++++++++++++++- packages/core/src/index.ts | 4 +- .../__tests__/AgentTokenStatsPanel.test.tsx | 21 ++++ .../routes-agent-token-usage.test.ts | 20 +++- .../src/__tests__/routes-agents.test.ts | 87 ++++++++++++++++ .../src/routes/register-agent-core-routes.ts | 38 ++++++- .../routes/register-agent-runtime-routes.ts | 5 +- 11 files changed, 395 insertions(+), 18 deletions(-) create mode 100644 .changeset/fn-7150-ephemeral-agent-token-counts.md diff --git a/.changeset/fn-7150-ephemeral-agent-token-counts.md b/.changeset/fn-7150-ephemeral-agent-token-counts.md new file mode 100644 index 0000000000..2d32f74896 --- /dev/null +++ b/.changeset/fn-7150-ephemeral-agent-token-counts.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Ephemeral/task-worker agents now show their token usage on the dashboard. +category: fix +dev: Derives zero/absent per-agent dashboard totals from task token usage and allows ephemeral Agent Detail token windows. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index c0fb04838d..2cc4cd9bdb 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -712,6 +712,7 @@ Navigation: Features: - Switch between **List**, **Board**, and **Org chart** layouts - Filter by role/state, include/exclude system agents, and inspect health/status +- **Token Usage by Agent** includes task-derived token counts for ephemeral/task-worker system agents when system agents are shown, matching Agent detail and Command Center Team token surfaces. - Agent list cards show the configured **Model** or plugin **Runtime** for each agent, falling back to **Auto** when no override is set - Agent list, live-agent, and detail task badges show the linked task ID with its current column when the task is non-terminal (for example `FN-6902 · Triage` or `FN-6902 · In Progress`). Terminal linked tasks are omitted, and unresolved column lookups render an explicit `Unresolved task` suffix so missing or deleted task links are not mistaken for healthy parked work. diff --git a/packages/core/src/__tests__/agent-token-usage.test.ts b/packages/core/src/__tests__/agent-token-usage.test.ts index dff12a5b5e..6527d4477b 100644 --- a/packages/core/src/__tests__/agent-token-usage.test.ts +++ b/packages/core/src/__tests__/agent-token-usage.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, beforeAll, afterAll } from "vitest"; import { AgentStore } from "../agent-store.js"; -import { aggregateAgentTokenUsage } from "../agent-token-usage.js"; +import { aggregateAgentTokenUsage, aggregateTaskTokenTotalsByAgentLink } from "../agent-token-usage.js"; import { createSharedTaskStoreTestHarness } from "./store-test-helpers.js"; describe("aggregateAgentTokenUsage", () => { @@ -25,10 +25,99 @@ describe("aggregateAgentTokenUsage", () => { expect(result).toBeNull(); }); - it("returns null for ephemeral agents", async () => { - const ephemeral = await agentStore.createAgent({ name: "temp", role: "executor", reportsTo: "FN-1", metadata: { type: "spawned" } }); - const result = await aggregateAgentTokenUsage({ taskStore: harness.store(), agentStore, agentId: ephemeral.id }); - expect(result).toBeNull(); + it("returns zero windows for an ephemeral task-worker with no token-bearing tasks", async () => { + const ephemeral = await agentStore.createAgent({ name: "executor-FN-0000", role: "executor", metadata: { agentKind: "task-worker" } }); + await harness.store().createTask({ + description: "task without token usage", + assignedAgentId: ephemeral.id, + }); + + const result = await aggregateAgentTokenUsage({ + taskStore: harness.store(), + agentStore, + agentId: ephemeral.id, + now: new Date("2026-05-13T12:00:00.000Z"), + }); + + expect(result).not.toBeNull(); + expect(result?.allTime).toMatchObject({ totalInputTokens: 0, totalCachedTokens: 0, totalCacheWriteTokens: 0, totalOutputTokens: 0, nTasks: 0 }); + expect(result?.last24h).toMatchObject({ totalInputTokens: 0, totalCachedTokens: 0, totalCacheWriteTokens: 0, totalOutputTokens: 0, nTasks: 0 }); + }); + + it("aggregates task-derived usage for ephemeral task-worker agents", async () => { + const ephemeral = await agentStore.createAgent({ name: "executor-FN-1234", role: "executor", metadata: { agentKind: "task-worker" } }); + await harness.store().createTask({ + description: "ephemeral worker task", + assignedAgentId: ephemeral.id, + tokenUsage: { + inputTokens: 75, + outputTokens: 25, + cachedTokens: 10, + cacheWriteTokens: 5, + totalTokens: 115, + firstUsedAt: "2026-05-13T09:00:00.000Z", + lastUsedAt: "2026-05-13T11:00:00.000Z", + }, + }); + + const result = await aggregateAgentTokenUsage({ + taskStore: harness.store(), + agentStore, + agentId: ephemeral.id, + now: new Date("2026-05-13T12:00:00.000Z"), + }); + + expect(result).not.toBeNull(); + expect(result?.allTime).toMatchObject({ totalInputTokens: 75, totalCachedTokens: 10, totalCacheWriteTokens: 5, totalOutputTokens: 25, nTasks: 1 }); + expect(result?.last24h).toMatchObject({ totalInputTokens: 75, totalCachedTokens: 10, totalCacheWriteTokens: 5, totalOutputTokens: 25, nTasks: 1 }); + }); + + it("aggregates task-derived totals by assigned, source, and checkout agent links without double-counting same-agent links", async () => { + const agent = await agentStore.createAgent({ name: "executor-FN-links", role: "executor", metadata: { agentKind: "task-worker" } }); + await harness.store().createTask({ + description: "source-linked token usage", + source: { sourceType: "agent_heartbeat", sourceAgentId: agent.id }, + tokenUsage: { + inputTokens: 30, + outputTokens: 7, + cachedTokens: 3, + cacheWriteTokens: 1, + totalTokens: 41, + firstUsedAt: "2026-05-13T09:00:00.000Z", + lastUsedAt: "2026-05-13T11:00:00.000Z", + }, + }); + const checkedTask = await harness.store().createTask({ + description: "checkout-linked token usage", + tokenUsage: { + inputTokens: 20, + outputTokens: 5, + cachedTokens: 2, + cacheWriteTokens: 0, + totalTokens: 27, + firstUsedAt: "2026-05-13T09:00:00.000Z", + lastUsedAt: "2026-05-13T11:00:00.000Z", + }, + }); + await harness.store().updateTask(checkedTask.id, { checkedOutBy: agent.id }); + await harness.store().createTask({ + description: "same agent appears in multiple task links", + assignedAgentId: agent.id, + source: { sourceType: "agent_heartbeat", sourceAgentId: agent.id }, + tokenUsage: { + inputTokens: 10, + outputTokens: 4, + cachedTokens: 0, + cacheWriteTokens: 0, + totalTokens: 14, + firstUsedAt: "2026-05-13T09:00:00.000Z", + lastUsedAt: "2026-05-13T11:00:00.000Z", + }, + }); + + const totals = aggregateTaskTokenTotalsByAgentLink(harness.store().getDatabase()).get(agent.id); + + expect(totals).toMatchObject({ inputTokens: 60, cachedTokens: 5, cacheWriteTokens: 1, outputTokens: 16, totalTokens: 82, nTasks: 3 }); }); it("aggregates usage across windows", async () => { diff --git a/packages/core/src/__tests__/team-analytics.test.ts b/packages/core/src/__tests__/team-analytics.test.ts index 666f344f2a..715691c4f8 100644 --- a/packages/core/src/__tests__/team-analytics.test.ts +++ b/packages/core/src/__tests__/team-analytics.test.ts @@ -206,6 +206,54 @@ describe("team-analytics", () => { expect(result.agents[0].tasksInProgress).toBe(1); }); + it("includes ephemeral executor agents in per-agent token totals", () => { + insertAgent(db, "agent-durable", "Durable", "executor", "idle"); + insertAgent(db, "agent-ephemeral", "executor-FN-1234", "executor", "running"); + insertTask(db, { + id: "durable-tokens", + agentId: "agent-durable", + inputTokens: 40, + outputTokens: 10, + cachedTokens: 5, + cacheWriteTokens: 1, + totalTokens: 56, + tokenUsageLastUsedAt: "2026-03-02T00:00:00.000Z", + }); + insertTask(db, { + id: "ephemeral-tokens", + agentId: "agent-ephemeral", + inputTokens: 120, + outputTokens: 45, + cachedTokens: 10, + cacheWriteTokens: 5, + totalTokens: 180, + tokenUsageLastUsedAt: "2026-03-02T00:00:00.000Z", + }); + insertTask(db, { + id: "ephemeral-no-usage", + agentId: "agent-ephemeral", + tokenUsageLastUsedAt: null, + }); + + const result = aggregateTeamAnalytics(db, {}); + const byAgent = new Map(result.agents.map((agent) => [agent.agentId, agent])); + + expect(byAgent.get("agent-ephemeral")).toMatchObject({ + agentName: "executor-FN-1234", + role: "executor", + state: "running", + }); + expect(byAgent.get("agent-ephemeral")?.tokens).toMatchObject({ + inputTokens: 120, + outputTokens: 45, + cachedTokens: 10, + cacheWriteTokens: 5, + totalTokens: 180, + nTasks: 1, + }); + expect(result.totals.tokens.totalTokens).toBe(236); + }); + it("keeps a safe row for a task whose agent row was deleted", () => { insertTask(db, { id: "orphan", diff --git a/packages/core/src/agent-token-usage.ts b/packages/core/src/agent-token-usage.ts index 4496f76815..b979b17e1b 100644 --- a/packages/core/src/agent-token-usage.ts +++ b/packages/core/src/agent-token-usage.ts @@ -1,6 +1,7 @@ import type { AgentStore } from "./agent-store.js"; +import type { Database } from "./db.js"; import type { TaskStore } from "./store.js"; -import { isEphemeralAgent, type AgentRole } from "./types.js"; +import type { AgentRole } from "./types.js"; export interface AgentTokenUsageWindowSummary { totalInputTokens: number; @@ -19,6 +20,80 @@ export interface AgentTokenUsageSummary { allTime: AgentTokenUsageWindowSummary; } +export interface AgentTaskTokenTotals { + inputTokens: number; + cachedTokens: number; + cacheWriteTokens: number; + outputTokens: number; + totalTokens: number; + nTasks: number; +} + +interface TaskTokenLinkRow { + taskId: string; + assignedAgentId: string | null; + sourceAgentId: string | null; + checkedOutBy: string | null; + inputTokens: number | null; + cachedTokens: number | null; + cacheWriteTokens: number | null; + outputTokens: number | null; + totalTokens: number | null; +} + +export function aggregateTaskTokenTotalsByAgentLink(db: Database): Map { + /* + FNXC:AgentTokenUsage 2026-06-27-23:06: + List-row token totals must use the same assigned/source/checkout attribution as Agent Detail so ephemeral task-worker agents do not report zero when they only sourced or checked out a task. + */ + const rows = db.prepare(` + SELECT + id AS taskId, + assignedAgentId, + sourceAgentId, + checkedOutBy, + tokenUsageInputTokens AS inputTokens, + tokenUsageCachedTokens AS cachedTokens, + tokenUsageCacheWriteTokens AS cacheWriteTokens, + tokenUsageOutputTokens AS outputTokens, + tokenUsageTotalTokens AS totalTokens + FROM tasks + WHERE tokenUsageInputTokens IS NOT NULL + OR tokenUsageCachedTokens IS NOT NULL + OR tokenUsageCacheWriteTokens IS NOT NULL + OR tokenUsageOutputTokens IS NOT NULL + OR tokenUsageTotalTokens IS NOT NULL + `).all() as TaskTokenLinkRow[]; + + const totalsByAgentId = new Map(); + for (const row of rows) { + const agentIds = new Set([row.assignedAgentId, row.sourceAgentId, row.checkedOutBy].filter((value): value is string => Boolean(value))); + for (const agentId of agentIds) { + const existing = totalsByAgentId.get(agentId) ?? createTaskTokenTotals(); + existing.inputTokens += row.inputTokens ?? 0; + existing.cachedTokens += row.cachedTokens ?? 0; + existing.cacheWriteTokens += row.cacheWriteTokens ?? 0; + existing.outputTokens += row.outputTokens ?? 0; + existing.totalTokens += row.totalTokens ?? (row.inputTokens ?? 0) + (row.cachedTokens ?? 0) + (row.cacheWriteTokens ?? 0) + (row.outputTokens ?? 0); + existing.nTasks += 1; + totalsByAgentId.set(agentId, existing); + } + } + + return totalsByAgentId; +} + +function createTaskTokenTotals(): AgentTaskTokenTotals { + return { + inputTokens: 0, + cachedTokens: 0, + cacheWriteTokens: 0, + outputTokens: 0, + totalTokens: 0, + nTasks: 0, + }; +} + export async function aggregateAgentTokenUsage({ taskStore, agentStore, @@ -31,10 +106,14 @@ export async function aggregateAgentTokenUsage({ now?: Date; }): Promise { const agent = await agentStore.getAgent(agentId); - if (!agent || isEphemeralAgent(agent)) { + if (!agent) { return null; } + /* + FNXC:AgentTokenUsage 2026-06-27-19:10: + Ephemeral/task-worker agents must surface task-derived token usage because their cumulative agent token fields are never accumulated by the durable-agent heartbeat path. + */ const tasks = await taskStore.listTasks({ slim: true, includeArchived: true }); const nowMs = now.getTime(); const last24hMs = nowMs - (24 * 60 * 60 * 1000); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c745615a06..da3b42c7b0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -577,8 +577,8 @@ export { } from "./duplicate-intake.js"; export { computeRetrySummary, RETRY_STORM_WARNING_RATIO } from "./retry-summary.js"; export { RetryStormError, serializeRetryStormError } from "./retry-storm-error.js"; -export { aggregateAgentTokenUsage } from "./agent-token-usage.js"; -export type { AgentTokenUsageSummary, AgentTokenUsageWindowSummary } from "./agent-token-usage.js"; +export { aggregateAgentTokenUsage, aggregateTaskTokenTotalsByAgentLink } from "./agent-token-usage.js"; +export type { AgentTaskTokenTotals, AgentTokenUsageSummary, AgentTokenUsageWindowSummary } from "./agent-token-usage.js"; export { emitUsageEvent, queryUsageEvents, diff --git a/packages/dashboard/app/components/__tests__/AgentTokenStatsPanel.test.tsx b/packages/dashboard/app/components/__tests__/AgentTokenStatsPanel.test.tsx index edbae893dd..e8ce9ff50e 100644 --- a/packages/dashboard/app/components/__tests__/AgentTokenStatsPanel.test.tsx +++ b/packages/dashboard/app/components/__tests__/AgentTokenStatsPanel.test.tsx @@ -52,6 +52,27 @@ describe("AgentTokenStatsPanel", () => { expect(within(rows[2]).getByText("Beta")).toBeInTheDocument(); }); + it("renders non-zero task-derived totals for an ephemeral agent row", () => { + render( + , + ); + + const row = screen.getByRole("row", { name: /executor-FN-1234/i }); + expect(within(row).getByText("120")).toBeInTheDocument(); + expect(within(row).getByText("45")).toBeInTheDocument(); + expect(within(row).getByText("165")).toBeInTheDocument(); + }); + it("treats missing token fields as zero and shows empty state when there is no usage", () => { render( { expect(res.status).toBe(404); }); - it("returns 400 for ephemeral agents", async () => { + it("returns summary for ephemeral agents", async () => { + mockGetAgent.mockResolvedValueOnce({ id: "executor-FN-1234", role: "executor", name: "executor-FN-1234", metadata: { agentKind: "task-worker" } }); mockIsEphemeralAgent.mockReturnValueOnce(true); - const res = await get(app, "/api/agents/agent-001/token-usage"); - expect(res.status).toBe(400); - expect((res.body as any).error).toContain("ephemeral"); + mockAggregateAgentTokenUsage.mockResolvedValueOnce({ + agentId: "executor-FN-1234", + role: "executor", + last24h: { totalInputTokens: 120, totalCachedTokens: 20, totalCacheWriteTokens: 5, totalOutputTokens: 40, nTasks: 1, hitRatio: 20 / 140 }, + last7d: { totalInputTokens: 120, totalCachedTokens: 20, totalCacheWriteTokens: 5, totalOutputTokens: 40, nTasks: 1, hitRatio: 20 / 140 }, + allTime: { totalInputTokens: 120, totalCachedTokens: 20, totalCacheWriteTokens: 5, totalOutputTokens: 40, nTasks: 1, hitRatio: 20 / 140 }, + }); + + const res = await get(app, "/api/agents/executor-FN-1234/token-usage"); + + expect(res.status).toBe(200); + expect((res.body as any).agentId).toBe("executor-FN-1234"); + expect((res.body as any).allTime.totalInputTokens).toBe(120); + expect(mockAggregateAgentTokenUsage).toHaveBeenCalledWith(expect.objectContaining({ agentId: "executor-FN-1234" })); }); }); diff --git a/packages/dashboard/src/__tests__/routes-agents.test.ts b/packages/dashboard/src/__tests__/routes-agents.test.ts index 58a7cdbb85..b0afc892bb 100644 --- a/packages/dashboard/src/__tests__/routes-agents.test.ts +++ b/packages/dashboard/src/__tests__/routes-agents.test.ts @@ -2892,6 +2892,93 @@ describe("Agent stale task-link sanitization", () => { expect(listed.pendingApprovalCount).toBe(0); }); + it("GET /api/agents derives zero agent token totals from assigned, source, and checkout task usage without overwriting durable totals", async () => { + const store = new CoreTaskStore(tempDir); + await store.init(); + const { AgentStore } = await import("@fusion/core"); + const agentStore = new AgentStore({ rootDir: fusionDir }); + await agentStore.init(); + const durable = await agentStore.createAgent({ + name: "Durable Tokens", + role: "executor", + }); + await agentStore.updateAgent(durable.id, { + totalInputTokens: 900, + totalOutputTokens: 100, + }); + const ephemeral = await agentStore.createAgent({ + name: "executor-FN-1234", + role: "executor", + metadata: { agentKind: "task-worker" }, + }); + await store.createTask({ + description: "durable task should not replace stored cumulative totals", + assignedAgentId: durable.id, + tokenUsage: { + inputTokens: 1, + outputTokens: 2, + cachedTokens: 0, + cacheWriteTokens: 0, + totalTokens: 3, + firstUsedAt: "2026-06-27T18:00:00.000Z", + lastUsedAt: "2026-06-27T18:05:00.000Z", + }, + }); + await store.createTask({ + description: "ephemeral task derives listing totals from assignment", + assignedAgentId: ephemeral.id, + tokenUsage: { + inputTokens: 120, + outputTokens: 45, + cachedTokens: 10, + cacheWriteTokens: 5, + totalTokens: 180, + firstUsedAt: "2026-06-27T18:00:00.000Z", + lastUsedAt: "2026-06-27T18:05:00.000Z", + }, + }); + await store.createTask({ + description: "ephemeral task derives listing totals from source link", + source: { sourceType: "agent_heartbeat", sourceAgentId: ephemeral.id }, + tokenUsage: { + inputTokens: 30, + outputTokens: 15, + cachedTokens: 0, + cacheWriteTokens: 0, + totalTokens: 45, + firstUsedAt: "2026-06-27T18:00:00.000Z", + lastUsedAt: "2026-06-27T18:05:00.000Z", + }, + }); + const checkedTask = await store.createTask({ + description: "ephemeral task derives listing totals from checkout link", + tokenUsage: { + inputTokens: 50, + outputTokens: 20, + cachedTokens: 0, + cacheWriteTokens: 0, + totalTokens: 70, + firstUsedAt: "2026-06-27T18:00:00.000Z", + lastUsedAt: "2026-06-27T18:05:00.000Z", + }, + }); + await store.updateTask(checkedTask.id, { checkedOutBy: ephemeral.id }); + + const app = express(); + app.use(express.json()); + app.use("/api", createApiRoutes(store)); + + const res = await GET(app, "/api/agents?includeEphemeral=true"); + + expect(res.status).toBe(200); + const agents = Array.isArray(res.body) ? res.body : [res.body]; + const listedDurable = agents.find((a: { id: string }) => a.id === durable.id); + const listedEphemeral = agents.find((a: { id: string }) => a.id === ephemeral.id); + expect(listedDurable).toMatchObject({ totalInputTokens: 900, totalOutputTokens: 100 }); + expect(listedEphemeral).toMatchObject({ totalInputTokens: 200, totalOutputTokens: 80 }); + store.close(); + }); + it("GET /api/agents pendingApprovalCount ignores approvals for missing agents", async () => { const app = buildAgentApp(); await createPendingApproval("agent-missing"); diff --git a/packages/dashboard/src/routes/register-agent-core-routes.ts b/packages/dashboard/src/routes/register-agent-core-routes.ts index fcb38ea373..52696dee18 100644 --- a/packages/dashboard/src/routes/register-agent-core-routes.ts +++ b/packages/dashboard/src/routes/register-agent-core-routes.ts @@ -5,6 +5,7 @@ import type { Agent, AgentCapability, AgentUpdateInput, TaskStore, AgentPermissi import { ApprovalRequestStore, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, + aggregateTaskTokenTotalsByAgentLink, getDefaultHeartbeatProcedurePath, isAgentPermissionPolicyPresetId, isEphemeralAgent, @@ -79,6 +80,40 @@ function isCompatibleDefaultHeartbeatPath(path: string | undefined, agent: Agent return new RegExp(`^\\.fusion/agents/[^/]+-${safeId}/HEARTBEAT\\.md$`).test(trimmed); } +function withTaskDerivedTokenTotals(agents: T[], scopedStore: TaskStore): T[] { + try { + const tokenTotalsByAgentId = aggregateTaskTokenTotalsByAgentLink(scopedStore.getDatabase()); + + return agents.map((agent) => { + const storedInputTokens = agent.totalInputTokens ?? 0; + const storedOutputTokens = agent.totalOutputTokens ?? 0; + if (storedInputTokens > 0 || storedOutputTokens > 0) { + return agent; + } + + const taskTotals = tokenTotalsByAgentId.get(agent.id); + if (!taskTotals || (taskTotals.inputTokens <= 0 && taskTotals.outputTokens <= 0)) { + return agent; + } + + /* + FNXC:Agents 2026-06-27-19:16: + The Agents listing derives zero/absent token totals from task links so ephemeral task-worker rows show real dashboard counts while durable agents keep their non-zero cumulative heartbeat totals. + + FNXC:Agents 2026-06-27-23:06: + Use the shared assigned/source/checkout token attribution so list rows match Agent Detail for task-worker agents that only sourced or checked out a task. + */ + return { + ...agent, + totalInputTokens: taskTotals.inputTokens, + totalOutputTokens: taskTotals.outputTokens, + }; + }); + } catch { + return agents; + } +} + function withPendingApprovalCounts(agents: T[], scopedStore: TaskStore): Array { try { const approvalStore = new ApprovalRequestStore(scopedStore.getDatabase()); @@ -125,7 +160,8 @@ export function registerAgentCoreListCreateRoutes(ctx: ApiRoutesContext, deps: A const agents = await agentStore.listAgents(filter as { state?: "idle" | "active" | "running" | "paused" | "error"; role?: AgentCapability; includeEphemeral?: boolean }); const sanitizedAgents = await sanitizeAgentTaskLinks(agents, scopedStore); - res.json(withPendingApprovalCounts(sanitizedAgents, scopedStore)); + const agentsWithTokenTotals = withTaskDerivedTokenTotals(sanitizedAgents, scopedStore); + res.json(withPendingApprovalCounts(agentsWithTokenTotals, scopedStore)); } catch (err: unknown) { if (err instanceof ApiError) { throw err; diff --git a/packages/dashboard/src/routes/register-agent-runtime-routes.ts b/packages/dashboard/src/routes/register-agent-runtime-routes.ts index 6e26e364e7..07fdab9da2 100644 --- a/packages/dashboard/src/routes/register-agent-runtime-routes.ts +++ b/packages/dashboard/src/routes/register-agent-runtime-routes.ts @@ -680,7 +680,7 @@ export function registerAgentRuntimeRoutes(ctx: ApiRoutesContext, deps: AgentRun router.get("/agents/:id/token-usage", async (req, res) => { try { const { store: scopedStore } = await getProjectContext(req); - const { AgentStore, aggregateAgentTokenUsage, isEphemeralAgent } = await import("@fusion/core"); + const { AgentStore, aggregateAgentTokenUsage } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); @@ -688,9 +688,6 @@ export function registerAgentRuntimeRoutes(ctx: ApiRoutesContext, deps: AgentRun if (!agent) { throw notFound("Agent not found"); } - if (isEphemeralAgent(agent)) { - throw badRequest("Token usage is not available for ephemeral agents"); - } const summary = await aggregateAgentTokenUsage({ taskStore: scopedStore,