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 8223eb0c..17d435de 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 @@ -106,30 +106,13 @@ interface PromptResponseData extends TrajectoryUrlKeys { interface TrajectoryState { // Lazy-load: trajectories start 'idle' so a multi-step task doesn't fire N parallel fetches on mount. status: 'idle' | 'loading' | 'loaded' | 'error'; - s3Uri: string; + objectUrl: string; // The prompt the agent received for this step. Multi-step tasks each have a distinct prompt, rendered above their trajectory section. promptText?: string; trajectory?: ParsedTrajectory; error?: string; } -interface FetchState { - status: 'idle' | 'loading' | 'loaded' | 'error'; - data?: unknown; - error?: string; -} - -interface UsersimModelData { - modelName: string; - trajectory: FetchState; - milestones: FetchState; - conversationLog: FetchState; - usersimResult: FetchState; - outputUrls: Record; - snapshotS3Uri: string | null; - score: { overall_score: number; overall_score_rationale: string } | null; -} - export interface TaskStepRef { id: string; prompt_id?: string | null; @@ -178,13 +161,10 @@ export function TaskInstanceViewer({ nextTurnPromptText?: string; }[] >([]); - const [usersimModels, setUsersimModels] = useState([]); - const instanceGenRef = useRef(0); const [copiedContext, setCopiedContext] = useState(false); const [selectedOverviewFile, setSelectedOverviewFile] = useState< string | null >(null); - const [copiedUsersim, setCopiedUsersim] = useState(null); // Per-deployment A2A cards for the Agent Card tab. The registered agent doc carries no card; it lives in // context.deployed_agents[].a2a_card, which the list endpoint strips — fall back to the single-instance GET. const deployedAgentsForCard = ((instance.context as Record | null) @@ -235,16 +215,8 @@ export function TaskInstanceViewer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [taskId, instanceId, agentCardSig]); - const handleCopyJson = useCallback((key: string, data: unknown) => { - navigator.clipboard.writeText(JSON.stringify(data, null, 2)); - setCopiedUsersim(key); - setTimeout(() => setCopiedUsersim(null), 1500); - }, []); - const [expandedTriggers, setExpandedTriggers] = useState>( - new Set(), - ); - // Build trajectory entries (one per prompt_response with an S3 URI), all 'idle' — no eager fetch (a + // Build trajectory entries (one per prompt_response with a trajectory URL), all 'idle' — no eager fetch (a // multi-step task can have 5+). Auto-load only when there's exactly one. Re-run when the prompt_responses // array reference changes (task-detail swaps a thin row for the full doc), keyed on a content signature // since the parent doesn't memoize. @@ -274,7 +246,7 @@ export function TaskInstanceViewer({ // Fan-out: each PromptResponse becomes 1+ entries — one per non-null turn URI for multi-turn, else one. type FlatPR = PromptResponseData & { - _entryS3Uri: string; + _entryObjectUrl: string; _turnIndex?: number; _totalTurns?: number; }; @@ -287,13 +259,13 @@ export function TaskInstanceViewer({ if (!uri) return; // skip turns whose trajectory upload failed flat.push({ ...pr, - _entryS3Uri: uri, + _entryObjectUrl: uri, _turnIndex: turnIdx, _totalTurns: perTurn.length, }); }); } else if (url) { - flat.push({ ...pr, _entryS3Uri: url }); + flat.push({ ...pr, _entryObjectUrl: url }); } } @@ -317,14 +289,14 @@ export function TaskInstanceViewer({ }); setTrajectories(prev => { - // Index prior loaded/loading state by s3Uri so a re-fire (e.g. taskSteps arriving late) doesn't undo + // Index prior loaded/loading state by objectUrl so a re-fire (e.g. taskSteps arriving late) doesn't undo // in-progress work — only label/order is recomputed, payloads preserved. const prevByUri = new Map(); - for (const t of prev) prevByUri.set(t.state.s3Uri, t.state); + for (const t of prev) prevByUri.set(t.state.objectUrl, t.state); return ordered.map((pr, i) => { const resolvedStepId = stepIdByPrompt[pr.prompt_id] ?? pr.step_id; - const carryover = prevByUri.get(pr._entryS3Uri); + const carryover = prevByUri.get(pr._entryObjectUrl); const initialStatus: TrajectoryState['status'] = carryover?.status ?? (ordered.length === 1 ? 'loading' : 'idle'); const turnSuffix = @@ -373,7 +345,7 @@ export function TaskInstanceViewer({ nextTurnPromptText, state: { status: initialStatus, - s3Uri: pr._entryS3Uri, + objectUrl: pr._entryObjectUrl, promptText, trajectory: carryover?.trajectory, error: carryover?.error, @@ -387,7 +359,7 @@ export function TaskInstanceViewer({ let cancelled = false; if (ordered.length === 1) { const pr = ordered[0]!; - fetchTrajectory(pr._entryS3Uri, pr.model) + fetchTrajectory(pr._entryObjectUrl, pr.model) .then(parsed => { if (cancelled) return; setTrajectories(prev => @@ -420,20 +392,20 @@ export function TaskInstanceViewer({ (index: number) => { const target = trajectories[index]; if (!target) return; - // Commit by s3Uri, not array index: `trajectories` can be rebuilt/reordered while a load is in flight. - const s3Uri = target.state.s3Uri; + // Commit by objectUrl, not array index: `trajectories` can be rebuilt/reordered while a load is in flight. + const objectUrl = target.state.objectUrl; setTrajectories(prev => prev.map(t => - t.state.s3Uri === s3Uri + t.state.objectUrl === objectUrl ? { ...t, state: { ...t.state, status: 'loading' } } : t, ), ); - fetchTrajectory(s3Uri) + fetchTrajectory(objectUrl) .then(parsed => { setTrajectories(prev => prev.map(t => - t.state.s3Uri === s3Uri + t.state.objectUrl === objectUrl ? { ...t, state: { ...t.state, status: 'loaded', trajectory: parsed }, @@ -445,7 +417,7 @@ export function TaskInstanceViewer({ .catch(err => { setTrajectories(prev => prev.map(t => - t.state.s3Uri === s3Uri + t.state.objectUrl === objectUrl ? { ...t, state: { @@ -463,86 +435,6 @@ export function TaskInstanceViewer({ [trajectories], ); - useEffect(() => { - const context = instance.context as Record | null; - if (!context) return; - const meta = context.metadata as Record | undefined; - if (!meta) return; - const trajectoryUrls = meta.trajectory_urls as - | Record - | undefined; - if (!trajectoryUrls || Object.keys(trajectoryUrls).length === 0) return; - - const outputUrls = meta.output_urls as - | Record> - | undefined; - const snapshotUrls = meta.snapshot_urls as - | Record - | undefined; - const delivery = meta.delivery as Record | undefined; - const deliveryTrajectories = (delivery?.trajectories ?? []) as { - model: string; - overall_score: number; - overall_score_rationale: string; - }[]; - const scoreByModel = new Map( - deliveryTrajectories.map(t => [ - t.model, - { - overall_score: t.overall_score, - overall_score_rationale: t.overall_score_rationale, - }, - ]), - ); - - const models = Object.keys(trajectoryUrls); - instanceGenRef.current += 1; - setUsersimModels( - models.map(modelName => ({ - modelName, - trajectory: { status: 'loading' }, - milestones: { status: 'idle' }, - conversationLog: { status: 'idle' }, - usersimResult: { status: 'idle' }, - outputUrls: outputUrls?.[modelName] ?? {}, - snapshotS3Uri: snapshotUrls?.[modelName] ?? null, - score: scoreByModel.get(modelName) ?? null, - })), - ); - - // Only fetch trajectories eagerly (default subtab) - const controller = new AbortController(); - models.forEach((modelName, i) => { - fetchRawS3Json(trajectoryUrls[modelName]!, controller.signal) - .then(data => { - if (!controller.signal.aborted) - setUsersimModels(prev => - prev.map((m, j) => - j === i ? { ...m, trajectory: { status: 'loaded', data } } : m, - ), - ); - }) - .catch(err => { - if (!controller.signal.aborted) - setUsersimModels(prev => - prev.map((m, j) => - j === i - ? { - ...m, - trajectory: { - status: 'error', - error: - err instanceof Error ? err.message : 'Failed to load', - }, - } - : m, - ), - ); - }); - }); - return () => controller.abort(); - }, [instanceId]); - const context = instance.context as Record | null; const promptResponses = context ? ((context.prompt_responses ?? []) as PromptResponseData[]) @@ -611,7 +503,7 @@ export function TaskInstanceViewer({ verifications && Object.keys(verifications).length > 0; // `collect_artifacts` writes a structured map context.metadata.collected_artifacts[step_id] = { artifacts: - // { filename: s3_uri }, file_artifact_universe }, plus a legacy flat context.metadata.artifacts mirror. + // { filename: object_url }, file_artifact_universe }, plus a legacy flat context.metadata.artifacts mirror. // Prefer the structured map; fall back to the flat mirror for older instances. const collectedArtifactsByStep = (metadata?.collected_artifacts ?? null) as Record }> | null; @@ -649,7 +541,7 @@ export function TaskInstanceViewer({ : []; })(); - // Flat merged map (filename -> s3_uri) for inline presigned links elsewhere + // Flat merged map (filename -> object_url) for inline content links elsewhere // (verifier-card path chips, reviewer-overview lookup). const collectedArtifacts: Record | null = collectedArtifactSections.length > 0 @@ -671,7 +563,7 @@ export function TaskInstanceViewer({ k.endsWith('reviewer_overview.html'), ) : undefined; - const reviewerOverviewS3 = + const reviewerOverviewUrl = reviewerOverviewKey && collectedArtifacts ? collectedArtifacts[reviewerOverviewKey] : null; @@ -695,130 +587,24 @@ export function TaskInstanceViewer({ ); const hasAggregateOrSandbox = aggregateVerifiers.length > 0 || sandboxVerifiers.length > 0; - const resultS3Uri = verifications - ? (( - Object.values(verifications).find( - v => (v as Record)?.result_s3_uri, - ) as Record | undefined - )?.result_s3_uri as string | undefined) - : undefined; - const hasUsersimModels = usersimModels.length > 0; - const taskData = metadata?.task_data as Record | undefined; - const completedRuns = (metadata?.completed_runs ?? []) as { - task_id: string; - run_epoch: number; - run_id: string; - model_label: string; - result: string; - s3_prefix: string; - trajectory_url?: string; - output_urls?: Record; - }[]; - const hasCompletedRuns = completedRuns.length > 0; - - const handleDownloadS3 = useCallback((s3Uri: string) => { - window.open(objectContentUrl(s3Uri), '_blank'); - }, []); - const userIntent = taskData?.user_intent as - | Record - | undefined; - const taskMilestones = (userIntent?.milestones ?? []) as { - milestone_id: string; - prompt: string; - continuation_criteria: string; - planned_interactions_list: { trigger: string; reaction: string }[]; - }[]; - const hasTaskMilestones = taskMilestones.length > 0; - - const lazyFetchField = useCallback( - ( - modelIndex: number, - field: 'milestones' | 'conversationLog' | 'usersimResult', - ) => { - const model = usersimModels[modelIndex]; - if (!model || model[field].status !== 'idle') return; - - const fileKey = { - milestones: 'milestone_progress.jsonl', - conversationLog: 'conversation_log.jsonl', - usersimResult: 'usersim_result.txt', - }[field]; - const s3Uri = model.outputUrls[fileKey]; - if (!s3Uri) { - setUsersimModels(prev => - prev.map((m, j) => - j === modelIndex - ? { ...m, [field]: { status: 'loaded', data: null } } - : m, - ), - ); - return; - } - - const gen = instanceGenRef.current; - setUsersimModels(prev => - prev.map((m, j) => - j === modelIndex ? { ...m, [field]: { status: 'loading' } } : m, - ), - ); - fetchRawS3Json(s3Uri) - .then(data => { - if (instanceGenRef.current !== gen) return; - setUsersimModels(prev => - prev.map((m, j) => - j === modelIndex - ? { ...m, [field]: { status: 'loaded', data } } - : m, - ), - ); - }) - .catch(err => { - if (instanceGenRef.current !== gen) return; - setUsersimModels(prev => - prev.map((m, j) => - j === modelIndex - ? { - ...m, - [field]: { - status: 'error', - error: - err instanceof Error ? err.message : 'Failed to load', - }, - } - : m, - ), - ); - }); - }, - [usersimModels], - ); - - const handleDownloadTrajectory = useCallback((s3Uri: string) => { - window.open(objectContentUrl(s3Uri), '_blank'); + const handleDownloadTrajectory = useCallback((objectUrl: string) => { + window.open(objectContentUrl(objectUrl), '_blank'); }, []); - const handleDownloadResults = useCallback(() => { - if (!resultS3Uri) return; - window.open(objectContentUrl(resultS3Uri), '_blank'); - }, [resultS3Uri]); // One list for both renderings, so the inline row and the collapsed dropdown // always show the same entries in the same order. const barDownloads = useMemo( - () => buildBarDownloads(promptResponses, resultS3Uri), - [promptResponses, resultS3Uri], + () => buildBarDownloads(promptResponses), + [promptResponses], ); const collapseBarDownloads = shouldCollapseBarDownloads(barDownloads.length); const runBarDownload = useCallback( (download: BarDownload) => { - if (download.kind === 'results') { - void handleDownloadResults(); - return; - } - void handleDownloadTrajectory(download.s3Uri); + void handleDownloadTrajectory(download.objectUrl); }, - [handleDownloadResults, handleDownloadTrajectory], + [handleDownloadTrajectory], ); // Initial tab by status: for terminal runs lead to the most diagnostic view — verifier verdict @@ -857,15 +643,13 @@ export function TaskInstanceViewer({ : undefined; const reviewerOverviewSrc = activeCollectedUri ? objectContentUrl(activeCollectedUri) - : reviewerOverviewS3 - ? objectContentUrl(reviewerOverviewS3) + : reviewerOverviewUrl + ? objectContentUrl(reviewerOverviewUrl) : null; const showReviewerOverview = - collectedFiles.length > 0 || !!reviewerOverviewS3; + collectedFiles.length > 0 || !!reviewerOverviewUrl; const liveDefaultValue = hasTrajectories ? 'trajectory' - : hasUsersimModels - ? `model-${usersimModels[0]!.modelName}` : hasVerifications ? 'verifier' : 'context'; @@ -993,15 +777,6 @@ export function TaskInstanceViewer({ Trajectory Viewer )} - {usersimModels.map(m => ( - - {m.modelName} - - ))} {hasVerifications && ( {hasAggregateOrSandbox @@ -1021,16 +796,6 @@ export function TaskInstanceViewer({ Collected Artifacts ({collectedArtifactsCount}) )} - {hasCompletedRuns && ( - - Completed Runs - - )} - {hasTaskMilestones && ( - - Milestones - - )} {hasServerConfig && ( Server Config @@ -1123,7 +888,7 @@ export function TaskInstanceViewer({ - - )} - { - if (val === 'score') lazyFetchField(mi, 'usersimResult'); - if (val === 'milestones') lazyFetchField(mi, 'milestones'); - if (val === 'conversation-log') - lazyFetchField(mi, 'conversationLog'); - }} - > - - - Trajectory - - - Score - - - Milestones - - - Conversation Log - - - - - - handleCopyJson( - `${m.modelName}-trajectory`, - m.trajectory.data, - ) - } - /> - - - - {m.score ? ( -
-
- - Overall Score - - - {m.score.overall_score} - -
-

- {m.score.overall_score_rationale} -

-
- ) : ( -

- No score data available. -

- )} - {m.usersimResult.status === 'loading' && ( -
- - Loading usersim result... -
- )} - {m.usersimResult.status === 'error' && ( -

- {m.usersimResult.error} -

- )} - {m.usersimResult.status === 'loaded' && - m.usersimResult.data != null && ( -
-

- Usersim Result -

-
-
-                          {typeof (
-                            m.usersimResult.data as Record
-                          )?.text === 'string'
-                            ? String(
-                                (
-                                  m.usersimResult.data as Record<
-                                    string,
-                                    unknown
-                                  >
-                                ).text,
-                              )
-                            : JSON.stringify(m.usersimResult.data, null, 2)}
-                        
-
-
- )} -
- - - {taskMilestones.length > 0 && - (() => { - const progressEntries = - m.milestones.status === 'loaded' && - Array.isArray(m.milestones.data) - ? (m.milestones.data as { - milestone_id: string; - status: string; - turn: number; - }[]) - : []; - const progressByMilestone = new Map< - string, - { startTurn?: number; endTurn?: number; status: string } - >(); - for (const entry of progressEntries) { - const existing = progressByMilestone.get( - entry.milestone_id, - ) ?? { status: 'unknown' }; - if (entry.status === 'in_progress') { - existing.startTurn = entry.turn; - if (existing.status !== 'completed') - existing.status = 'in_progress'; - } - if (entry.status === 'completed') { - existing.endTurn = entry.turn; - existing.status = 'completed'; - } - progressByMilestone.set(entry.milestone_id, existing); - } - return ( -
- {taskMilestones.map(ms => { - const progress = progressByMilestone.get( - ms.milestone_id, - ); - return ( -
-
-

- {ms.milestone_id} -

- {progress ? ( - <> - - {progress.status} - - - turns {progress.startTurn ?? '?'} - {'\u2013'} - {progress.endTurn ?? '?'} - - - ) : m.milestones.status === 'loaded' ? ( - - not reached - - ) : null} -
-

- {ms.continuation_criteria} -

- {ms.planned_interactions_list.length > 0 && - (() => { - const isExpanded = expandedTriggers.has( - ms.milestone_id, - ); - const PREVIEW_COUNT = 2; - const visible = isExpanded - ? ms.planned_interactions_list - : ms.planned_interactions_list.slice( - 0, - PREVIEW_COUNT, - ); - const hiddenCount = - ms.planned_interactions_list.length - - PREVIEW_COUNT; - return ( -
- {visible.map((interaction, idx) => ( -
-
- - Trigger - -

- {interaction.trigger} -

-
-
- - Reaction - -

- {interaction.reaction} -

-
-
- ))} - {hiddenCount > 0 && ( - - )} -
- ); - })()} -
- ); - })} -
- ); - })()} -

- Milestone Progress -

- - handleCopyJson( - `${m.modelName}-milestones`, - m.milestones.data, - ) - } - /> -
- - - - handleCopyJson( - `${m.modelName}-conversationLog`, - m.conversationLog.data, - ) - } - /> - -
- - ))} - {hasVerifications && ( )} - {hasCompletedRuns && ( - -
- {completedRuns.map((run, i) => ( -
-
- - {run.model_label} - - - {run.run_id} - - - {run.result} - -
- {(run.trajectory_url || - run.output_urls?.['trajectory.json']) && ( - - )} -
- ))} -
-
- )} - - {hasTaskMilestones && ( - -
- {taskMilestones.map((milestone, index) => ( -
-
- - {milestone.milestone_id} - -
-
-
-

- Prompt -

-

- {milestone.prompt} -

-
- {milestone.continuation_criteria && ( -
-

- Continuation Criteria -

-

- {milestone.continuation_criteria} -

-
- )} -
-
- ))} -
-
- )} - {hasServerConfig && ( void; -}) { - if (state.status === 'idle') { - return null; - } - if (state.status === 'loading') { - return ( -
- - Loading... -
- ); - } - if (state.status === 'error') { - return

{state.error}

; - } - if (state.data == null) { - return ( -

- No data available. -

- ); - } - return ( -
-
- -
- -
-          {JSON.stringify(state.data, null, 2)}
-        
-
-
- ); -} - // Surface the backend's structured `detail` (e.g. the 413 "too large" message) // instead of a bare status code. async function trajectoryFetchError( @@ -1962,29 +1291,15 @@ async function trajectoryFetchError( return new Error(detail || `${fallback} (${res.status})`); } -async function fetchRawS3Json( - s3Uri: string, - signal?: AbortSignal, -): Promise { - const res = await apiFetch( - objectContentUrl(s3Uri), - signal ? { signal } : undefined, - ); - if (!res.ok) { - throw await trajectoryFetchError(res, 'Failed to fetch'); - } - return res.json(); -} - async function fetchTrajectory( - s3Uri: string, + objectUrl: string, modelHint?: string, ): Promise { - const base = objectContentUrl(s3Uri); + const base = objectContentUrl(objectUrl); let res = await apiFetch(base); // Too large to inline? Retry the screenshot-trimmed stream (frames become lazy-loaded placeholders). // Only .json is trimmable, so a non-JSON 413 keeps its message rather than retrying into an identical 413. - if (res.status === 413 && s3Uri.endsWith('.json')) { + if (res.status === 413 && objectUrl.endsWith('.json')) { res = await apiFetch(`${base}&trim=screenshots`); } if (!res.ok) { @@ -2127,22 +1442,22 @@ function parseContainsOutcome( } /** Open a collected-artifact path chip via the /objects/content seam in a new tab, mirroring the Collected Artifacts download. */ -function openCollectedArtifact(s3Uri: string): void { - window.open(objectContentUrl(s3Uri), '_blank'); +function openCollectedArtifact(objectUrl: string): void { + window.open(objectContentUrl(objectUrl), '_blank'); } -/** Path chip in the Sandbox Verifier card: a link when `s3Uri` is set (a collect step uploaded the file), +/** Path chip in the Sandbox Verifier card: a link when `objectUrl` is set (a collect step uploaded the file), * green/red when a "Missing: …" set was parsed (`hasMissingSet`), else neutral. */ function SandboxPathChip({ path, isMissing, hasMissingSet, - s3Uri, + objectUrl, }: { path: string; isMissing: boolean; hasMissingSet: boolean; - s3Uri: string | undefined; + objectUrl: string | undefined; }) { const stateClasses = hasMissingSet ? isMissing @@ -2158,11 +1473,11 @@ function SandboxPathChip({ ) ) : null; - if (s3Uri) { + if (objectUrl) { return (