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:
7
.changeset/fn-7150-ephemeral-agent-token-counts.md
Normal file
7
.changeset/fn-7150-ephemeral-agent-token-counts.md
Normal 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.
|
||||||
@@ -712,6 +712,7 @@ Navigation:
|
|||||||
Features:
|
Features:
|
||||||
- Switch between **List**, **Board**, and **Org chart** layouts
|
- Switch between **List**, **Board**, and **Org chart** layouts
|
||||||
- Filter by role/state, include/exclude system agents, and inspect health/status
|
- 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 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. -->
|
<!-- 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.
|
- 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.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, beforeAll, afterAll } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, beforeAll, afterAll } from "vitest";
|
||||||
import { AgentStore } from "../agent-store.js";
|
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";
|
import { createSharedTaskStoreTestHarness } from "./store-test-helpers.js";
|
||||||
|
|
||||||
describe("aggregateAgentTokenUsage", () => {
|
describe("aggregateAgentTokenUsage", () => {
|
||||||
@@ -25,10 +25,99 @@ describe("aggregateAgentTokenUsage", () => {
|
|||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null for ephemeral agents", async () => {
|
it("returns zero windows for an ephemeral task-worker with no token-bearing tasks", async () => {
|
||||||
const ephemeral = await agentStore.createAgent({ name: "temp", role: "executor", reportsTo: "FN-1", metadata: { type: "spawned" } });
|
const ephemeral = await agentStore.createAgent({ name: "executor-FN-0000", role: "executor", metadata: { agentKind: "task-worker" } });
|
||||||
const result = await aggregateAgentTokenUsage({ taskStore: harness.store(), agentStore, agentId: ephemeral.id });
|
await harness.store().createTask({
|
||||||
expect(result).toBeNull();
|
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 () => {
|
it("aggregates usage across windows", async () => {
|
||||||
|
|||||||
@@ -206,6 +206,54 @@ describe("team-analytics", () => {
|
|||||||
expect(result.agents[0].tasksInProgress).toBe(1);
|
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", () => {
|
it("keeps a safe row for a task whose agent row was deleted", () => {
|
||||||
insertTask(db, {
|
insertTask(db, {
|
||||||
id: "orphan",
|
id: "orphan",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { AgentStore } from "./agent-store.js";
|
import type { AgentStore } from "./agent-store.js";
|
||||||
|
import type { Database } from "./db.js";
|
||||||
import type { TaskStore } from "./store.js";
|
import type { TaskStore } from "./store.js";
|
||||||
import { isEphemeralAgent, type AgentRole } from "./types.js";
|
import type { AgentRole } from "./types.js";
|
||||||
|
|
||||||
export interface AgentTokenUsageWindowSummary {
|
export interface AgentTokenUsageWindowSummary {
|
||||||
totalInputTokens: number;
|
totalInputTokens: number;
|
||||||
@@ -19,6 +20,80 @@ export interface AgentTokenUsageSummary {
|
|||||||
allTime: AgentTokenUsageWindowSummary;
|
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({
|
export async function aggregateAgentTokenUsage({
|
||||||
taskStore,
|
taskStore,
|
||||||
agentStore,
|
agentStore,
|
||||||
@@ -31,10 +106,14 @@ export async function aggregateAgentTokenUsage({
|
|||||||
now?: Date;
|
now?: Date;
|
||||||
}): Promise<AgentTokenUsageSummary | null> {
|
}): Promise<AgentTokenUsageSummary | null> {
|
||||||
const agent = await agentStore.getAgent(agentId);
|
const agent = await agentStore.getAgent(agentId);
|
||||||
if (!agent || isEphemeralAgent(agent)) {
|
if (!agent) {
|
||||||
return null;
|
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 tasks = await taskStore.listTasks({ slim: true, includeArchived: true });
|
||||||
const nowMs = now.getTime();
|
const nowMs = now.getTime();
|
||||||
const last24hMs = nowMs - (24 * 60 * 60 * 1000);
|
const last24hMs = nowMs - (24 * 60 * 60 * 1000);
|
||||||
|
|||||||
@@ -577,8 +577,8 @@ export {
|
|||||||
} from "./duplicate-intake.js";
|
} from "./duplicate-intake.js";
|
||||||
export { computeRetrySummary, RETRY_STORM_WARNING_RATIO } from "./retry-summary.js";
|
export { computeRetrySummary, RETRY_STORM_WARNING_RATIO } from "./retry-summary.js";
|
||||||
export { RetryStormError, serializeRetryStormError } from "./retry-storm-error.js";
|
export { RetryStormError, serializeRetryStormError } from "./retry-storm-error.js";
|
||||||
export { aggregateAgentTokenUsage } from "./agent-token-usage.js";
|
export { aggregateAgentTokenUsage, aggregateTaskTokenTotalsByAgentLink } from "./agent-token-usage.js";
|
||||||
export type { AgentTokenUsageSummary, AgentTokenUsageWindowSummary } from "./agent-token-usage.js";
|
export type { AgentTaskTokenTotals, AgentTokenUsageSummary, AgentTokenUsageWindowSummary } from "./agent-token-usage.js";
|
||||||
export {
|
export {
|
||||||
emitUsageEvent,
|
emitUsageEvent,
|
||||||
queryUsageEvents,
|
queryUsageEvents,
|
||||||
|
|||||||
@@ -52,6 +52,27 @@ describe("AgentTokenStatsPanel", () => {
|
|||||||
expect(within(rows[2]).getByText("Beta")).toBeInTheDocument();
|
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", () => {
|
it("treats missing token fields as zero and shows empty state when there is no usage", () => {
|
||||||
render(
|
render(
|
||||||
<AgentTokenStatsPanel
|
<AgentTokenStatsPanel
|
||||||
|
|||||||
@@ -86,10 +86,22 @@ describe("GET /api/agents/:id/token-usage", () => {
|
|||||||
expect(res.status).toBe(404);
|
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);
|
mockIsEphemeralAgent.mockReturnValueOnce(true);
|
||||||
const res = await get(app, "/api/agents/agent-001/token-usage");
|
mockAggregateAgentTokenUsage.mockResolvedValueOnce({
|
||||||
expect(res.status).toBe(400);
|
agentId: "executor-FN-1234",
|
||||||
expect((res.body as any).error).toContain("ephemeral");
|
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" }));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2892,6 +2892,93 @@ describe("Agent stale task-link sanitization", () => {
|
|||||||
expect(listed.pendingApprovalCount).toBe(0);
|
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 () => {
|
it("GET /api/agents pendingApprovalCount ignores approvals for missing agents", async () => {
|
||||||
const app = buildAgentApp();
|
const app = buildAgentApp();
|
||||||
await createPendingApproval("agent-missing");
|
await createPendingApproval("agent-missing");
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { Agent, AgentCapability, AgentUpdateInput, TaskStore, AgentPermissi
|
|||||||
import {
|
import {
|
||||||
ApprovalRequestStore,
|
ApprovalRequestStore,
|
||||||
AGENT_PERMISSION_POLICY_ACTION_CATEGORIES,
|
AGENT_PERMISSION_POLICY_ACTION_CATEGORIES,
|
||||||
|
aggregateTaskTokenTotalsByAgentLink,
|
||||||
getDefaultHeartbeatProcedurePath,
|
getDefaultHeartbeatProcedurePath,
|
||||||
isAgentPermissionPolicyPresetId,
|
isAgentPermissionPolicyPresetId,
|
||||||
isEphemeralAgent,
|
isEphemeralAgent,
|
||||||
@@ -79,6 +80,40 @@ function isCompatibleDefaultHeartbeatPath(path: string | undefined, agent: Agent
|
|||||||
return new RegExp(`^\\.fusion/agents/[^/]+-${safeId}/HEARTBEAT\\.md$`).test(trimmed);
|
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 }> {
|
function withPendingApprovalCounts<T extends Agent>(agents: T[], scopedStore: TaskStore): Array<T & { pendingApprovalCount: number }> {
|
||||||
try {
|
try {
|
||||||
const approvalStore = new ApprovalRequestStore(scopedStore.getDatabase());
|
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 agents = await agentStore.listAgents(filter as { state?: "idle" | "active" | "running" | "paused" | "error"; role?: AgentCapability; includeEphemeral?: boolean });
|
||||||
const sanitizedAgents = await sanitizeAgentTaskLinks(agents, scopedStore);
|
const sanitizedAgents = await sanitizeAgentTaskLinks(agents, scopedStore);
|
||||||
res.json(withPendingApprovalCounts(sanitizedAgents, scopedStore));
|
const agentsWithTokenTotals = withTaskDerivedTokenTotals(sanitizedAgents, scopedStore);
|
||||||
|
res.json(withPendingApprovalCounts(agentsWithTokenTotals, scopedStore));
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@@ -680,7 +680,7 @@ export function registerAgentRuntimeRoutes(ctx: ApiRoutesContext, deps: AgentRun
|
|||||||
router.get("/agents/:id/token-usage", async (req, res) => {
|
router.get("/agents/:id/token-usage", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { store: scopedStore } = await getProjectContext(req);
|
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() });
|
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||||
await agentStore.init();
|
await agentStore.init();
|
||||||
|
|
||||||
@@ -688,9 +688,6 @@ export function registerAgentRuntimeRoutes(ctx: ApiRoutesContext, deps: AgentRun
|
|||||||
if (!agent) {
|
if (!agent) {
|
||||||
throw notFound("Agent not found");
|
throw notFound("Agent not found");
|
||||||
}
|
}
|
||||||
if (isEphemeralAgent(agent)) {
|
|
||||||
throw badRequest("Token usage is not available for ephemeral agents");
|
|
||||||
}
|
|
||||||
|
|
||||||
const summary = await aggregateAgentTokenUsage({
|
const summary = await aggregateAgentTokenUsage({
|
||||||
taskStore: scopedStore,
|
taskStore: scopedStore,
|
||||||
|
|||||||
Reference in New Issue
Block a user