Skip to content

Commit 9b4e473

Browse files
committed
perf(dashboard-agent): share one 1h-TTL prompt cache between head start and the agent
Head start passed `system` as a bare string, so Anthropic neither wrote nor read the cache: the ~18.4k-token prefix was billed in full and the agent's step 2 then paid for a fresh write. Both breakpoints now carry a 1-hour TTL. The two prefixes were also not identical — `get_run` sat in a different position in the agent's tool set than in the canonical schema-only one, so they could never have shared a cache. Reordered, and a fingerprint over system text + tool definitions is logged per model call alongside the provider's cache-write, cache-read and uncached input token counts, so a future drift is visible instead of silent.
1 parent 893b9e8 commit 9b4e473

6 files changed

Lines changed: 197 additions & 15 deletions

File tree

apps/webapp/app/services/dashboardAgentHeadStart.server.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import {
66
dashboardAgentCodeToolSchemas,
77
dashboardAgentToolSchemas,
88
} from "@internal/dashboard-agent/tool-schemas";
9+
import {
10+
describePromptPrefix,
11+
PROMPT_CACHE_CONTROL,
12+
promptCacheAttributes,
13+
} from "@internal/dashboard-agent/prompt-prefix";
914
import { ApiClient, SessionStreamInstance, writeTurnCompleteRecord } from "@trigger.dev/core/v3";
1015
import { chat as chatServer } from "@trigger.dev/sdk/chat-server";
1116
import { streamText, type UIMessage, type UIMessageChunk } from "ai";
@@ -109,7 +114,26 @@ export async function startDashboardAgentHeadStart(params: {
109114
streamText({
110115
...helper.toStreamTextOptions({ tools }),
111116
model: anthropic(DASHBOARD_AGENT_MODEL),
112-
system,
117+
// A structured system message, not a bare string: without provider options
118+
// Anthropic neither writes nor reads the cache, so this call paid full price
119+
// for the prefix and the agent's step 2 then paid for a fresh write. The tool
120+
// key order is frozen (see `tool-schemas.ts`) so both prefixes are identical
121+
// — the logged fingerprint is how a drift becomes visible.
122+
system: {
123+
role: "system",
124+
content: system,
125+
providerOptions: { anthropic: { cacheControl: PROMPT_CACHE_CONTROL } },
126+
},
127+
onStepFinish: (step) => {
128+
logger.info(
129+
"Dashboard agent prompt cache",
130+
promptCacheAttributes({
131+
source: "head-start",
132+
usage: step.usage,
133+
prefix: describePromptPrefix({ system, tools }),
134+
})
135+
);
136+
},
113137
}),
114138
});
115139

internal-packages/dashboard-agent/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"type": "module",
88
"exports": {
99
".": "./src/index.ts",
10-
"./tool-schemas": "./src/tool-schemas.ts"
10+
"./tool-schemas": "./src/tool-schemas.ts",
11+
"./prompt-prefix": "./src/prompt-prefix.ts"
1112
},
1213
"dependencies": {
1314
"@ai-sdk/anthropic": "^3.0.0",

internal-packages/dashboard-agent/src/dashboard-agent.ts

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ import {
2727
import { z } from "zod";
2828
import type { EvalTurnPayload, evalTurn } from "./eval-turn";
2929
import { codeSystemPrompt, systemPrompt, titlePrompt } from "./prompts";
30+
import {
31+
describePromptPrefix,
32+
PROMPT_CACHE_CONTROL,
33+
promptCacheAttributes,
34+
type PromptCacheUsage,
35+
} from "./prompt-prefix";
3036
import { buildDashboardAgentTools } from "./tools";
3137

3238
/**
@@ -587,12 +593,39 @@ function withCacheBreakpointOnLast(messages: ModelMessage[]): ModelMessage[] {
587593
...last,
588594
providerOptions: {
589595
...last.providerOptions,
590-
anthropic: { cacheControl: { type: "ephemeral" } },
596+
anthropic: { cacheControl: PROMPT_CACHE_CONTROL },
591597
},
592598
},
593599
];
594600
}
595601

602+
/**
603+
* One line per model call: what the provider billed as a cache write, a cache read
604+
* and uncached input, against the prefix we expect to be cached. The estimate and
605+
* the fingerprint are ours; the token counts are the provider's, and are logged as
606+
* `null` when it reported none.
607+
*/
608+
function recordPromptCacheUsage(args: {
609+
source: string;
610+
usage: PromptCacheUsage | undefined;
611+
system: string;
612+
tools: ToolSet;
613+
}): void {
614+
try {
615+
logger.info(
616+
"dashboard-agent prompt cache",
617+
promptCacheAttributes({
618+
source: args.source,
619+
usage: args.usage,
620+
prefix: describePromptPrefix({ system: args.system, tools: args.tools }),
621+
})
622+
);
623+
} catch (error) {
624+
// Measurement must never fail a turn.
625+
logger.debug("dashboard-agent prompt cache measurement failed", { error });
626+
}
627+
}
628+
596629
/**
597630
* How the watch ended. The narration speaks resolution and observed outcome,
598631
* never "fired"/"expired" — those are the wire encoding, and a watch that ran its
@@ -1192,7 +1225,7 @@ export const dashboardAgent = chat.agent({
11921225
// prompt; the resolve is cached per process. The cache breakpoint on the system
11931226
// block carries through toStreamTextOptions() and survives suspend/resume.
11941227
chat.prompt.set(await getSystemPrompt(modeFor(clientData)), {
1195-
providerOptions: { anthropic: { cacheControl: { type: "ephemeral" } } },
1228+
providerOptions: { anthropic: { cacheControl: PROMPT_CACHE_CONTROL } },
11961229
});
11971230
},
11981231

@@ -1306,7 +1339,7 @@ export const dashboardAgent = chat.agent({
13061339
...last,
13071340
providerOptions: {
13081341
...last.providerOptions,
1309-
anthropic: { cacheControl: { type: "ephemeral" } },
1342+
anthropic: { cacheControl: PROMPT_CACHE_CONTROL },
13101343
},
13111344
},
13121345
];
@@ -1327,6 +1360,14 @@ export const dashboardAgent = chat.agent({
13271360
),
13281361
messages,
13291362
abortSignal: signal,
1363+
// Per model call, so the head-start prefix and this one can be compared.
1364+
onStepFinish: (step) =>
1365+
recordPromptCacheUsage({
1366+
source: "agent-turn",
1367+
usage: step.usage,
1368+
system: resolved.text,
1369+
tools: tools ?? {},
1370+
}),
13301371
// toStreamTextOptions() defaults to a single step; override so the model can
13311372
// call a tool and then answer from its result in the same turn.
13321373
stopWhen: stepCountIs(10),
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { describe, expect, it } from "vitest";
2+
import { describePromptPrefix, PROMPT_CACHE_CONTROL, promptCacheAttributes } from "./prompt-prefix";
3+
import {
4+
DASHBOARD_AGENT_CODE_SYSTEM_PROMPT,
5+
DASHBOARD_AGENT_SYSTEM_PROMPT,
6+
dashboardAgentCodeToolSchemas,
7+
dashboardAgentToolSchemas,
8+
} from "./tool-schemas";
9+
import { buildDashboardAgentTools } from "./tools";
10+
import type { RepoSnapshot } from "./repo-tools";
11+
12+
const SCOPE = { projectRef: "proj_abc", environmentId: "env_abc" };
13+
14+
const snapshot: RepoSnapshot = {
15+
tarballUrl: "http://unused.invalid/never-fetched",
16+
owner: "acme",
17+
repo: "demo",
18+
sha: "deadbeefdeadbeef",
19+
};
20+
21+
/**
22+
* The whole point of the frozen tool key order: the webapp's warm first call and the
23+
* agent task's every-later call must hand Anthropic a byte-identical prefix, or they
24+
* cache separately and every call pays a fresh write.
25+
*/
26+
describe("the head-start and agent prefixes are the same prefix", () => {
27+
it("matches in assistant mode", () => {
28+
const headStart = describePromptPrefix({
29+
system: DASHBOARD_AGENT_SYSTEM_PROMPT,
30+
tools: dashboardAgentToolSchemas,
31+
});
32+
const agent = describePromptPrefix({
33+
system: DASHBOARD_AGENT_SYSTEM_PROMPT,
34+
tools: buildDashboardAgentTools(SCOPE),
35+
});
36+
37+
expect(agent.fingerprint).toBe(headStart.fingerprint);
38+
expect(agent.chars).toBe(headStart.chars);
39+
});
40+
41+
it("matches in code mode", () => {
42+
const headStart = describePromptPrefix({
43+
system: DASHBOARD_AGENT_CODE_SYSTEM_PROMPT,
44+
tools: dashboardAgentCodeToolSchemas,
45+
});
46+
const agent = describePromptPrefix({
47+
system: DASHBOARD_AGENT_CODE_SYSTEM_PROMPT,
48+
tools: buildDashboardAgentTools({ ...SCOPE, repoSnapshot: snapshot }),
49+
});
50+
51+
expect(agent.fingerprint).toBe(headStart.fingerprint);
52+
});
53+
54+
it("notices a reordered or changed tool set", () => {
55+
const base = describePromptPrefix({
56+
system: DASHBOARD_AGENT_SYSTEM_PROMPT,
57+
tools: dashboardAgentToolSchemas,
58+
});
59+
60+
const { list_projects, ...rest } = dashboardAgentToolSchemas;
61+
const reordered = describePromptPrefix({
62+
system: DASHBOARD_AGENT_SYSTEM_PROMPT,
63+
tools: { ...rest, list_projects },
64+
});
65+
expect(reordered.fingerprint).not.toBe(base.fingerprint);
66+
67+
const editedPrompt = describePromptPrefix({
68+
system: `${DASHBOARD_AGENT_SYSTEM_PROMPT}\nOne more rule.`,
69+
tools: dashboardAgentToolSchemas,
70+
});
71+
expect(editedPrompt.fingerprint).not.toBe(base.fingerprint);
72+
});
73+
74+
it("caches on the 1-hour breakpoint", () => {
75+
expect(PROMPT_CACHE_CONTROL).toEqual({ type: "ephemeral", ttl: "1h" });
76+
});
77+
});
78+
79+
describe("promptCacheAttributes", () => {
80+
const prefix = { chars: 80_000, estimatedTokens: 20_000, fingerprint: "abcd1234" };
81+
82+
it("records the four token counts the provider reports", () => {
83+
expect(
84+
promptCacheAttributes({
85+
source: "agent-turn",
86+
usage: {
87+
inputTokens: 21_000,
88+
inputTokenDetails: { noCacheTokens: 100, cacheReadTokens: 20_900, cacheWriteTokens: 0 },
89+
},
90+
prefix,
91+
})
92+
).toEqual({
93+
"dashboard_agent.prompt_cache.source": "agent-turn",
94+
"gen_ai.usage.input_tokens": 21_000,
95+
"gen_ai.usage.cache_write_input_tokens": 0,
96+
"gen_ai.usage.cache_read_input_tokens": 20_900,
97+
"gen_ai.usage.uncached_input_tokens": 100,
98+
"dashboard_agent.prefix.estimated_tokens": 20_000,
99+
"dashboard_agent.prefix.chars": 80_000,
100+
"dashboard_agent.prefix.fingerprint": "abcd1234",
101+
});
102+
});
103+
104+
it("reports a value the provider didn't give as null rather than zero", () => {
105+
const attributes = promptCacheAttributes({ source: "head-start", usage: undefined, prefix });
106+
expect(attributes["gen_ai.usage.cache_write_input_tokens"]).toBeNull();
107+
expect(attributes["gen_ai.usage.cache_read_input_tokens"]).toBeNull();
108+
expect(attributes["gen_ai.usage.uncached_input_tokens"]).toBeNull();
109+
expect(attributes["gen_ai.usage.input_tokens"]).toBeNull();
110+
// Ours, so always present.
111+
expect(attributes["dashboard_agent.prefix.estimated_tokens"]).toBe(20_000);
112+
});
113+
});
3.99 KB
Binary file not shown.

internal-packages/dashboard-agent/src/tools.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1076,16 +1076,6 @@ export function buildDashboardAgentTools(ctx: DashboardAgentToolContext): ToolSe
10761076
},
10771077
}),
10781078

1079-
get_run: tool({
1080-
...getRunSchema,
1081-
execute: async ({ runId }) => {
1082-
const result = await envApiGet(`/api/v3/runs/${runId}`);
1083-
if (!result) return { error: "No current environment is available to read runs from." };
1084-
if (!result.ok) return { error: `Couldn't get run ${runId} (status ${result.status}).` };
1085-
return curateRun(result.data);
1086-
},
1087-
}),
1088-
10891079
list_tasks: tool({
10901080
...listTasksSchema,
10911081
execute: async () => {
@@ -1121,6 +1111,19 @@ export function buildDashboardAgentTools(ctx: DashboardAgentToolContext): ToolSe
11211111
},
11221112
}),
11231113

1114+
// Order matters up to here: `dashboardAgentToolSchemas` is the canonical key
1115+
// order (head start builds its prefix from it), and a different order is a
1116+
// different cached prefix.
1117+
get_run: tool({
1118+
...getRunSchema,
1119+
execute: async ({ runId }) => {
1120+
const result = await envApiGet(`/api/v3/runs/${runId}`);
1121+
if (!result) return { error: "No current environment is available to read runs from." };
1122+
if (!result.ok) return { error: `Couldn't get run ${runId} (status ${result.status}).` };
1123+
return curateRun(result.data);
1124+
},
1125+
}),
1126+
11241127
get_run_trace: tool({
11251128
...getRunTraceSchema,
11261129
execute: async ({ runId }) => {

0 commit comments

Comments
 (0)