From 953a478129b1c725a13e71c3a7c45e78b3523942 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Wed, 7 Oct 2026 13:20:25 -0700 Subject: [PATCH] feat(explorer): read prompt-response trajectory URLs under either key The explorer reads a prompt response's trajectory URL and per-turn URLs through one accessor, lib/trajectory-url.ts: the S3-named key first while both are written, falling through to the object-store name when it is null or missing, as core reads them. The task-instance viewer, the tab bar's downloads and the runner's materialized conversation use it, so an instance recorded under either key shows its trajectories. The viewer's unused compact_trajectory_s3_uri field goes. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/components/task-instance-viewer.tsx | 34 ++--- .../ui/src/lib/bar-downloads.smoke.ts | 16 +++ .../explorer/ui/src/lib/bar-downloads.ts | 11 +- .../explorer/ui/src/lib/materialize.ts | 10 +- .../ui/src/lib/trajectory-url.smoke.ts | 121 ++++++++++++++++++ .../explorer/ui/src/lib/trajectory-url.ts | 30 +++++ 6 files changed, 191 insertions(+), 31 deletions(-) create mode 100644 src/agent_env/explorer/ui/src/lib/trajectory-url.smoke.ts create mode 100644 src/agent_env/explorer/ui/src/lib/trajectory-url.ts diff --git a/src/agent_env/explorer/ui/src/components/task-instance-viewer.tsx b/src/agent_env/explorer/ui/src/components/task-instance-viewer.tsx index 487af7e3..8223eb0c 100644 --- a/src/agent_env/explorer/ui/src/components/task-instance-viewer.tsx +++ b/src/agent_env/explorer/ui/src/components/task-instance-viewer.tsx @@ -81,13 +81,16 @@ import { type OtelSpan, parseOtelTrajectory, } from '../lib/parse-trajectory'; +import { + type TrajectoryUrlKeys, + perTurnTrajectoryUrls, + trajectoryUrl, +} from '../lib/trajectory-url'; -interface PromptResponseData { +interface PromptResponseData extends TrajectoryUrlKeys { prompt_id: string; response: string; prompt_text?: string; - agent_trajectory_s3_uri?: string; - target_agent_per_turn_trajectory_s3_uris?: (string | null)[]; // A null entry means "identical to prompt_text" (the initial prompt is stored once). source_agent_per_turn_prompt_parts?: Array | null>; - compact_trajectory_s3_uri?: string; model?: string; // step_id is the human-readable step name (e.g. "run-solver"). Older instances may have only prompt_id. step_id?: string; @@ -248,21 +250,14 @@ export function TaskInstanceViewer({ // since the parent doesn't memoize. const promptResponsesArr = ( instance.context as Record | null - )?.prompt_responses as - | { - agent_trajectory_s3_uri?: string; - target_agent_per_turn_trajectory_s3_uris?: (string | null)[]; - }[] - | undefined; + )?.prompt_responses as TrajectoryUrlKeys[] | undefined; const promptResponsesSignature = useMemo( () => (promptResponsesArr ?? []) .map(pr => { // Include per-turn URIs in the signature so the effect re-fires when new turns land between polls. - const perTurn = ( - pr?.target_agent_per_turn_trajectory_s3_uris ?? [] - ).join(','); - return `${pr?.agent_trajectory_s3_uri ?? ''}#${perTurn}`; + const perTurn = (perTurnTrajectoryUrls(pr) ?? []).join(','); + return `${trajectoryUrl(pr) ?? ''}#${perTurn}`; }) .join('|'), [promptResponsesArr], @@ -285,7 +280,8 @@ export function TaskInstanceViewer({ }; const flat: FlatPR[] = []; for (const pr of promptResponses) { - const perTurn = pr.target_agent_per_turn_trajectory_s3_uris; + const perTurn = perTurnTrajectoryUrls(pr); + const url = trajectoryUrl(pr); if (perTurn && perTurn.length > 0) { perTurn.forEach((uri, turnIdx) => { if (!uri) return; // skip turns whose trajectory upload failed @@ -296,8 +292,8 @@ export function TaskInstanceViewer({ _totalTurns: perTurn.length, }); }); - } else if (pr.agent_trajectory_s3_uri) { - flat.push({ ...pr, _entryS3Uri: pr.agent_trajectory_s3_uri }); + } else if (url) { + flat.push({ ...pr, _entryS3Uri: url }); } } @@ -551,9 +547,7 @@ export function TaskInstanceViewer({ const promptResponses = context ? ((context.prompt_responses ?? []) as PromptResponseData[]) : []; - const hasTrajectories = promptResponses.some( - pr => pr.agent_trajectory_s3_uri, - ); + const hasTrajectories = promptResponses.some(pr => trajectoryUrl(pr)); const deployedEnvs = context ? ((context.deployed_envs ?? []) as Record[]) : []; diff --git a/src/agent_env/explorer/ui/src/lib/bar-downloads.smoke.ts b/src/agent_env/explorer/ui/src/lib/bar-downloads.smoke.ts index 5aa1cf9e..a1863f2b 100644 --- a/src/agent_env/explorer/ui/src/lib/bar-downloads.smoke.ts +++ b/src/agent_env/explorer/ui/src/lib/bar-downloads.smoke.ts @@ -182,6 +182,22 @@ function main(): void { ); } + // Trajectories recorded under the object-store name are listed the same way. + { + const downloads = buildBarDownloads( + [ + { agent_trajectory_object_url: 'obj://t/1', step_id: 'a' }, + { agent_trajectory_s3_uri: 'obj://t/2', step_id: 'b' }, + { agent_trajectory_s3_uri: null, agent_trajectory_object_url: 'obj://t/3', step_id: 'c' }, + ], + undefined, + ); + assert( + downloads.map(d => d.s3Uri).join(',') === 'obj://t/1,obj://t/2,obj://t/3', + 'a trajectory under either key is downloadable', + ); + } + if (failures > 0) { console.error(`\n${failures} assertion(s) failed`); process.exit(1); diff --git a/src/agent_env/explorer/ui/src/lib/bar-downloads.ts b/src/agent_env/explorer/ui/src/lib/bar-downloads.ts index 3851b6c6..cda0dd31 100644 --- a/src/agent_env/explorer/ui/src/lib/bar-downloads.ts +++ b/src/agent_env/explorer/ui/src/lib/bar-downloads.ts @@ -6,9 +6,10 @@ /** How many downloads may sit inline in the bar before they collapse. */ export const MAX_INLINE_BAR_DOWNLOADS = 2; +import { type TrajectoryUrlKeys, trajectoryUrl } from './trajectory-url'; + /** The subset of a prompt-response the bar needs to label a trajectory. */ -export interface BarDownloadTrajectory { - agent_trajectory_s3_uri?: string; +export interface BarDownloadTrajectory extends TrajectoryUrlKeys { step_id?: string; prompt_id?: string | null; model?: string; @@ -31,16 +32,14 @@ export function buildBarDownloads( promptResponses: BarDownloadTrajectory[], resultS3Uri?: string, ): BarDownload[] { - const withTrajectory = promptResponses.filter( - pr => pr.agent_trajectory_s3_uri, - ); + const withTrajectory = promptResponses.filter(pr => trajectoryUrl(pr)); const single = withTrajectory.length === 1; const downloads: BarDownload[] = withTrajectory.map((pr, i) => { const label = single ? 'Trajectory' : pr.step_id || pr.prompt_id || `Trajectory ${i + 1}`; - const s3Uri = pr.agent_trajectory_s3_uri as string; + const s3Uri = trajectoryUrl(pr) as string; return { key: s3Uri, label, diff --git a/src/agent_env/explorer/ui/src/lib/materialize.ts b/src/agent_env/explorer/ui/src/lib/materialize.ts index e0bb1d9c..5b99478f 100644 --- a/src/agent_env/explorer/ui/src/lib/materialize.ts +++ b/src/agent_env/explorer/ui/src/lib/materialize.ts @@ -1,6 +1,8 @@ // Pure derivation of `materialized` for a task-runner submission. Split out of // task-runner-page.tsx, which was ~90 KB. +import { perTurnTrajectoryUrls, trajectoryUrl } from './trajectory-url'; + /** Opt-in per-instance fields. Everything else (conversation, prompt, systemPrompt, defaultModel, * environmentId, snapshot id, summary) is derived from the instance record, so it's free. */ export type MaterializeOptions = { @@ -85,9 +87,7 @@ function buildConversation( turn += 1; }; - const innerUris = Array.isArray(resp.target_agent_per_turn_trajectory_s3_uris) - ? (resp.target_agent_per_turn_trajectory_s3_uris as unknown[]) - : null; + const innerUris = perTurnTrajectoryUrls(resp) ?? null; if (innerUris?.length) { const innerParts = Array.isArray(resp.source_agent_per_turn_prompt_parts) ? (resp.source_agent_per_turn_prompt_parts as unknown[]) @@ -109,7 +109,7 @@ function buildConversation( pushTurn( str(resp.prompt_text), str(resp.response), - str(resp.agent_trajectory_s3_uri), + str(trajectoryUrl(resp)), typeof resp.tool_call_count === 'number' ? resp.tool_call_count : 0, ); } @@ -201,7 +201,7 @@ export function materializeInstance( // Pure per-turn captures for anything that runs a parser. A turn whose records haven't arrived is OMITTED // (not []), so "not fetched" is distinguishable from "empty"; a later submission fills it in. const records = responses.flatMap((resp, i) => { - const uri = str(resp.agent_trajectory_s3_uri); + const uri = str(trajectoryUrl(resp)); const recs = uri ? trajectoryCache?.get(uri) : undefined; return recs ? [[i, recs] as const] : []; }); diff --git a/src/agent_env/explorer/ui/src/lib/trajectory-url.smoke.ts b/src/agent_env/explorer/ui/src/lib/trajectory-url.smoke.ts new file mode 100644 index 00000000..c25eec5d --- /dev/null +++ b/src/agent_env/explorer/ui/src/lib/trajectory-url.smoke.ts @@ -0,0 +1,121 @@ +/** + * Smoke test for reading a prompt response's trajectory URLs under either key. + * + * Runner: plain TS, exits non-zero on assertion failure. From this package: + * npx tsx src/lib/trajectory-url.smoke.ts + */ +import { materializeInstance } from './materialize'; +import { perTurnTrajectoryUrls, trajectoryUrl } from './trajectory-url'; + +let failures = 0; +function assert(cond: unknown, msg: string): void { + if (cond) { + console.log(`✓ ${msg}`); + } else { + failures += 1; + console.error(`✗ ${msg}`); + } +} + +const TURNS = ['obj://t/1.json', null, 'obj://t/2.json']; + +function turnUrls(inst: Record): unknown[] { + const materialized = materializeInstance(inst, undefined); + return (materialized.trajectory as Record[]) + .filter(m => m.role === 'user') + .map(m => m.trajectoryS3Uri); +} + +function main(): void { + { + const legacy = { + agent_trajectory_s3_uri: 'obj://t/2.json', + target_agent_per_turn_trajectory_s3_uris: TURNS, + }; + const neutral = { + agent_trajectory_object_url: 'obj://t/2.json', + target_agent_per_turn_trajectory_object_urls: TURNS, + }; + for (const [name, pr] of [ + ['legacy', legacy], + ['neutral', neutral], + ] as const) { + assert( + trajectoryUrl(pr) === 'obj://t/2.json', + `${name} keys: the trajectory URL`, + ); + assert( + JSON.stringify(perTurnTrajectoryUrls(pr)) === JSON.stringify(TURNS), + `${name} keys: the per-turn URLs, a failed turn kept as null`, + ); + } + } + + // The S3-named key wins while both are written: only a raw-doc writer that knows nothing else makes them differ. + { + const pr = { + agent_trajectory_s3_uri: 'obj://t/new.json', + agent_trajectory_object_url: 'obj://t/stale.json', + target_agent_per_turn_trajectory_s3_uris: [], + target_agent_per_turn_trajectory_object_urls: ['obj://t/stale.json'], + }; + assert( + trajectoryUrl(pr) === 'obj://t/new.json', + 'the S3-named trajectory URL wins', + ); + assert( + perTurnTrajectoryUrls(pr)?.length === 0, + 'an empty S3-named per-turn list wins', + ); + } + + { + const pr = { + agent_trajectory_s3_uri: null, + agent_trajectory_object_url: 'obj://t/2.json', + }; + assert( + trajectoryUrl(pr) === 'obj://t/2.json', + 'a null S3-named key falls through', + ); + assert(trajectoryUrl({}) === undefined, 'no key: no trajectory'); + assert(perTurnTrajectoryUrls({}) === undefined, 'no key: no per-turn list'); + assert( + trajectoryUrl({ agent_trajectory_object_url: 42 }) === undefined, + 'a non-string URL is ignored', + ); + } + + // materializeInstance reads them too: one user turn per per-turn URL, else one for the response's URL. + { + const response = { prompt_id: 'p1', response: 'done', prompt_text: 'go' }; + const multi = (keys: Record) => + turnUrls({ context: { prompt_responses: [{ ...response, ...keys }] } }); + assert( + JSON.stringify( + multi({ target_agent_per_turn_trajectory_object_urls: TURNS }), + ) === JSON.stringify(TURNS), + 'materialize: per-turn URLs under the object-store name', + ); + assert( + JSON.stringify( + multi({ target_agent_per_turn_trajectory_s3_uris: TURNS }), + ) === JSON.stringify(TURNS), + 'materialize: per-turn URLs under the S3-named key', + ); + assert( + JSON.stringify( + multi({ agent_trajectory_object_url: 'obj://t/only.json' }), + ) === JSON.stringify(['obj://t/only.json']), + 'materialize: a single-turn URL under the object-store name', + ); + } + + if (failures > 0) { + console.error(`\n${failures} assertion(s) failed`); + process.exit(1); + } + console.log('\nall assertions passed'); +} + +main(); diff --git a/src/agent_env/explorer/ui/src/lib/trajectory-url.ts b/src/agent_env/explorer/ui/src/lib/trajectory-url.ts new file mode 100644 index 00000000..7668e7d7 --- /dev/null +++ b/src/agent_env/explorer/ui/src/lib/trajectory-url.ts @@ -0,0 +1,30 @@ +/** A prompt response's trajectory URLs. Core writes each under an object-store name beside its S3-named key, and + * instances recorded before that carry only the S3-named one. Read the S3-named key first while both are written + * (a raw-doc writer that knows only it leaves the other stale), falling through to the other when it is null or + * missing — the order core's `read_dual_keyed` reads them in. */ +export interface TrajectoryUrlKeys { + agent_trajectory_s3_uri?: unknown; + agent_trajectory_object_url?: unknown; + target_agent_per_turn_trajectory_s3_uris?: unknown; + target_agent_per_turn_trajectory_object_urls?: unknown; +} + +/** The trajectory of the response's last turn, if it has one. */ +export function trajectoryUrl( + pr: TrajectoryUrlKeys | null | undefined, +): string | undefined { + const url = pr?.agent_trajectory_s3_uri ?? pr?.agent_trajectory_object_url; + return typeof url === 'string' && url ? url : undefined; +} + +/** One trajectory per turn of a multi-turn response; null for a turn whose upload failed. */ +export function perTurnTrajectoryUrls( + pr: TrajectoryUrlKeys | null | undefined, +): (string | null)[] | undefined { + const urls = + pr?.target_agent_per_turn_trajectory_s3_uris ?? + pr?.target_agent_per_turn_trajectory_object_urls; + return Array.isArray(urls) + ? urls.map(url => (typeof url === 'string' && url ? url : null)) + : undefined; +}