diff --git a/packages/runtime/src/__tests__/computer-use-tools.test.ts b/packages/runtime/src/__tests__/computer-use-tools.test.ts index bb9d23be24..c79f2c4845 100644 --- a/packages/runtime/src/__tests__/computer-use-tools.test.ts +++ b/packages/runtime/src/__tests__/computer-use-tools.test.ts @@ -1116,6 +1116,294 @@ describe('buildComputerUseTools — the `maka_computer` MakaTool', () => { ]); }); + test('a sequence closing observation is immediately usable by the next action', async () => { + const backend = fakeBackend(); + backend.observeApp = async () => observation(); + backend.captureObservation = async () => observation(); + let dispatches = 0; + backend.runSemantic = async () => { + dispatches += 1; + return { outcome: { ok: true, tier: 'ax', verified: true } }; + }; + const [tool] = buildComputerUseTools({ backend }); + const observed = (await tool.impl({ action: 'observe', app: 'Fixture' } as never, ctx())) as { + text: string; + }; + const sequence = (await tool.impl( + { + action: 'element_sequence', + observation_id: JSON.parse(observed.text).observation_id, + steps: [{ label: 'Continue' }], + } as never, + ctx(undefined, { toolCallId: 'sequence' }), + )) as { modelText?: string; error?: string }; + assert.equal(sequence.error, undefined); + const freshId = observationIdOf(sequence.modelText); + assert.ok(freshId); + const next = (await tool.impl( + { action: 'click_element', observation_id: freshId, element_id: '5' } as never, + ctx(undefined, { toolCallId: 'next' }), + )) as { error?: string }; + assert.equal(next.error, undefined); + assert.equal(dispatches, 2); + }); + + test('a sequence does not publish a closing capture invalidated in flight', async () => { + const backend = fakeBackend(); + backend.observeApp = async () => observation(); + const tools = buildComputerUseTools({ backend }); + backend.captureObservation = async () => { + tools.sessionEvents.reobserveRequired('s1'); + return observation(); + }; + backend.runSemantic = async () => ({ outcome: { ok: true, tier: 'ax', verified: true } }); + const [tool] = tools; + const observed = (await tool.impl({ action: 'observe', app: 'Fixture' } as never, ctx())) as { + text: string; + }; + const result = (await tool.impl( + { + action: 'element_sequence', + observation_id: JSON.parse(observed.text).observation_id, + steps: [{ label: 'Continue' }], + } as never, + ctx(undefined, { toolCallId: 'sequence' }), + )) as { modelText?: string; text: string; error?: string }; + assert.doesNotMatch(result.modelText ?? '', /Fresh observation/); + assert.doesNotMatch(result.text, /element_sequence ok/); + assert.match(result.text, /failed after 1 of 1 steps: reobserve_required/); + assert.match(result.modelText ?? '', /call action:"observe"/); + assert.equal(result.error, 'reobserve_required'); + assert.equal(tools.sessionEvents.snapshot('s1').status, 'reobserve_required'); + }); + + test('a sequence stops before the next step when its intermediate capture is invalidated', async () => { + const backend = fakeBackend(); + backend.observeApp = async () => observation(); + const tools = buildComputerUseTools({ backend }); + let captures = 0; + let dispatches = 0; + backend.captureObservation = async () => { + if (++captures === 1) tools.sessionEvents.userStopped('s1'); + return observation(); + }; + backend.runSemantic = async () => { + dispatches += 1; + return { outcome: { ok: true, tier: 'ax', verified: true } }; + }; + const [tool] = tools; + const observed = (await tool.impl({ action: 'observe', app: 'Fixture' } as never, ctx())) as { + text: string; + }; + const result = (await tool.impl( + { + action: 'element_sequence', + observation_id: JSON.parse(observed.text).observation_id, + steps: [{ label: 'Continue' }, { label: 'Continue' }], + } as never, + ctx(undefined, { toolCallId: 'sequence' }), + )) as { modelText?: string; text: string }; + assert.equal(dispatches, 1); + assert.match(result.text, /stopped at step 1 of 2: user_stopped/); + assert.doesNotMatch(result.modelText ?? '', /Fresh observation/); + }); + + test('a condition wait does not accept an observation invalidated during polling', async () => { + const backend = fakeBackend(); + const tools = buildComputerUseTools({ backend }); + let polls = 0; + backend.observeApp = async () => { + if (++polls === 2) tools.sessionEvents.userStopped('s1'); + return observation(); + }; + const [tool] = tools; + await tool.impl({ action: 'observe', app: 'Fixture' } as never, ctx()); + const result = (await tool.impl( + { action: 'wait', wait_for_text: 'Continue', duration: 0.01 } as never, + ctx(undefined, { toolCallId: 'wait' }), + )) as { error?: string; modelText?: string }; + assert.equal(result.error, 'user_stopped'); + assert.doesNotMatch(result.modelText ?? '', /observation_id=/); + }); + + test('a new turn accepts its observation after retiring the previous turn frame', async () => { + const backend = fakeBackend(); + backend.observeApp = async () => observation(); + const [tool] = buildComputerUseTools({ backend }); + const first = (await tool.impl({ action: 'observe', app: 'Fixture' } as never, ctx())) as { + modelText?: string; + }; + assert.ok(observationIdOf(first.modelText)); + const next = (await tool.impl( + { action: 'observe', app: 'Fixture' } as never, + ctx(undefined, { turnId: 't2', toolCallId: 'new-turn-observe' }), + )) as { error?: string; modelText?: string }; + assert.equal(next.error, undefined); + assert.ok(observationIdOf(next.modelText)); + }); + + test('a sequence stopped during presentation keeps its completed prefix and does not dispatch the next step', async () => { + const backend = fakeBackend(); + backend.observeApp = async () => observation(); + backend.captureObservation = async () => observation(); + let dispatches = 0; + backend.runSemantic = async () => { + dispatches += 1; + return { outcome: { ok: true, tier: 'ax', verified: true } }; + }; + let begins = 0; + let tools!: ReturnType; + tools = buildComputerUseTools({ + backend, + overlay: { + onActionBegin() { + if (++begins === 2) tools.sessionEvents.userStopped('s1'); + return { readyForInteraction: Promise.resolve(), finished: Promise.resolve() }; + }, + }, + }); + const [tool] = tools; + const observed = (await tool.impl({ action: 'observe', app: 'Fixture' } as never, ctx())) as { + text: string; + }; + const progress: Array<[number, number]> = []; + const result = (await tool.impl( + { + action: 'element_sequence', + observation_id: JSON.parse(observed.text).observation_id, + steps: [{ label: 'Continue' }, { label: 'Continue' }], + } as never, + ctx(undefined, { + toolCallId: 'sequence', + emitProgress: (current, total) => progress.push([current, total]), + }), + )) as { text: string; modelText?: string; error?: string }; + assert.equal(dispatches, 1); + assert.equal(result.error, 'user_stopped'); + assert.match(result.text, /stopped at step 2 of 2: user_stopped/); + assert.match(result.modelText ?? '', /1\. ok/); + assert.match(result.modelText ?? '', /2\. failed/); + assert.deepEqual(progress, [ + [0, 2], + [1, 2], + [2, 2], + ]); + }); + + test('a partially delivered sequence step reports an unknown outcome', async () => { + const backend = fakeBackend(); + backend.observeApp = async () => observation(); + backend.captureObservation = async () => observation(); + backend.runSemantic = async () => ({ + outcome: { + ok: false, + error: 'capture_failed', + message: 'verification failed after delivery', + completedSubSteps: 1, + }, + }); + const [tool] = buildComputerUseTools({ backend }); + const observed = (await tool.impl({ action: 'observe', app: 'Fixture' } as never, ctx())) as { + text: string; + }; + const result = (await tool.impl( + { + action: 'element_sequence', + observation_id: JSON.parse(observed.text).observation_id, + steps: [{ label: 'Continue' }], + } as never, + ctx(undefined, { toolCallId: 'sequence' }), + )) as { text: string; error?: string }; + assert.equal(result.error, 'outcome_unknown'); + assert.match(result.text, /stopped at step 1 of 1: outcome_unknown/); + }); + + test('a stopped sequence preserves a partially delivered outcome over frame confirmation failure', async () => { + const backend = fakeBackend(); + backend.observeApp = async () => observation(); + const tools = buildComputerUseTools({ backend }); + backend.captureObservation = async () => observation(); + backend.runSemantic = async () => { + tools.sessionEvents.userStopped('s1'); + return { + outcome: { + ok: false, + error: 'capture_failed', + message: 'verification failed after delivery', + completedSubSteps: 1, + }, + }; + }; + const [tool] = tools; + const observed = (await tool.impl({ action: 'observe', app: 'Fixture' } as never, ctx())) as { + text: string; + }; + const result = (await tool.impl( + { + action: 'element_sequence', + observation_id: JSON.parse(observed.text).observation_id, + steps: [{ label: 'Continue' }], + } as never, + ctx(undefined, { toolCallId: 'sequence' }), + )) as { text: string; error?: string }; + assert.equal(result.error, 'outcome_unknown'); + assert.match(result.text, /stopped at step 1 of 1: outcome_unknown/); + }); + + test('a sequence dispatch exception releases its presentation before a later action', async () => { + const backend = fakeBackend(); + backend.observeApp = async () => observation(); + backend.captureObservation = async () => observation(); + let dispatches = 0; + backend.runSemantic = async () => { + if (++dispatches === 1) throw new Error('executor unavailable'); + return { outcome: { ok: true, tier: 'ax', verified: true } }; + }; + let finished = 0; + const [tool] = buildComputerUseTools({ + backend, + overlay: { + onActionBegin() { + return { readyForInteraction: Promise.resolve(), finished: Promise.resolve() }; + }, + onActionEnd() { + finished += 1; + }, + }, + }); + const observed = (await tool.impl({ action: 'observe', app: 'Fixture' } as never, ctx())) as { + text: string; + }; + await assert.rejects( + async () => + tool.impl( + { + action: 'element_sequence', + observation_id: JSON.parse(observed.text).observation_id, + steps: [{ label: 'Continue' }], + } as never, + ctx(undefined, { toolCallId: 'sequence' }), + ), + /executor unavailable/, + ); + assert.equal(finished, 1); + const reobserved = (await tool.impl( + { action: 'observe', app: 'Fixture' } as never, + ctx(undefined, { toolCallId: 'reobserve' }), + )) as { text: string }; + const next = (await tool.impl( + { + action: 'click_element', + observation_id: JSON.parse(reobserved.text).observation_id, + element_id: '5', + } as never, + ctx(undefined, { toolCallId: 'next' }), + )) as { error?: string }; + assert.equal(next.error, undefined); + assert.equal(finished, 2); + assert.equal(dispatches, 2); + }); + test('a sequence stops at the step it cannot resolve, and says which', async () => { const backend = fakeBackend() as CuDispatchBackend & { observeApp: NonNullable; diff --git a/packages/runtime/src/computer-use-tools.ts b/packages/runtime/src/computer-use-tools.ts index 84c6ac5631..1c9c816064 100644 --- a/packages/runtime/src/computer-use-tools.ts +++ b/packages/runtime/src/computer-use-tools.ts @@ -902,10 +902,13 @@ export function buildComputerUseTools(deps: { }; } - function registerObservation( + function acceptObservation( + state: CuaSessionState, record: SessionObservationRecord, + lease: CuaActionLease, observation: CuObservation, - ): CuObservation { + ): CuObservation | undefined { + if (!state.validateObservationLease(lease).ok) return undefined; const normalized = { ...observation, elements: observation.elements.map((element) => ({ @@ -930,6 +933,7 @@ export function buildComputerUseTools(deps: { record.windowId = observation.windowId; record.obscuringRects = observation.obscuringRects; record.elements = new Map(normalized.elements.map((element) => [element.elementId, element])); + state.freshObservationSucceeded(); return { ...normalized, observationId: frame.frameId }; } @@ -1253,12 +1257,7 @@ export function buildComputerUseTools(deps: { captured && result.screenshot && !captured.screenshot ? { ...captured, screenshot: result.screenshot } : captured; - if (!fresh || !state.validateObservationLease(observationLease.lease).ok) { - return undefined; - } - const registered = registerObservation(record, fresh); - const snapshot = state.freshObservationSucceeded(); - return snapshot.status === 'active' ? registered : undefined; + return fresh ? acceptObservation(state, record, observationLease.lease, fresh) : undefined; } async function withInvocationQueue( @@ -1460,6 +1459,67 @@ export function buildComputerUseTools(deps: { } } + async function executeBoundAction(input: { + state: CuaSessionState; + lease: CuaActionLease; + record: SessionObservationRecord; + binding: CuaBoundAction; + action: CuPresentationAction; + context: CuRunContext; + signal: AbortSignal; + generation: number; + dispatch(context: CuRunContext): Promise; + }): Promise< + | { blocked: ComputerToolResult; result?: never; finish?: never } + | { blocked?: never; result: CuRunResult; finish(result?: CuRunResult): void } + > { + const { state, lease, record, binding, action, signal } = input; + let result: CuRunResult | undefined; + let consumeFailure: BindingFailureReason | undefined; + let presentation: Awaited> | undefined; + try { + const blocked = validateActionLease(state, lease); + if (blocked) return { blocked }; + const context = { ...input.context, boundAction: binding }; + presentation = await runWithPresentation( + action, + context, + signal, + () => input.dispatch(context), + () => validateActionLease(state, lease), + input.generation, + ); + if (presentation.blocked) return { blocked: presentation.blocked }; + if (!presentation.result) { + presentation.finish(); + return { blocked: bindingFailure('capture_failed', action.type) }; + } + result = preservePartialDelivery(presentation.result); + applyTypedOutcomeState(state, result.outcome); + if (result.outcome.ok) { + const blocked = validateActionLease(state, lease); + if (blocked) { + presentation.finish(); + return { blocked }; + } + } + } finally { + // A path:none refusal retains its frame; a delivered or unknown attempt + // consumes it even if the backend throws. + if (dispatchedNothing(result)) { + consumeFailure = retireBoundAction(record, binding); + } else { + consumeFailure = consumeBoundAction(record, binding); + if (state.validateLease(lease).ok) state.reobserveRequired(); + } + } + if (consumeFailure && !hasUncertainDeliveredOutcome(result)) { + presentation.finish(); + return { blocked: refusalAfterDispatch(consumeFailure, result, action.type) }; + } + return { result, finish: presentation.finish }; + } + const tool: MakaTool = { name: 'maka_computer', displayName: 'Maka Computer', @@ -1602,6 +1662,10 @@ export function buildComputerUseTools(deps: { state.screenLocked(); return sessionFailure('screen_locked'); } + // A new Turn retires the previous frame and advances the session + // generation. Do that before leasing an observe, not after capture. + const observingRecord = + input.action === 'observe' ? sessionObservation(sessionId, turnId) : undefined; // Both halves of the wire enum are partitioned in `@maka/core`, so a // new action cannot be added without landing on one side or the // other — and offline consumers read the same partition. @@ -1719,12 +1783,12 @@ export function buildComputerUseTools(deps: { stopped = 'capture_failed'; break; } - current = registerObservation(record, recaptured); - // A frame the host just captured is a live frame. Without this - // the session stays in `reobserve_required` from the previous - // step and the next action is refused — the sequence would take - // exactly one step and stop. - state.freshObservationSucceeded(); + current = acceptObservation(state, record, lease.lease, recaptured); + if (!current) { + const valid = state.validateObservationLease(lease.lease); + stopped = valid.ok ? 'reobserve_required' : valid.reason; + break; + } } const wanted = step.label.trim().toLowerCase(); const matches = (current?.elements ?? []).filter( @@ -1785,36 +1849,29 @@ export function buildComputerUseTools(deps: { stopped = 'stale_frame'; break; } - const operationContext = { ...runCtx, boundAction: binding }; - let stepResult: CuRunResult | undefined; - let presentation: Awaited> | undefined; - try { - presentation = await runWithPresentation( - summarySemanticAction(semantic), - operationContext, - abortSignal, - () => - deps.backend.runSemantic!( - { ...semantic, observationId: record.backendObservationId! }, - abortSignal, - operationContext, - ), - undefined, - invocationGeneration, - ); - if (presentation.blocked) return presentation.blocked; - stepResult = presentation.result; - } finally { - consumeBoundAction(record, binding); - state.reobserveRequired(); + const backendAction = { ...semantic, observationId: record.backendObservationId }; + const execution = await executeBoundAction({ + state, + lease: actionLeaseResult.lease, + record, + binding, + action: summarySemanticAction(semantic), + context: runCtx, + signal: abortSignal, + generation: invocationGeneration, + dispatch: (context) => + deps.backend.runSemantic!(backendAction, abortSignal, context), + }); + if (execution.blocked) { + stopped = execution.blocked.error ?? 'outcome_unknown'; + done.push({ step: index + 1, label: step.label, ok: false }); + emitProgress?.(done.length, input.steps.length); + break; } - presentation?.finish(stepResult); - if (!stepResult || !stepResult.outcome.ok) { - if (stepResult) applyTypedOutcomeState(state, stepResult.outcome); - stopped = - stepResult && !stepResult.outcome.ok - ? stepResult.outcome.error - : 'capture_failed'; + const stepResult = execution.result; + execution.finish(stepResult); + if (!stepResult.outcome.ok) { + stopped = stepResult.outcome.error; done.push({ step: index + 1, label: step.label, ok: false }); emitProgress?.(done.length, input.steps.length); break; @@ -1825,6 +1882,7 @@ export function buildComputerUseTools(deps: { // One observation at the end, whatever happened: the model needs a // current frame either to carry on or to work out what went wrong. let final: CuObservation | undefined; + let closingBlock: CuaSessionActionBlockReason | undefined; try { const lease = state.beforeObservation(); if (lease.ok) { @@ -1845,17 +1903,27 @@ export function buildComputerUseTools(deps: { abortSignal, runCtx, ); - final = registerObservation( + final = acceptObservation( + state, record, + lease.lease, await capture(true).catch(() => capture(false)), ); + if (!final) { + const valid = state.validateObservationLease(lease.lease); + closingBlock = valid.ok ? 'reobserve_required' : valid.reason; + } + } else { + closingBlock = lease.reason; } } catch { final = undefined; } const headline = stopped ? `maka_computer.element_sequence stopped at step ${done.length} of ${input.steps.length}: ${stopped}` - : `maka_computer.element_sequence ok (${done.length} of ${input.steps.length} steps)`; + : closingBlock + ? `maka_computer.element_sequence failed after ${done.length} of ${input.steps.length} steps: ${closingBlock} — ${SESSION_BLOCK_RECOVERY[closingBlock]}` + : `maka_computer.element_sequence ok (${done.length} of ${input.steps.length} steps)`; const persistedTail = final ? `\nFresh observation: ${persistedObservationText(final)}` : ''; @@ -1869,7 +1937,11 @@ export function buildComputerUseTools(deps: { return { text: `${headline}${persistedTail}`, modelText: `${headline}\n${stepLines}${modelTail}`, - ...(stopped && isComputerUseErrorCode(stopped) ? { error: stopped } : {}), + ...(stopped && isComputerUseErrorCode(stopped) + ? { error: stopped } + : closingBlock + ? { error: closingBlock } + : {}), ...(final?.screenshot ? { screenshot: { @@ -2052,8 +2124,13 @@ export function buildComputerUseTools(deps: { .some((part) => part.toLowerCase().includes(needle)), ); if (found === wantPresent) { - const observation = registerObservation(record, last); - state.freshObservationSucceeded(); + const observation = observationLease?.ok + ? acceptObservation(state, record, observationLease.lease, last) + : undefined; + if (!observation) { + const valid = state.beforeAction(); + return sessionFailure(valid.ok ? 'reobserve_required' : valid.reason, 'wait'); + } const waited = ( (Date.now() - (deadline - Math.round((input.duration ?? 5) * 1000))) / 1000 @@ -2069,8 +2146,13 @@ export function buildComputerUseTools(deps: { // holds instead is the whole question a model asks next, and // making it spend another call on that is the round trip this // action exists to remove. - const observation = registerObservation(record, last); - state.freshObservationSucceeded(); + const observation = observationLease?.ok + ? acceptObservation(state, record, observationLease.lease, last) + : undefined; + if (!observation) { + const valid = state.beforeAction(); + return sessionFailure(valid.ok ? 'reobserve_required' : valid.reason, 'wait'); + } const text = `maka_computer.wait failed: timeout — ${JSON.stringify(input.wait_for_text ?? input.wait_for_text_gone)} was still ${wantPresent ? 'absent' : 'present'} after ${(input.duration ?? 5).toFixed(1)}s and ${polls} looks. This is the window as it stands.`; return { text: `${text}\n${persistedObservationText(observation)}`, @@ -2224,15 +2306,11 @@ export function buildComputerUseTools(deps: { error: code, }; } - if ( - !observationLease?.ok || - !state.validateObservationLease(observationLease.lease).ok - ) { + if (!observationLease?.ok) { const blocked = state.beforeAction(); return sessionFailure(blocked.ok ? 'reobserve_required' : blocked.reason, 'observe'); } - const record = sessionObservation(sessionId, turnId); - const observation = registerObservation(record, { + const observation = acceptObservation(state, observingRecord!, observationLease.lease, { ...withRequestedView(backendObservation, { ...(input.query ? { query: input.query } : {}), ...(input.menu ? { menu: input.menu } : {}), @@ -2248,12 +2326,9 @@ export function buildComputerUseTools(deps: { ? { appAlias: input.app } : {}), }); - const activated = state.freshObservationSucceeded(); - if (activated.status !== 'active') { - invalidateObservation(sessionId); - return sessionFailure( - activated.status === 'blocked_url' ? 'blocked_url' : 'user_stopped', - ); + if (!observation) { + const valid = state.beforeAction(); + return sessionFailure(valid.ok ? 'reobserve_required' : valid.reason, 'observe'); } const screenshot = observation.screenshot; return screenshot @@ -2434,58 +2509,22 @@ export function buildComputerUseTools(deps: { observationId: record.backendObservationId, }; const summaryAction = summarySemanticAction(semanticAction); - let result: CuRunResult | undefined; - let consumeFailure: BindingFailureReason | undefined; - let presentation: Awaited> | undefined; - try { - if (!actionLease) return sessionFailure('no_active_frame', input.action); - const leaseFailure = validateActionLease(state, actionLease); - if (leaseFailure) return leaseFailure; - const operationContext = { ...runCtx, boundAction: binding }; - presentation = await runWithPresentation( - summaryAction, - operationContext, - abortSignal, - () => deps.backend.runSemantic!(semanticAction, abortSignal, operationContext), - () => validateActionLease(state, actionLease), - invocationGeneration, - ); - if (presentation.blocked) return presentation.blocked; - if (!presentation.result) return bindingFailure('capture_failed', input.action); - result = preservePartialDelivery(presentation.result); - applyTypedOutcomeState(state, result.outcome); - if (result.outcome.ok) { - const postDispatchFailure = validateActionLease(state, actionLease); - if (postDispatchFailure) { - presentation.finish(); - return postDispatchFailure; - } - } - } finally { - // A refusal that never reached the window leaves the frame it was - // quoted against exactly as it was, so it keeps its frame and its - // lease. Consuming both is what turned one refusal into three - // calls: the action failed, the frame was thrown away, and the - // code was not one that hands back a fresh one — so the model's - // next call was `reobserve_required` and the one after it was the - // `observe` it should never have had to spend. - if (dispatchedNothing(result)) { - consumeFailure = retireBoundAction(record, binding); - } else { - consumeFailure = consumeBoundAction(record, binding); - if (actionLease && state.validateLease(actionLease).ok) { - state.reobserveRequired(); - } - } - } - if (consumeFailure && !hasUncertainDeliveredOutcome(result)) { - presentation?.finish(); - return refusalAfterDispatch(consumeFailure, result, input.action); - } - if (!result) { - presentation?.finish(); - return bindingFailure('capture_failed', input.action); - } + if (!actionLease) return sessionFailure('no_active_frame', input.action); + const execution = await executeBoundAction({ + state, + lease: actionLease, + record, + binding, + action: summaryAction, + context: runCtx, + signal: abortSignal, + generation: invocationGeneration, + dispatch: (context) => + deps.backend.runSemantic!(semanticAction, abortSignal, context), + }); + if (execution.blocked) return execution.blocked; + const { result } = execution; + const presentation = execution; // One action removes its own target on purpose, and the machinery // below reads a missing target as an uncertain outcome. // @@ -2546,7 +2585,7 @@ export function buildComputerUseTools(deps: { // `REOBSERVABLE_FAILURES` — `target_missing`, `target_changed`, // `ambiguous_target`, `duplicate_action`, `stale_frame`, // `invalid_coordinate` — takes the fresh observation above, and - // `registerObservation` makes that the current frame. The sentence + // `acceptObservation` makes that the current frame. The sentence // then named a frame the same reply had just superseded: the model // read "observation X is still current, use it rather than // observing again", did exactly that, and collected `stale_frame`