Skip to content
Open
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
59 changes: 59 additions & 0 deletions .github/workflows/claude.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ jobs:
bun-version: "1.3.12"

- name: Run Claude
id: claude
if: steps.prompt.outputs.skip != 'true'
uses: anthropics/claude-code-action@v1
with:
Expand All @@ -78,6 +79,17 @@ jobs:
}
}

- name: Report token usage
if: steps.claude.outcome != 'skipped' && steps.claude.outputs.execution_file != ''
continue-on-error: true
env:
GITHUB_TOKEN: ${{ secrets.AGENT_TOKEN }}
EXEC_FILE: ${{ steps.claude.outputs.execution_file }}
ENTITY_TYPE: issue
ENTITY_NUMBER: ${{ github.event.issue.number }}
REPO: ${{ github.repository }}
run: bash scripts/report-token-usage.sh

# ---------------------------------------------------------------------------
# Comment on a plain issue → update the related PR
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -117,6 +129,7 @@ jobs:
bun-version: "1.3.12"

- name: Run Claude
id: claude
if: steps.prompt.outputs.skip != 'true'
uses: anthropics/claude-code-action@v1
with:
Expand All @@ -138,6 +151,17 @@ jobs:
}
}

- name: Report token usage
if: steps.claude.outcome != 'skipped' && steps.claude.outputs.execution_file != ''
continue-on-error: true
env:
GITHUB_TOKEN: ${{ secrets.AGENT_TOKEN }}
EXEC_FILE: ${{ steps.claude.outputs.execution_file }}
ENTITY_TYPE: issue
ENTITY_NUMBER: ${{ github.event.issue.number }}
REPO: ${{ github.repository }}
run: bash scripts/report-token-usage.sh

# ---------------------------------------------------------------------------
# PR opened/updated (not Claude's) → post a review
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -264,6 +288,17 @@ jobs:
GH_TOKEN: ${{ secrets.AGENT_TOKEN }}
PR_NUMBER: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.event.workflow_run.pull_requests[0].number }}

- name: Report token usage
if: steps.skip-check.outputs.skip != 'true' && steps.claude.outcome != 'skipped' && steps.claude.outputs.execution_file != ''
continue-on-error: true
env:
GITHUB_TOKEN: ${{ secrets.AGENT_TOKEN }}
EXEC_FILE: ${{ steps.claude.outputs.execution_file }}
ENTITY_TYPE: pr
ENTITY_NUMBER: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.event.workflow_run.pull_requests[0].number }}
REPO: ${{ github.repository }}
run: bash scripts/report-token-usage.sh

# ---------------------------------------------------------------------------
# PR synchronize (new commits pushed) → clear status, check prior concerns
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -314,6 +349,7 @@ jobs:
bun-version: "1.3.12"

- name: Run Claude
id: claude
if: steps.prompt.outputs.skip != 'true'
uses: anthropics/claude-code-action@v1
with:
Expand All @@ -328,6 +364,17 @@ jobs:
}
}

- name: Report token usage
if: steps.claude.outcome != 'skipped' && steps.claude.outputs.execution_file != ''
continue-on-error: true
env:
GITHUB_TOKEN: ${{ secrets.AGENT_TOKEN }}
EXEC_FILE: ${{ steps.claude.outputs.execution_file }}
ENTITY_TYPE: pr
ENTITY_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: bash scripts/report-token-usage.sh

# ---------------------------------------------------------------------------
# Comment on Claude's own PR → address the feedback
# Handles both timeline comments (issue_comment) and inline diff comments
Expand Down Expand Up @@ -380,6 +427,7 @@ jobs:
bun-version: "1.3.12"

- name: Run Claude
id: claude
if: steps.prompt.outputs.skip != 'true'
uses: anthropics/claude-code-action@v1
with:
Expand All @@ -400,3 +448,14 @@ jobs:
]
}
}

- name: Report token usage
if: steps.claude.outcome != 'skipped' && steps.claude.outputs.execution_file != ''
continue-on-error: true
env:
GITHUB_TOKEN: ${{ secrets.AGENT_TOKEN }}
EXEC_FILE: ${{ steps.claude.outputs.execution_file }}
ENTITY_TYPE: issue
ENTITY_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: bash scripts/report-token-usage.sh
49 changes: 45 additions & 4 deletions packages/agent/src/__tests__/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ describe("runAgent", () => {
})()
);

const result = await runAgent("find the Q4 report", "You are Squirrel.");
const { result } = await runAgent("find the Q4 report", "You are Squirrel.");
expect(result).toBe("Here is the document you requested.");
});

Expand All @@ -35,7 +35,7 @@ describe("runAgent", () => {
})()
);

const result = await runAgent("find something", "system");
const { result } = await runAgent("find something", "system");
expect(result).toBe("I was unable to generate a response.");
});

Expand All @@ -47,10 +47,51 @@ describe("runAgent", () => {
})()
);

const result = await runAgent("prompt", "system");
const { result } = await runAgent("prompt", "system");
expect(result).toBe("second");
});

it("captures token usage and cost from the result message", async () => {
vi.mocked(query).mockReturnValue(
(async function* () {
yield {
result: "answer",
total_cost_usd: 0.0042,
// real SDKResultMessage.usage uses the API's snake_case keys
usage: {
input_tokens: 800,
output_tokens: 300,
cache_read_input_tokens: 150,
cache_creation_input_tokens: 50,
},
};
})()
);

const agentResult = await runAgent("prompt", "system");
expect(agentResult.result).toBe("answer");
expect(agentResult.costUsd).toBeCloseTo(0.0042);
expect(agentResult.inputTokens).toBe(800);
expect(agentResult.outputTokens).toBe(300);
expect(agentResult.cacheReadTokens).toBe(150);
expect(agentResult.cacheCreationTokens).toBe(50);
});

it("defaults token counts and cost to zero when usage is absent", async () => {
vi.mocked(query).mockReturnValue(
(async function* () {
yield { result: "ok" };
})()
);

const agentResult = await runAgent("prompt", "system");
expect(agentResult.costUsd).toBe(0);
expect(agentResult.inputTokens).toBe(0);
expect(agentResult.outputTokens).toBe(0);
expect(agentResult.cacheReadTokens).toBe(0);
expect(agentResult.cacheCreationTokens).toBe(0);
});

it("passes systemPrompt and model to query options", async () => {
vi.mocked(query).mockReturnValue(
(async function* () {
Expand Down Expand Up @@ -126,7 +167,7 @@ describe("runAgent", () => {

const resultPromise = runAgent("prompt", "system");
await vi.runAllTimersAsync();
const result = await resultPromise;
const { result } = await resultPromise;
expect(result).toBe("recovered");
expect(vi.mocked(query)).toHaveBeenCalledTimes(2);
vi.useRealTimers();
Expand Down
21 changes: 15 additions & 6 deletions packages/agent/src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,12 @@ vi.mock("../slack.js", () => ({
postEphemeral: vi.fn().mockResolvedValue(undefined),
}));

const mockAgentResult = (result: string, costUsd = 0) => ({
result, inputTokens: 100, outputTokens: 50, cacheReadTokens: 10, cacheCreationTokens: 5, costUsd,
});

vi.mock("../agent.js", () => ({
runAgent: vi.fn().mockResolvedValue("Agent response"),
runAgent: vi.fn().mockResolvedValue(mockAgentResult("Agent response")),
}));

vi.mock("../prompt.js", () => ({
Expand Down Expand Up @@ -103,7 +107,7 @@ describe("processEvent", () => {
it("fetches thread history, runs agent, and posts response", async () => {
const history = [{ user: "U1", text: "earlier message" }];
vi.mocked(fetchThreadHistory).mockResolvedValueOnce(history);
vi.mocked(runAgent).mockResolvedValueOnce("Here is your answer.");
vi.mocked(runAgent).mockResolvedValueOnce(mockAgentResult("Here is your answer."));

await processEvent(baseEvent);

Expand All @@ -120,7 +124,7 @@ describe("processEvent", () => {
});

it("does not post or audit when agent returns the no-reply sentinel", async () => {
vi.mocked(runAgent).mockResolvedValueOnce("__NO_REPLY__");
vi.mocked(runAgent).mockResolvedValueOnce(mockAgentResult("__NO_REPLY__"));
await processEvent({ ...baseEvent, requires_discretion: true });
expect(postMessage).not.toHaveBeenCalled();
expect(mockAuditLog).not.toHaveBeenCalled();
Expand All @@ -136,7 +140,7 @@ describe("processEvent", () => {
});

it("passes the agent response to postMessage", async () => {
vi.mocked(runAgent).mockResolvedValueOnce("Custom agent answer");
vi.mocked(runAgent).mockResolvedValueOnce(mockAgentResult("Custom agent answer"));
await processEvent(baseEvent);
expect(postMessage).toHaveBeenCalledWith("C_CHAN", "1.0", "Custom agent answer");
});
Expand Down Expand Up @@ -176,9 +180,9 @@ describe("processEvent", () => {
await expect(processEvent(baseEvent)).rejects.toThrow("Agent failed");
});

it("writes audit record with channel, user, model, and duration after posting response", async () => {
it("writes audit record with channel, user, model, duration, and token usage after posting response", async () => {
vi.mocked(mockConfigStore.get).mockResolvedValueOnce("claude-opus-4-6");
vi.mocked(runAgent).mockResolvedValueOnce("The answer.");
vi.mocked(runAgent).mockResolvedValueOnce(mockAgentResult("The answer.", 0.0042));

await processEvent(baseEvent);

Expand All @@ -189,6 +193,11 @@ describe("processEvent", () => {
user_id: "U1",
response: "The answer.",
model: "claude-opus-4-6",
input_tokens: 100,
output_tokens: 50,
cache_read_tokens: 10,
cache_creation_tokens: 5,
cost_usd: "0.004200",
})
);
expect(typeof mockAuditLog.mock.calls[0][0].duration_ms).toBe("number");
Expand Down
37 changes: 34 additions & 3 deletions packages/agent/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,22 @@ import * as path from "path";
import { randomUUID } from "crypto";
import type { ImageContent } from "./slack.js";

export interface AgentResult {
result: string;
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheCreationTokens: number;
costUsd: number;
}

export async function runAgent(
prompt: string,
systemPrompt: string,
model?: string,
maxTokens?: number,
images?: ImageContent[],
): Promise<string> {
): Promise<AgentResult> {
const maxRetries = parseInt(process.env.MAX_MCP_RETRIES ?? "2", 10);
let lastError: Error | undefined;

Expand Down Expand Up @@ -82,7 +91,7 @@ async function runAgentOnce(
model?: string,
maxTokens?: number,
images?: ImageContent[],
): Promise<string> {
): Promise<AgentResult> {
const urlFetcherPath = path.resolve(
__dirname,
"../../mcp-url-fetcher/dist/index.js"
Expand All @@ -105,6 +114,11 @@ async function runAgentOnce(
: prompt;

let result = "";
let inputTokens = 0;
let outputTokens = 0;
let cacheReadTokens = 0;
let cacheCreationTokens = 0;
let costUsd = 0;

for await (const message of query({
prompt: sdkPrompt,
Expand Down Expand Up @@ -174,8 +188,25 @@ async function runAgentOnce(
})) {
if ("result" in message) {
result = message.result;
const msg = message as Record<string, unknown>;
costUsd = typeof msg.total_cost_usd === "number" ? msg.total_cost_usd : 0;
// SDKResultMessage.usage is the API Usage shape — snake_case keys
const usage = msg.usage as Record<string, number> | undefined;
if (usage) {
inputTokens = usage.input_tokens ?? 0;
outputTokens = usage.output_tokens ?? 0;
cacheReadTokens = usage.cache_read_input_tokens ?? 0;
cacheCreationTokens = usage.cache_creation_input_tokens ?? 0;
}
}
}

return result || "I was unable to generate a response.";
return {
result: result || "I was unable to generate a response.",
inputTokens,
outputTokens,
cacheReadTokens,
cacheCreationTokens,
costUsd,
};
}
13 changes: 9 additions & 4 deletions packages/agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,23 +76,28 @@ export async function processEvent(event: SlackEvent): Promise<void> {
: [];

const start = Date.now();
const response = await runAgent(prompt, systemPrompt, model, maxTokens, images);
const agentResult = await runAgent(prompt, systemPrompt, model, maxTokens, images);
const duration_ms = Date.now() - start;

if (response.trim() === NO_REPLY_SENTINEL) {
if (agentResult.result.trim() === NO_REPLY_SENTINEL) {
console.log(`[discretion] agent elected not to reply in channel ${event.channel}`);
return;
}

await postMessage(event.channel, event.thread_ts, response);
await postMessage(event.channel, event.thread_ts, agentResult.result);
await auditLogger.log({
channel: event.channel,
thread_ts: event.thread_ts,
user_id: event.user,
prompt,
response,
response: agentResult.result,
model: model ?? null,
duration_ms,
input_tokens: agentResult.inputTokens,
output_tokens: agentResult.outputTokens,
cache_read_tokens: agentResult.cacheReadTokens,
cache_creation_tokens: agentResult.cacheCreationTokens,
cost_usd: agentResult.costUsd.toFixed(6),
});
}

Expand Down
9 changes: 9 additions & 0 deletions packages/db/drizzle/0001_audit_token_cost.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
ALTER TABLE "audit_log" ADD COLUMN "input_tokens" integer NOT NULL DEFAULT 0;
--> statement-breakpoint
ALTER TABLE "audit_log" ADD COLUMN "output_tokens" integer NOT NULL DEFAULT 0;
--> statement-breakpoint
ALTER TABLE "audit_log" ADD COLUMN "cache_read_tokens" integer NOT NULL DEFAULT 0;
--> statement-breakpoint
ALTER TABLE "audit_log" ADD COLUMN "cache_creation_tokens" integer NOT NULL DEFAULT 0;
--> statement-breakpoint
ALTER TABLE "audit_log" ADD COLUMN "cost_usd" text NOT NULL DEFAULT '0';
7 changes: 7 additions & 0 deletions packages/db/drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@
"when": 1742688000000,
"tag": "0000_initial",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1751760000000,
"tag": "0001_audit_token_cost",
"breakpoints": true
}
]
}
Loading
Loading