Skip to content

Commit 4595363

Browse files
committed
feat(dashboard-agent): explicit project/environment target on every read tool
Every environment-bound read now accepts project/environment/branch, with the chat's own scope as the default rather than the limit: list_tasks, list_errors, get_query_schema, run_query, get_report, list_deploys and get_deploy join the tools that already had it, and the source tools pass the target through the run-snapshot resolution. run_query's POST goes out on the target's env JWT. Preview and dev branches are targetable as environment "preview"/"dev" plus the branchName list_environments returned, forwarded as x-trigger-branch on both the JWT exchange and the delegated-token routes. A target naming a project that list_projects did not report this turn is refused before any exchange. That is client hygiene to save a pointless round trip, not a boundary: authorization stays server-side on the exchange and every route. Prompt prefix grows to 80,937 chars (assistant) / 88,550 (code); the ceilings and the committed measurement move with it.
1 parent 3dfacb7 commit 4595363

9 files changed

Lines changed: 458 additions & 137 deletions

File tree

‎internal-packages/dashboard-agent/src/__snapshots__/prompt-prefix.test.ts.snap‎

Lines changed: 14 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎internal-packages/dashboard-agent/src/prompt-prefix.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,8 @@ describe("the head-start and agent prefixes are the same prefix", () => {
8888
* drift. The snapshot below is the itemised diff a reviewer reads.
8989
*/
9090
const PREFIX_BUDGET = {
91-
assistant: { chars: 77_500, estimatedTokens: 19_500, tools: 24, promptChars: 27_000 },
92-
code: { chars: 83_500, estimatedTokens: 21_000, tools: 28, promptChars: 29_600 },
91+
assistant: { chars: 81_300, estimatedTokens: 20_600, tools: 24, promptChars: 27_800 },
92+
code: { chars: 88_900, estimatedTokens: 22_500, tools: 28, promptChars: 30_300 },
9393
} as const;
9494

9595
describe("the prefix stays inside its budget", () => {

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

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { isAbsolute, join, relative, resolve, sep } from "node:path";
66
import { promisify } from "node:util";
77
import { sliceWellFormed } from "@internal/dashboard-agent-contracts";
88
import { tool, type ToolSet } from "ai";
9+
import { crossProjectTarget, type ApiTarget, type TargetInput } from "./tool-api-client";
910
import {
1011
getRepoInfoSchema,
1112
listFilesSchema,
@@ -209,19 +210,25 @@ export async function disposeRepoWorkspaces(): Promise<void> {
209210
}
210211

211212
/** Resolve a run-pinned snapshot for a runId, or null. See the webapp's repo/snapshot route. */
212-
export type RunSnapshotResolver = (runId: string) => Promise<RepoSnapshot | null>;
213+
export type RunSnapshotResolver = (
214+
runId: string,
215+
target?: ApiTarget
216+
) => Promise<RepoSnapshot | null>;
213217

214218
export function buildRepoTools(
215219
defaultSnapshot: RepoSnapshot,
216220
resolveRunSnapshot?: RunSnapshotResolver
217221
): ToolSet {
218222
// Pick the snapshot for a call: a runId pins to that run's deployed commit
219223
// (resolved server-side), otherwise the default tracked-branch snapshot.
220-
async function snapshotFor(runId?: string): Promise<RepoSnapshot | { error: string }> {
224+
async function snapshotFor(
225+
runId?: string,
226+
target?: TargetInput
227+
): Promise<RepoSnapshot | { error: string }> {
221228
if (!runId) return defaultSnapshot;
222229
if (!resolveRunSnapshot)
223230
return { error: "Reading a specific run's source isn't available here." };
224-
const snap = await resolveRunSnapshot(runId);
231+
const snap = await resolveRunSnapshot(runId, target && crossProjectTarget(target));
225232
return (
226233
snap ?? {
227234
error: `Couldn't resolve the source for ${runId} (it may be a dev run, or the project has no connected repo).`,
@@ -232,9 +239,10 @@ export function buildRepoTools(
232239
// snapshotFor + ensureWorkspace, returning the workdir (plus the snapshot's dirty
233240
// stamp, for tools that surface it) or an error result.
234241
async function loadWorkdir(
235-
runId?: string
242+
runId?: string,
243+
target?: TargetInput
236244
): Promise<{ workdir: string; dirty: boolean } | { error: string }> {
237-
const snap = await snapshotFor(runId);
245+
const snap = await snapshotFor(runId, target);
238246
if ("error" in snap) return snap;
239247
try {
240248
// Canonicalize the root so the per-tool realpath checks below compare
@@ -251,8 +259,8 @@ export function buildRepoTools(
251259
return {
252260
get_repo_info: tool({
253261
...getRepoInfoSchema,
254-
execute: async ({ runId }) => {
255-
const snap = await snapshotFor(runId);
262+
execute: async ({ runId, project, environment, branch }) => {
263+
const snap = await snapshotFor(runId, { project, environment, branch });
256264
if ("error" in snap) return snap;
257265
return {
258266
owner: snap.owner,
@@ -266,8 +274,8 @@ export function buildRepoTools(
266274

267275
list_files: tool({
268276
...listFilesSchema,
269-
execute: async ({ glob, path, runId }) => {
270-
const loaded = await loadWorkdir(runId);
277+
execute: async ({ glob, path, runId, project, environment, branch }) => {
278+
const loaded = await loadWorkdir(runId, { project, environment, branch });
271279
if ("error" in loaded) return loaded;
272280
const { workdir } = loaded;
273281
const args = ["--files"];
@@ -300,8 +308,8 @@ export function buildRepoTools(
300308

301309
read_file: tool({
302310
...readFileSchema,
303-
execute: async ({ path, startLine, endLine, runId }) => {
304-
const loaded = await loadWorkdir(runId);
311+
execute: async ({ path, startLine, endLine, runId, project, environment, branch }) => {
312+
const loaded = await loadWorkdir(runId, { project, environment, branch });
305313
if ("error" in loaded) return loaded;
306314
const { workdir, dirty } = loaded;
307315
const target = safeResolve(workdir, path);
@@ -350,8 +358,8 @@ export function buildRepoTools(
350358

351359
search_code: tool({
352360
...searchCodeSchema,
353-
execute: async ({ query, glob, maxResults, runId }) => {
354-
const loaded = await loadWorkdir(runId);
361+
execute: async ({ query, glob, maxResults, runId, project, environment, branch }) => {
362+
const loaded = await loadWorkdir(runId, { project, environment, branch });
355363
if ("error" in loaded) return loaded;
356364
const { workdir } = loaded;
357365
const cap = Math.min(maxResults ?? 40, MAX_MATCHES);

‎internal-packages/dashboard-agent/src/tool-api-client.ts‎

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,11 @@ export type DashboardAgentApiClient = {
132132
hasAuth: boolean;
133133
/** A GET as the environment JWT, or why no environment JWT could be made. */
134134
envApiGet(path: string, target?: ApiTarget): Promise<EnvFetchResult>;
135-
postQuery(query: string, period: string | undefined): Promise<QueryPostResult | EnvUnavailable>;
135+
postQuery(
136+
query: string,
137+
period: string | undefined,
138+
target?: ApiTarget
139+
): Promise<QueryPostResult | EnvUnavailable>;
136140
validateChartQuery(query: string, period: string | undefined): Promise<string | null>;
137141
/**
138142
* The canonical RuntimeEnvironment id for a target, proven by the same JWT exchange
@@ -153,8 +157,26 @@ export type ApiClientContext = {
153157

154158
// A per-call override of which project/environment a data lookup targets, for reads
155159
// that cross into another project of the same organization. Omitted fields fall back
156-
// to the context's own project/environment.
157-
export type ApiTarget = { projectRef?: string; environmentName?: string };
160+
// to the context's own project/environment. `branch` picks a preview/dev branch out of
161+
// the family its name addresses; without it the name resolves to the parent.
162+
export type ApiTarget = { projectRef?: string; environmentName?: string; branch?: string };
163+
164+
/** A tool call's explicit target: the project, environment and branch it names. */
165+
export type TargetInput = { project?: string; environment?: string; branch?: string };
166+
167+
/**
168+
* A tool call's optional target as an `ApiTarget`. `undefined` when nothing was given, so
169+
* the default (ctx-scoped, branch-aware) path is unchanged rather than re-derived from ctx
170+
* through an override.
171+
*/
172+
export function crossProjectTarget(input: TargetInput): ApiTarget | undefined {
173+
if (!input.project && !input.environment && !input.branch) return undefined;
174+
return {
175+
projectRef: input.project,
176+
environmentName: input.environment,
177+
branch: input.branch,
178+
};
179+
}
158180

159181
export function createApiClient(ctx: ApiClientContext): DashboardAgentApiClient {
160182
const { userActorToken, apiOrigin, projectRef, environmentName, environmentBranch } = ctx;
@@ -166,12 +188,12 @@ export function createApiClient(ctx: ApiClientContext): DashboardAgentApiClient
166188
type EnvJwt = { ok: true; token: string } | EnvUnavailable;
167189
const envJwts = new Map<string, Promise<EnvJwt>>();
168190
function getEnvJwt(refresh = false, target?: ApiTarget): Promise<EnvJwt> {
169-
// An override drops the branch: it names another project/environment, which the
170-
// current branch can't be assumed to apply to. A field left off the override still
171-
// falls back to ctx's own value.
191+
// An override carries its own branch or none: it names another project/environment,
192+
// which the current branch can't be assumed to apply to. A field left off the override
193+
// still falls back to ctx's own value.
172194
const ref = target?.projectRef ?? projectRef;
173195
const env = target?.environmentName ?? environmentName;
174-
const branch = target ? undefined : environmentBranch;
196+
const branch = target ? target.branch : environmentBranch;
175197
if (!hasAuth || !ref || !env) return Promise.resolve(MISSING_ENV);
176198
const key = `${ref}/${env}/${branch ?? ""}`;
177199
if (refresh) envJwts.delete(key);
@@ -216,7 +238,8 @@ export function createApiClient(ctx: ApiClientContext): DashboardAgentApiClient
216238
// re-exchange on a 401. Shared by run_query and chart-block validation.
217239
async function postQuery(
218240
query: string,
219-
period: string | undefined
241+
period: string | undefined,
242+
target?: ApiTarget
220243
): Promise<QueryPostResult | EnvUnavailable> {
221244
const attempt = await withEnvJwt<{ res: Response } | { error: string }>(
222245
async (jwt) => {
@@ -237,7 +260,8 @@ export function createApiClient(ctx: ApiClientContext): DashboardAgentApiClient
237260
return { error: `Query request failed: ${(error as Error).message}` };
238261
}
239262
},
240-
(result) => "res" in result && result.res.status === 401
263+
(result) => "res" in result && result.res.status === 401,
264+
target
241265
);
242266
if (isEnvUnavailable(attempt)) return attempt;
243267
if ("error" in attempt) return { ok: false, kind: "transport", error: attempt.error };

0 commit comments

Comments
 (0)