Skip to content
Closed
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
2 changes: 1 addition & 1 deletion platform/src/agents/agent-runtime-do.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1050,7 +1050,7 @@ export class AgentRuntimeDO extends Agent<Env, AgentRuntimeState> {
requestedVideoIds: decision.route === 'inspect_video' ? [decision.videoId] : decision.comparisonVideoIds ?? [],
// An application fallback is checked against what this run had, not earlier turns' cited content.
runPackets: this.admittedRunEvidence(runId, history),
} : undefined);
} : undefined, 'frameAccess' in decision ? decision.frameAccess : undefined);
// Hash the accepted text: unmatched citations may have been removed from the input.
const answerHash = createHash('sha256').update(result.answer).digest('hex');
const serialized = JSON.stringify(result);
Expand Down
9 changes: 8 additions & 1 deletion platform/src/agents/finalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import {
type AgentTurnResult,
type EvidencePacket,
type FinalizeAnswerInput,
type FrameAccess,
} from './contracts';
import { frameActivityReport } from './runtime/frame-access';
import { appendNotices, playlistCoverageDisclosures } from './playlist-coverage';
import { CITED_FRAME_PREVIEWS, citedPacketFramePreviews } from './runtime/frame-previews';

Expand All @@ -28,6 +30,7 @@ export function buildAgentTurnResult(
packets: EvidencePacket[],
creditsCharged: number,
durationLimitContext?: DurationLimitAnswerContext,
frameAccess?: FrameAccess,
): AgentTurnResult {
const markers = answerCitationIds(input.answer);
const citations: AgentCitation[] = [];
Expand Down Expand Up @@ -75,7 +78,11 @@ export function buildAgentTurnResult(

// The application states playlist totals, the listing limit and reviewed content itself.
const playlistCoverage = playlistCoverageDisclosures(packets, citations.map(citation => citation.id));
const disclosedAnswer = appendNotices(answer, playlistCoverage.notes);
const notes = [...playlistCoverage.notes];
if (frameAccess?.savedTimestampsMs.length && input.intent !== 'clarification' && input.intent !== 'rejected') {
notes.push(frameActivityReport(frameAccess, identity.runId, packets));
}
const disclosedAnswer = appendNotices(answer, notes);

const warnings = answerWarnings(packets, [
...input.warnings,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export function executeGetVideoFrames(input: z.input<typeof getVideoFramesInputS
},
analyze: (value, analysisContext) => analyzeFrameEvidence(validateFrameResponse(request, value), parsed.analysis!, analysisContext, toolCallId),
} : undefined, onReady => visualSpan('retrieval', () => context.provider.frames!(request, context.signal, { extractionTimeoutMs,
...(!context.session && (context.refreshFramesOnly || context.refreshEvidence) ? { refresh: true } : {}) },
...(!context.session && (context.frameAccess || context.refreshFramesOnly || context.refreshEvidence) ? { refresh: true } : {}) },
event => { extractionDiagnostics.push(event); context.onExtractionDiagnostic?.({ ...event, toolCallId }); }, ...(onReady ? [onReady] : []))), async (response, signal) => {
const frames = validateFrameResponse(request, response.value);
if (!context.saveFramePreviews) return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export async function readAnalysisAssets(
() => mapInBatches(versions, async (version) => {
context.signal.throwIfAborted();
const requiresRetrieval =
(context.refreshEvidence || (context.refreshFramesOnly && kind === 'frame')) &&
(context.refreshEvidence || ((scope || context.refreshFramesOnly) && kind === 'frame')) &&
!(context.getEvidence?.() ?? []).some(
(packet) =>
packet.packetId.startsWith(`packet:${context.runId}:`) &&
Expand Down
2 changes: 1 addition & 1 deletion platform/src/agents/research/capability-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function createCapabilityProvider(
? { ...options, refresh: true, ...(decision.refreshDynamicData ? { includeSignals: true } : {}) } : options;
let result = await savedProvider.video(id, readOptions);
const needsDuration = options?.requireFrameDuration || (decision.route === 'inspect_video' &&
(decision.refreshFramesOnly || decision.timestampedFrameQuestion));
(decision.refreshFramesOnly || decision.frameAccess || decision.timestampedFrameQuestion));
if (!decision.savedOnly && needsDuration) {
const missingDuration = result.value.isLive !== true && eligibleDuration(result.value.durationSeconds) === undefined;
if (!readOptions?.refresh && (result.cacheStatus === 'hit' || result.cacheStatus === 'stale') && (result.value.id !== id || missingDuration))
Expand Down
81 changes: 46 additions & 35 deletions platform/src/agents/research/capability-router.ts

Large diffs are not rendered by default.

59 changes: 32 additions & 27 deletions platform/src/agents/research/research-agent.ts

Large diffs are not rendered by default.

11 changes: 9 additions & 2 deletions platform/src/agents/runtime/frame-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { frameCoverageSchema } from './model-evidence';
/** A requested position, a returned image and an analyst result are different facts.
* Only packets produced by this run can establish that it did the work. */
export function frameActivity(scope: FrameAccess, runId: string, evidence: Iterable<EvidencePacket>) {
const inspectedVersions = new Set<string>();
const retrievedVersions = new Set<string>();
const inspected = new Set<number>();
const retrieved = new Set<number>();
const requested = new Set<number>();
Expand All @@ -17,6 +19,7 @@ export function frameActivity(scope: FrameAccess, runId: string, evidence: Itera
const coverage = frameCoverageSchema.safeParse(artifact.data);
if (!coverage.success) continue;
const analyzing = artifact.type === 'youtube_frame_analysis';
for (const version of packet.assetVersions ?? []) (analyzing ? inspectedVersions : retrievedVersions).add(version);
if (!analyzing) for (const time of coverage.data.requestedTimestampsMs) requested.add(time);
for (const frame of coverage.data.frames) (analyzing ? inspected : retrieved).add(frame.timestampMs);
}
Expand All @@ -25,13 +28,17 @@ export function frameActivity(scope: FrameAccess, runId: string, evidence: Itera
const captures = scope.captureTimestampsMs.map(time => ({
time, requested: requested.has(time), retrieved: retrieved.has(time), inspected: inspected.has(time),
}));
const report = [
return { saved, captures, inspectedVersions, pendingCaptureVersions: [...retrievedVersions].filter(version => !inspectedVersions.has(version)) };
}

export function frameActivityReport(scope: FrameAccess, runId: string, packets: Iterable<EvidencePacket>): string {
const { saved, captures } = frameActivity(scope, runId, packets);
return [
...saved.map(({ time, inspected }) => inspected ? `I re-inspected the saved ${clockTime(time)} frame.`
: `I could not re-inspect the saved ${clockTime(time)} frame in this turn.`),
...captures.map(({ time, requested, retrieved, inspected }) => retrieved
? `I retrieved the ${clockTime(time)} frame${inspected ? ' and inspected it' : ''}.`
: requested ? `I requested ${clockTime(time)}, but no frame was returned.`
: `I have no confirmed retrieval of the ${clockTime(time)} frame in this turn.`),
].join(' ');
return { saved, captures, report };
}
4 changes: 3 additions & 1 deletion platform/test/youtube-agent-frame-deadlines.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,8 +243,10 @@ test('names the required visual facts when finalizing without visual observation
const model = new MockLanguageModelV4({ doGenerate: async () => ({ content: [{ type: 'tool-call', toolCallId: 'finish', toolName: 'finalize_answer',
input: JSON.stringify({ intent: 'inspect_video', confidence: 'low', artifacts: [], warnings: [], blocks: [{ text: 'Unverified.', evidenceIds: ['ref_1'] }] }) }],
finishReason: { unified: 'tool-calls', raw: undefined }, usage, warnings: [] }) });
await runResearchAgentWithModel({ ...options, model, recoveredEvidence: [metadata],
const warnings = Array.from({ length: 50 }, (_, index) => ({ code: 'TRANSCRIPT_CONTEXT_TRUNCATED', message: `Omitted passage ${index}.` }));
await runResearchAgentWithModel({ ...options, model, recoveredEvidence: [{ ...metadata, warnings }],
recoveredToolFailures: [{ toolCallId: 'frames', toolName: 'get_video_frames', operation: 'frames', message: 'Frame extraction failed' }] });
expect(vi.mocked(context.finalize).mock.calls[0]![1].warnings).toHaveLength(50);
expect(JSON.stringify(model.doGenerateCalls[0]?.prompt)).toContain('Required visual evidence: player names on shirts');
expect(context.finalize).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ warnings:
expect.arrayContaining([expect.objectContaining({ code: 'VISUAL_EVIDENCE_INCOMPLETE', message: expect.stringContaining('player names on shirts') })]) }));
Expand Down
41 changes: 31 additions & 10 deletions platform/test/youtube-agent-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1091,19 +1091,40 @@ describe('YouTube agent capability router', () => {
expect(decision).toMatchObject({ route: 'inspect_video', videoId, timestampedFrameQuestion: question });
});

it.each(['overlap', 'unrestricted'])('repairs an unscoped refresh and rejects %s capture permission', async invalid => {
it.each(['direct', 'recovery'])('runs an open-ended frame refresh after %s routing', async mode => {
const videoId = 'abcdefghijk';
const model = sequenceClassifier([{ route: 'inspect_video', videoId, refreshFramesOnly: true,
visualEvidence: mode === 'direct' ? 'helpful' : 'invalid' }]);
const decision = await classifyCapabilityWithModel({ model, message: 'Refresh the frames in https://youtu.be/abcdefghijk',
signal: new AbortController().signal });
expect(decision).toMatchObject({ route: 'inspect_video', videoId, refreshFramesOnly: true });
expect(decision).not.toHaveProperty('frameAccess');
});

it.each(['overlap', 'unrestricted', 'omitted', 'wrong video', 'comparison'] as const)('repairs %s before authorizing a bounded frame refresh', async invalid => {
const videoId = 'abcdefghijk', previous = 'lmnopqrstuv';
const frameAccess = { videoId, savedTimestampsMs: [630000], captureTimestampsMs: [645000] };
const base = { route: 'inspect_video', videoId, refreshFramesOnly: true, visualEvidence: 'required', visualRequirements: ['dough shape'] };
const model = sequenceClassifier([base, { ...base, frameAccess: invalid === 'overlap'
? { ...frameAccess, captureTimestampsMs: [630000, 645000] } : 'agent_selected' }]);
const fallbackModel = sequenceClassifier([{ ...base, frameAccess }]);
const route = await classifyCapabilityWithModel({ model, fallbackModel, message: 'Reuse saved 10:30 without recapturing it and capture only 10:45.',
sessionBrief: { memories: [], assets: [{ videoId, version: 'a'.repeat(64), kind: 'frame', current: true,
collectedAt: 1, details: { timestampMs: 630000 } }] }, signal: new AbortController().signal });
expect(route).toMatchObject({ route: 'inspect_video', videoId, frameAccess, refreshFramesOnly: true });
const base = { route: 'inspect_video', videoId, visualEvidence: 'required', visualRequirements: ['dough shape'] };
const candidates = {
omitted: base,
overlap: { ...base, refreshFramesOnly: { ...frameAccess, captureTimestampsMs: [645000, 630000] } },
unrestricted: { ...base, refreshFramesOnly: true },
'wrong video': { ...base, videoId: previous, refreshFramesOnly: { ...frameAccess, videoId: previous } },
comparison: { ...base, route: 'topic_research', comparisonVideoIds: [previous, videoId], refreshFramesOnly: frameAccess },
};
const model = sequenceClassifier([candidates[invalid], { ...base, refreshFramesOnly: frameAccess }]);
const route = await classifyCapabilityWithModel({ model, message: 'Reuse saved 10:30 without recapturing it and capture only 10:45 in https://youtu.be/abcdefghijk.',
sessionBrief: { memories: [], assets: [{ videoId: previous, version: 'a'.repeat(64), kind: 'frame', current: true,
collectedAt: 1, details: {} }] }, signal: new AbortController().signal });
expect(route).toMatchObject({ route: 'inspect_video', videoId, frameAccess });
expect(model.doGenerateCalls).toHaveLength(2);
expect(fallbackModel.doGenerateCalls).toHaveLength(1);
});

it('keeps conflicting persisted frame permissions closed on resume', async () => {
const frameAccess = { videoId: 'lmnopqrstuv', savedTimestampsMs: [630000], captureTimestampsMs: [645000] };
const decision = await resolveCapabilityRoute({ persisted: { route: 'inspect_video', videoId: 'abcdefghijk', frameAccess },
classify: vi.fn(), persist: vi.fn() });
expect(decision).toMatchObject({ route: 'finalize', responseIntent: 'clarification' });
});

it('pins inspect_video provider calls to the classified video', async () => {
Expand Down
55 changes: 33 additions & 22 deletions platform/test/youtube-stored-analysis.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { describe, expect, it, vi } from 'vitest';
import { buildAgentTurnResult } from '../src/agents/finalizer';
import { MockLanguageModelV4 } from 'ai/test';
import { runResearchAgentWithModel } from '../src/agents/research/research-agent';
import { z } from 'zod';
import type { AgentToolContext } from '../src/agents/providers/youtube/tool-context';
import { evidencePacketSchema, type EvidencePacket } from '../src/agents/contracts';
import { evidencePacketSchema, type EvidencePacket, type FinalizeAnswerInput } from '../src/agents/contracts';
import { createCapabilityToolSet } from '../src/agents/providers/youtube/tool-library';
import { executeGetVideoTranscriptForModel } from '../src/agents/providers/youtube/tools/get-video-transcript';
import { executeGetVideoFrames } from '../src/agents/providers/youtube/tools/get-video-frames';
Expand Down Expand Up @@ -115,26 +116,38 @@ function setup() {
const kinds = ['transcript', 'frame', 'storyboard_sheet'] as const;

it.each(['available', 'missing', 'analysis fails'])('inspects saved 10:30 and only fetches 10:45 (saved image %s)', async savedState => {
const { context, provider, store, frames } = setup();
const { context, provider, store, frames, packets } = setup();
const identity = { runId: crypto.randomUUID(), conversationId: crypto.randomUUID(),
userMessageId: crypto.randomUUID(), agentMessageId: crypto.randomUUID() };
context.runId = identity.runId;
const frameAccess = { videoId, savedTimestampsMs: [630000], captureTimestampsMs: [645000] };
const finalize = vi.fn(async (_id: string, input: FinalizeAnswerInput) => buildAgentTurnResult(identity, { userId: 'user', creditsRemaining: 100 },
input, packets, 0, undefined, frameAccess));
context.finalize = finalize;
const at = (timestampMs: number) => ({ ...frames, frames: [{ ...frames.frames[0]!, timestampMs }] });
const saved = store.put('frame', videoId, at(630000), { timestampMs: 630000 });
const saved = store.put('frame', videoId, at(630000));
if (savedState === 'missing') store.remove(saved);
provider.frames.mockResolvedValue({ value: at(645000), cacheStatus: 'miss' });
context.analyzeFrames = vi.fn(async ({ frames }) => {
const time = frames.frames[0]!.timestampMs;
if (savedState === 'analysis fails' && time === 630000) throw new Error('Saved image analysis failed.');
return { findings: [{ observation: 'The dough is round.', timestampsMs: [time] }], warnings: [] };
});
const calls = [
{ name: 'analyze_video_frames', input: { assetVersions: [saved], focus: 'Inspect the saved dough shape.' } },
{ name: 'get_video_frames', input: { videoId, timestampsMs: [645000], analysis: 'Describe the dough.' } },
{ name: 'complete_research', input: {} },
];
const inspectSaved = { name: 'analyze_video_frames', input: { assetVersions: [saved], focus: 'Inspect the saved dough shape.' } };
const capture = { name: 'get_video_frames', input: { videoId, timestampsMs: [645000] } };
const inspectCapture = { name: 'analyze_video_frames', input: { assetVersions: [] as string[], focus: 'Describe the dough.' } };
const calls = savedState === 'analysis fails' ? [inspectSaved, capture, inspectCapture] : [capture, inspectCapture, inspectSaved];
const usage = { inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 },
outputTokens: { total: 1, text: 1, reasoning: 0 } };
let step = 0;
const model = new MockLanguageModelV4({ doGenerate: async () => {
const call = calls[step++]!;
const model = new MockLanguageModelV4({ doGenerate: async ({ tools }) => {
const call = calls[step++] ?? { name: 'complete_research', input: {} };
if (call.name !== 'complete_research' && savedState !== 'missing') {
expect(tools?.map(tool => tool.name)).not.toContain('complete_research');
}
if (call === inspectCapture) {
inspectCapture.input.assetVersions = packets.find(packet => packet.artifacts.some(artifact => artifact.type === 'youtube_frame_retrieval'))!.assetVersions!;
}
return { content: [{ type: 'tool-call', toolCallId: `step-${step}`, toolName: call.name, input: JSON.stringify(call.input) }],
finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, usage, warnings: [] };
} });
Expand All @@ -146,22 +159,21 @@ it.each(['available', 'missing', 'analysis fails'])('inspects saved 10:30 and on
await runResearchAgentWithModel({ model, finalizationModel, context,
toolNames: ['analyze_video_frames', 'get_video_frames', 'finalize_answer'],
message: 'Re-inspect the saved 10:30 image and capture only 10:45. Report the gap if reuse fails.',
decision: { route: 'inspect_video', videoId, useStoryboard: true, refreshFramesOnly: true, visualEvidence: 'required',
frameAccess: { videoId, savedTimestampsMs: [630000], captureTimestampsMs: [645000] } },
decision: { route: 'inspect_video', videoId, useStoryboard: true, visualEvidence: 'required', frameAccess },
});
expect(provider.frames).toHaveBeenCalledOnce();
expect(provider.frames).toHaveBeenCalledWith(expect.objectContaining({ timestampsMs: [645000] }), expect.anything(),
expect.anything(), expect.anything(), expect.anything());
expect.anything(), expect.anything());
expect(vi.mocked(context.analyzeFrames).mock.calls.map(([input]) => input.frames.frames[0]!.timestampMs))
.toEqual(savedState === 'missing' ? [645000] : [630000, 645000]);
.toEqual(savedState === 'missing' ? [645000] : savedState === 'analysis fails' ? [630000, 645000] : [645000, 630000]);
const savedReport = savedState === 'available' ? 'I re-inspected the saved 10:30 frame' : 'I could not re-inspect the saved 10:30 frame';
expect(JSON.stringify(finalizationModel.doGenerateCalls[0]!.prompt)).toContain(savedReport);
expect(context.finalize).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
answer: expect.stringContaining(savedReport),
}));
expect(context.finalize).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
answer: expect.stringContaining('I retrieved the 10:45 frame and inspected it.'),
}));
const result = await finalize.mock.results[0]!.value;
const [findings, activity] = result.answer.split('\n\n');
expect(findings).toContain('[cite:');
expect(activity).toContain(savedReport);
expect(activity).toContain('I retrieved the 10:45 frame and inspected it.');
expect(activity).not.toContain('[cite:');
expect(result.warnings).not.toContainEqual(expect.objectContaining({ code: 'FRAME_ACTIVITY' }));
});

it('rejects forbidden positions and other videos before metadata, billing or extraction', () => {
Expand All @@ -180,7 +192,6 @@ it('rejects forbidden positions and other videos before metadata, billing or ext

it('keeps the freshness requirement for a previously saved version at the new capture position', async () => {
const { context, store, frames } = setup();
context.refreshFramesOnly = true;
context.frameAccess = { videoId, savedTimestampsMs: [630000], captureTimestampsMs: [645000] };
const stale = store.put('frame', videoId, { ...frames, frames: [{ ...frames.frames[0]!, timestampMs: 645000 }] });
await expect(analyze('frame', stale, context)).rejects.toThrow('requires a fresh capture');
Expand Down
Loading