Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f776554
feat(dashboard-agent): run model calls through Bedrock behind an env …
kathiekiwi Aug 13, 2026
2e6e26f
feat(dashboard-agent): report cache token usage on both providers
kathiekiwi Aug 13, 2026
bfb3a59
fix(dashboard-agent): guard Bedrock model-id shape and drop the guess…
kathiekiwi Aug 13, 2026
de36f49
fix(rbac): deny non-member user-actor tokens at the ability layer
kathiekiwi Aug 13, 2026
b938d4d
fix(webapp): route dashboard agent head-start through the shared mode…
kathiekiwi Aug 13, 2026
4f93ff1
docs: add server changes note for dashboard agent auth hardening
kathiekiwi Aug 13, 2026
0a54744
fix(webapp): make dashboard agent head-start gate provider-aware
kathiekiwi Aug 13, 2026
87851ad
fix(webapp): gate bedrock head-start on region and validate provider env
kathiekiwi Aug 13, 2026
66cfdfb
chore: drop server-changes note
kathiekiwi Aug 13, 2026
f4ba8d3
fix(webapp): gate bedrock head-start on AWS_REGION only
kathiekiwi Aug 13, 2026
df2a1bb
fix(rbac): gate user-actor membership floor on a scoped context
kathiekiwi Aug 13, 2026
fde7011
fix(dashboard-agent): route the runtime eval judge through the provid…
kathiekiwi Aug 13, 2026
c094a76
fix(dashboard-agent): keep the Bedrock prefix cachePoint on short con…
kathiekiwi Aug 13, 2026
85bd73e
test(rbac): prove unscoped user-actor path skips the user lookup
kathiekiwi Aug 13, 2026
cbfa5f8
fix(webapp): gate bedrock head-start on AWS_REGION or AWS_DEFAULT_REGION
kathiekiwi Aug 13, 2026
642d8a3
fix(dashboard-agent): resolve Bedrock region from AWS_REGION or AWS_D…
kathiekiwi Aug 13, 2026
89cd034
refactor(dashboard-agent): treat empty Bedrock region as unset, match…
kathiekiwi Aug 13, 2026
cbf7e3b
fix(dashboard-agent): stop leaking an undocumented ttl to AWS Bedrock
kathiekiwi Aug 13, 2026
b6a73d5
docs(dashboard-agent): trim the cache-breakpoint comments
kathiekiwi Aug 13, 2026
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
12 changes: 12 additions & 0 deletions apps/webapp/app/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,18 @@ const EnvironmentSchema = z
// uses its own key on the Trigger side. When unset, Head Start is disabled
// and the first turn falls back to the normal cold-start path.
ANTHROPIC_API_KEY: z.string().optional(),
// Selects the dashboard agent's LLM provider (default anthropic). The internal
// seam reads process.env directly; this entry validates the value webapp-side.
DASHBOARD_AGENT_MODEL_PROVIDER: z.enum(["anthropic", "bedrock"]).default("anthropic"),
Comment thread
kathiekiwi marked this conversation as resolved.
// AWS credentials for the dashboard agent's Bedrock provider (only used when
// DASHBOARD_AGENT_MODEL_PROVIDER=bedrock; default path stays Anthropic). The
// provider resolves credentials itself, so only the region is read here.
AWS_REGION: z.string().optional(),
AWS_DEFAULT_REGION: z.string().optional(),
AWS_ACCESS_KEY_ID: z.string().optional(),
AWS_SECRET_ACCESS_KEY: z.string().optional(),
AWS_SESSION_TOKEN: z.string().optional(),
AWS_BEARER_TOKEN_BEDROCK: z.string().optional(),
DIRECT_URL: z
.string()
.refine(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
softDeleteChat,
} from "@internal/dashboard-agent-db";
import { watchDraftSchema, type WatchDraft } from "@internal/dashboard-agent-contracts";
import { dashboardAgentProvider } from "@internal/dashboard-agent/model-provider";
import { generateFriendlyId } from "@trigger.dev/core/v3/isomorphic";
import type { UIMessage } from "ai";
import { z } from "zod";
Expand Down Expand Up @@ -329,7 +330,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
const chatId = generateFriendlyId("chat");
try {
const repoSnapshot = await resolveDashboardAgentRepoSnapshot(project.id);
const headStarted = Boolean(env.ANTHROPIC_API_KEY);
const headStarted =
dashboardAgentProvider() === "bedrock"
? Boolean(env.AWS_REGION || env.AWS_DEFAULT_REGION)
: Boolean(env.ANTHROPIC_API_KEY);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

// The lookups and the mint all run before the chat row exists, so a failure here can't
// leave an empty chat behind in the user's history.
Expand Down
14 changes: 7 additions & 7 deletions apps/webapp/app/services/dashboardAgentHeadStart.server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { createAnthropic } from "@ai-sdk/anthropic";
import {
DASHBOARD_AGENT_CODE_SYSTEM_PROMPT,
DASHBOARD_AGENT_MODEL,
Expand All @@ -8,9 +7,12 @@ import {
} from "@internal/dashboard-agent/tool-schemas";
import {
describePromptPrefix,
PROMPT_CACHE_CONTROL,
promptCacheAttributes,
} from "@internal/dashboard-agent/prompt-prefix";
import {
resolveDashboardAgentModel,
withCacheBreakpoint,
} from "@internal/dashboard-agent/model-provider";
import { ApiClient, SessionStreamInstance, writeTurnCompleteRecord } from "@trigger.dev/core/v3";
import { chat as chatServer } from "@trigger.dev/sdk/chat-server";
import { streamText, type UIMessage, type UIMessageChunk } from "ai";
Expand All @@ -23,8 +25,6 @@ import { logger } from "~/services/logger.server";

const TASK_ID = "dashboard-agent";

const anthropic = createAnthropic({ apiKey: env.ANTHROPIC_API_KEY });

/** Shown when the warm first turn produced nothing. The provider error is only logged. */
export const HEAD_START_FAILURE_ERROR_TEXT =
"The assistant couldn't start this response. Please send your message again.";
Expand Down Expand Up @@ -113,16 +113,16 @@ export async function startDashboardAgentHeadStart(params: {
run: async ({ chat: helper }) =>
streamText({
...helper.toStreamTextOptions({ tools }),
model: anthropic(DASHBOARD_AGENT_MODEL),
model: resolveDashboardAgentModel(DASHBOARD_AGENT_MODEL),
// A structured system message, not a bare string: without provider options
// Anthropic neither writes nor reads the cache, so this call paid full price
// the provider neither writes nor reads the cache, so this call paid full price
// for the prefix and the agent's step 2 then paid for a fresh write. The tool
// key order is frozen (see `tool-schemas.ts`) so both prefixes are identical
// — the logged fingerprint is how a drift becomes visible.
system: {
role: "system",
content: system,
providerOptions: { anthropic: { cacheControl: PROMPT_CACHE_CONTROL } },
providerOptions: withCacheBreakpoint(undefined, "prefix"),
},
onStepFinish: (step) => {
logger.info(
Expand Down
4 changes: 3 additions & 1 deletion internal-packages/dashboard-agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@
".": "./src/index.ts",
"./tool-curation": "./src/tool-curation.ts",
"./tool-schemas": "./src/tool-schemas.ts",
"./prompt-prefix": "./src/prompt-prefix.ts"
"./prompt-prefix": "./src/prompt-prefix.ts",
"./model-provider": "./src/model-provider.ts"
},
"dependencies": {
"@ai-sdk/amazon-bedrock": "4.0.117",
"@ai-sdk/anthropic": "^3.0.0",
"@internal/dashboard-agent-contracts": "workspace:*",
"@internal/dashboard-agent-db": "workspace:*",
Expand Down
26 changes: 8 additions & 18 deletions internal-packages/dashboard-agent/src/agent-runtime.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { anthropic } from "@ai-sdk/anthropic";
import {
appendChatMessageOnce,
createDashboardAgentDb,
Expand All @@ -18,22 +17,16 @@ import {
type UpsertInvestigationResult,
} from "@internal/dashboard-agent-db";
import { locals, logger } from "@trigger.dev/sdk";
import {
createProviderRegistry,
type LanguageModel,
type ModelMessage,
type ToolSet,
type UIMessage,
} from "ai";
import { type LanguageModel, type ModelMessage, type ToolSet, type UIMessage } from "ai";
import { z } from "zod";
import {
agentPageContextSchema,
forceSettledInvestigationState,
investigationStateSchema,
type InvestigationState,
} from "@internal/dashboard-agent-contracts";
import { withCacheBreakpoint } from "./model-provider";
import { codeSystemPrompt, systemPrompt } from "./prompts";
import { PROMPT_CACHE_CONTROL } from "./prompt-prefix";
import { buildDashboardAgentTools } from "./tools";

/**
Expand Down Expand Up @@ -63,8 +56,8 @@ function getDb(): DashboardAgentDbClient {
}

// Resolves the `"provider:model-id"` strings on our managed prompts to AI SDK
// models. Add another @ai-sdk/* provider here to allow it on a prompt.
export const registry = createProviderRegistry({ anthropic });
// models, against whichever provider is switched on.
export { registry, resolveDashboardAgentModel } from "./model-provider";

// The agent's persistence, behind an interface so tests can inject a fake via
// `locals` and never need a real database.
Expand Down Expand Up @@ -354,20 +347,17 @@ export function sanitizeReplayedToolInputs(messages: ModelMessage[]): ModelMessa
}) as ModelMessage[];
}

// Same Anthropic breakpoint `prepareMessages` rolls onto a turn's last message.
// Same breakpoint `prepareMessages` rolls onto a turn's last message.
export function withCacheBreakpointOnLast(messages: ModelMessage[]): ModelMessage[] {
if (messages.length === 0) return messages;
const last = messages[messages.length - 1]!;
return [
...messages.slice(0, -1),
{
...last,
providerOptions: {
...last.providerOptions,
// Merged, not replaced: the breakpoint is one Anthropic option among any
// others the message already carries.
anthropic: { ...last.providerOptions?.anthropic, cacheControl: PROMPT_CACHE_CONTROL },
},
// Merged, not replaced: the breakpoint is one provider option among any
// others the message already carries.
providerOptions: withCacheBreakpoint(last.providerOptions, "prefix"),
},
];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ describe("withCacheBreakpointOnLast", () => {
const prepared = withCacheBreakpointOnLast(lastMessageWithAnthropicOptions());

expect(prepared[1]!.providerOptions).toEqual({
__cacheBreakpoint: { kind: "prefix" },
anthropic: { cacheControl: PROMPT_CACHE_CONTROL, thinking: { budget: 1024 } },
openai: { store: false },
});
Expand All @@ -54,6 +55,7 @@ describe("prepareTurnMessages", () => {
});

expect(prepared[1]!.providerOptions).toEqual({
__cacheBreakpoint: { kind: "prefix" },
anthropic: { cacheControl: PROMPT_CACHE_CONTROL, thinking: { budget: 1024 } },
openai: { store: false },
});
Expand Down
4 changes: 2 additions & 2 deletions internal-packages/dashboard-agent/src/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { generateText, type ModelMessage, type UIMessage } from "ai";
import {
dashboardAgentModelKey,
latestCards,
registry,
resolveDashboardAgentModel,
sanitizeReplayedToolInputs,
} from "./agent-runtime";

Expand Down Expand Up @@ -271,7 +271,7 @@ export function renderTranscriptForSummary(messages: ModelMessage[]): string {

async function summarizeConversation(event: SummarizeEvent): Promise<string> {
const { text } = await generateText({
model: locals.get(dashboardAgentModelKey) ?? registry.languageModel(SUMMARY_MODEL),
model: locals.get(dashboardAgentModelKey) ?? resolveDashboardAgentModel(SUMMARY_MODEL),
system: SUMMARY_INSTRUCTION,
prompt: renderTranscriptForSummary(event.messages),
maxOutputTokens: SUMMARY_MAX_OUTPUT_TOKENS,
Expand Down
14 changes: 5 additions & 9 deletions internal-packages/dashboard-agent/src/dashboard-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
getStore,
getSystemPrompt,
modeFor,
registry,
resolveDashboardAgentModel,
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
sanitizeReplayedToolInputs,
settlementCardMessages,
clearOpenInvestigations,
Expand All @@ -25,7 +25,7 @@ import {
type DashboardAgentStore,
} from "./agent-runtime";
import { titlePrompt } from "./prompts";
import { PROMPT_CACHE_CONTROL } from "./prompt-prefix";
import { withCacheBreakpoint } from "./model-provider";
import { recordPromptCacheUsage, stepCachePrepareStep } from "./step-cache";
import { dashboardAgentActionSchema, handleWatchAction } from "./watch-actions";
import { dashboardAgentCompaction, withDurableState } from "./compaction";
Expand Down Expand Up @@ -309,9 +309,7 @@ async function generateAndSaveTitle(
const { text } = await generateText({
model:
locals.get(dashboardAgentModelKey) ??
registry.languageModel(
(resolved.model ?? "anthropic:claude-haiku-4-5") as `anthropic:${string}`
),
resolveDashboardAgentModel(resolved.model ?? "anthropic:claude-haiku-4-5"),
system: resolved.text,
prompt: userText,
...resolved.toAISDKTelemetry(),
Expand Down Expand Up @@ -428,7 +426,7 @@ export const dashboardAgent = chat.agent({
// prompt; the resolve is cached per process. The cache breakpoint on the system
// block carries through toStreamTextOptions() and survives suspend/resume.
chat.prompt.set(await getSystemPrompt(modeFor(clientData)), {
providerOptions: { anthropic: { cacheControl: PROMPT_CACHE_CONTROL } },
providerOptions: withCacheBreakpoint(undefined, "prefix"),
});
},

Expand Down Expand Up @@ -581,9 +579,7 @@ export const dashboardAgent = chat.agent({
...options,
model:
locals.get(dashboardAgentModelKey) ??
registry.languageModel(
(resolved.model ?? "anthropic:claude-sonnet-4-6") as `anthropic:${string}`
),
resolveDashboardAgentModel(resolved.model ?? "anthropic:claude-sonnet-4-6"),
messages,
abortSignal: signal,
prepareStep: stepCachePrepareStep(options) as never,
Expand Down
4 changes: 2 additions & 2 deletions internal-packages/dashboard-agent/src/eval-turn.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { anthropic } from "@ai-sdk/anthropic";
import {
createDashboardAgentDb,
insertTurnEval,
type DashboardAgentDbClient,
} from "@internal/dashboard-agent-db";
import { logger, task } from "@trigger.dev/sdk";
import { EVAL_ERROR_CATEGORIES, redactedEvalOutputErrored } from "./eval-policy";
import { resolveDashboardAgentModel } from "./model-provider";
import { generateObject } from "ai";
import { z } from "zod";

Expand Down Expand Up @@ -164,7 +164,7 @@ export const evalTurn = task({
id: "dashboard-agent-eval-turn",
run: async (payload: EvalTurnPayload, { ctx }) => {
const { object } = await generateObject({
model: anthropic(JUDGE_MODEL),
model: resolveDashboardAgentModel(`anthropic:${JUDGE_MODEL}`),
schema: TurnEval,
system: JUDGE_SYSTEM,
prompt: [
Expand Down
99 changes: 99 additions & 0 deletions internal-packages/dashboard-agent/src/model-provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { afterEach, describe, expect, it } from "vitest";
import { PROMPT_CACHE_CONTROL } from "./prompt-prefix";
import {
BEDROCK_MODEL_IDS,
isLongLivedCacheBreakpoint,
isStepCacheBreakpoint,
resolveDashboardAgentModel,
STEP_CACHE_CONTROL,
withCacheBreakpoint,
withoutCacheBreakpoint,
} from "./model-provider";

function useBedrock() {
process.env.DASHBOARD_AGENT_MODEL_PROVIDER = "bedrock";
}

afterEach(() => {
delete process.env.DASHBOARD_AGENT_MODEL_PROVIDER;
});

describe("resolveDashboardAgentModel", () => {
it("resolves a canonical prompt string against Anthropic by default", () => {
expect(resolveDashboardAgentModel("anthropic:claude-sonnet-4-6").modelId).toBe(
"claude-sonnet-4-6"
);
});

it("maps the same canonical string to a Bedrock inference profile", () => {
useBedrock();
expect(resolveDashboardAgentModel("anthropic:claude-sonnet-4-6").modelId).toBe(
"us.anthropic.claude-sonnet-4-6-v1"
);
expect(resolveDashboardAgentModel("anthropic:claude-haiku-4-5").modelId).toBe(
"us.anthropic.claude-haiku-4-5-20251001-v1:0"
);
});

it("throws rather than guessing a profile for an unmapped id", () => {
useBedrock();
expect(() => resolveDashboardAgentModel("anthropic:claude-made-up-9-9")).toThrow(
/No Bedrock model mapping/
);
});

// Structural, not an echo of the table: an AWS us cross-region Anthropic profile
// is either dated with a `:N` suffix, or an undated `-vN` (the 4-6 generation). A
// dated id must never drop its `:N`, and every id must end in a version.
it("every Bedrock-mapped id has a well-formed AWS inference-profile shape", () => {
const dated = /^us\.anthropic\.claude-[a-z]+(?:-\d+)+-\d{8}-v\d+:\d+$/;
const undated = /^us\.anthropic\.claude-[a-z]+(?:-\d+)+-v\d+$/;
for (const id of Object.values(BEDROCK_MODEL_IDS)) {
expect(dated.test(id) || undated.test(id), id).toBe(true);
if (/-\d{8}-/.test(id)) expect(id, id).toMatch(/:\d+$/);
}
});
});

describe("cache breakpoints", () => {
it("keeps the Anthropic cacheControl ttls intact, tagged with the discriminator", () => {
expect(withCacheBreakpoint({ openai: { store: false } }, "prefix")).toEqual({
__cacheBreakpoint: { kind: "prefix" },
openai: { store: false },
anthropic: { cacheControl: PROMPT_CACHE_CONTROL },
});
expect(withCacheBreakpoint(undefined, "step")).toEqual({
__cacheBreakpoint: { kind: "step" },
anthropic: { cacheControl: STEP_CACHE_CONTROL },
});
});

it("emits a plain Bedrock cachePoint with no ttl for either marker", () => {
useBedrock();
for (const breakpoint of ["prefix", "step"] as const) {
const options = withCacheBreakpoint(undefined, breakpoint);
// The only thing the SDK serialises to AWS is bedrock.cachePoint — it must be plain.
expect(options.bedrock.cachePoint).toEqual({ type: "default" });
expect(options.bedrock.cachePoint).not.toHaveProperty("ttl");
expect(options.__cacheBreakpoint).toEqual({ kind: breakpoint });
}
});

it("classifies and strips the active provider's breakpoint via the discriminator", () => {
const anthropicStep = withCacheBreakpoint({ anthropic: { keep: true } }, "step");
expect(isStepCacheBreakpoint(anthropicStep)).toBe(true);
expect(isLongLivedCacheBreakpoint(withCacheBreakpoint(undefined, "prefix"))).toBe(true);
// The strip removes both the provider field and the top-level discriminator.
expect(withoutCacheBreakpoint(anthropicStep)).toEqual({ anthropic: { keep: true } });

useBedrock();
const bedrockStep = withCacheBreakpoint(undefined, "step");
const bedrockPrefix = withCacheBreakpoint(undefined, "prefix");
// The two Bedrock markers are byte-identical on the wire — only the tag tells them apart.
expect(bedrockStep.bedrock).toEqual(bedrockPrefix.bedrock);
expect(isStepCacheBreakpoint(bedrockStep)).toBe(true);
expect(isLongLivedCacheBreakpoint(bedrockStep)).toBe(false);
expect(isLongLivedCacheBreakpoint(bedrockPrefix)).toBe(true);
expect(withoutCacheBreakpoint(bedrockStep)).toEqual({});
});
});
Loading