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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-27 19:37:39 -07:00
parent 7ebf58ee3b
commit 100164dc50
11 changed files with 395 additions and 18 deletions

View File

@@ -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.

View File

@@ -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
<!-- FNXC:AgentTaskStateDrift 2026-06-27-16:46: Agent task badges include the linked task column so parked `triage`/`todo` ownership from the FN-7138 invariant is not misread as execution drift. -->
- 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.

View File

@@ -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 () => {

View File

@@ -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",

View File

@@ -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<string, AgentTaskTokenTotals> {
/*
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<string, AgentTaskTokenTotals>();
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<AgentTokenUsageSummary | null> {
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);

View File

@@ -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,

View File

@@ -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(
<AgentTokenStatsPanel
agents={[
makeAgent({
id: "agent-ephemeral",
name: "executor-FN-1234",
metadata: { agentKind: "task-worker" },
totalInputTokens: 120,
totalOutputTokens: 45,
}),
]}
/>,
);
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(
<AgentTokenStatsPanel

View File

@@ -86,10 +86,22 @@ describe("GET /api/agents/:id/token-usage", () => {
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" }));
});
});

View File

@@ -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");

View File

@@ -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<T extends Agent>(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<T extends Agent>(agents: T[], scopedStore: TaskStore): Array<T & { pendingApprovalCount: number }> {
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;

View File

@@ -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,