Skip to content
Merged
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
34 changes: 14 additions & 20 deletions src/agent_env/explorer/ui/src/components/task-instance-viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,21 +81,23 @@ 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<Array<{
kind: string;
text?: string;
file?: { name?: string; uri?: string };
data?: unknown;
}> | 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;
Expand Down Expand Up @@ -248,21 +250,14 @@ export function TaskInstanceViewer({
// since the parent doesn't memoize.
const promptResponsesArr = (
instance.context as Record<string, unknown> | 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],
Expand All @@ -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
Expand All @@ -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 });
}
}

Expand Down Expand Up @@ -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<string, unknown>[])
: [];
Expand Down
16 changes: 16 additions & 0 deletions src/agent_env/explorer/ui/src/lib/bar-downloads.smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
11 changes: 5 additions & 6 deletions src/agent_env/explorer/ui/src/lib/bar-downloads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down
10 changes: 5 additions & 5 deletions src/agent_env/explorer/ui/src/lib/materialize.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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[])
Expand All @@ -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,
);
}
Expand Down Expand Up @@ -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] : [];
});
Expand Down
121 changes: 121 additions & 0 deletions src/agent_env/explorer/ui/src/lib/trajectory-url.smoke.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): unknown[] {
const materialized = materializeInstance(inst, undefined);
return (materialized.trajectory as Record<string, unknown>[])
.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<string, unknown>) =>
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();
30 changes: 30 additions & 0 deletions src/agent_env/explorer/ui/src/lib/trajectory-url.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading