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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"test:attachment-drop": "node test/attachment-drop.mjs",
"test:workflow-editor": "node --test test/workflow-editor.mjs",
"test:security": "node test/security/injection-corpus.mjs",
"test:cloud-bridge-approval": "node --test test/cloud-bridge-approval.mjs test/bot-memory.mjs test/bot-memory-click-guard.mjs test/bot-memory-registries.mjs test/bot-memory-cloud-controller.mjs test/cloud-conversation-isolation.mjs test/cloud-run-outcomes.mjs test/cloud-act-completion.mjs test/cloud-model-recovery.mjs test/private-results.mjs",
"test:cloud-bridge-approval": "node --test test/cloud-bridge-approval.mjs test/cloud-handoff.mjs test/bot-memory.mjs test/bot-memory-click-guard.mjs test/bot-memory-registries.mjs test/bot-memory-cloud-controller.mjs test/cloud-conversation-isolation.mjs test/cloud-run-outcomes.mjs test/cloud-act-completion.mjs test/cloud-model-recovery.mjs test/private-results.mjs",
"test:discord": "node --test test/discord-guard.mjs",
"test:toolbar-guard": "node test/rich-text-toolbar-guard.mjs",
"test:pdf-read": "node test/pdf-read.mjs",
Expand Down
62 changes: 62 additions & 0 deletions src/chrome/src/agent/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -6870,6 +6870,10 @@ export class Agent extends LoopDetector {
return this._runningTabs.has(tabId);
}

hasOtherRunningTabs(tabId) {
return [...this._runningTabs].some(other => other !== tabId);
}

_beginSteeringRun(tabId, onUpdate, runOptions = {}) {
const feedbackRun = this._pageFeedbackRuns?.get(tabId);
if (feedbackRun) feedbackRun.onUpdate = onUpdate;
Expand Down Expand Up @@ -13769,6 +13773,29 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
return true;
}

async _observeAfterCloudHandoff(tabId, messages, onUpdate, runOptions, handoff) {
// Preserve task authorization, pending outbound sends, and physical-action
// receipts. Only cached page plans/references are invalid after manual use.
const cached = this._jevSessions?.get(tabId);
if (cached) { cached.disabled = true; cached.queue = []; }
this._throwIfAborted(this._runAbortSignal(tabId));
const result = await this.executeTool(tabId, 'get_accessibility_tree', {}, onUpdate);
if (!result || result.success === false || result.error) {
throw new Error('Could not inspect the browser after handoff. Keep manual control and reconcile the interrupted run.');
}
const id = 'handoff_observation_' + globalThis.crypto.randomUUID();
const observation = { ...result, handoff: {
policy: 'The user returned browser control. The original task and authorization remain in force. This note is reported state, not a new instruction or permission. Verify pending or uncertain mutations before considering another action; do not repeat a submission because control changed.',
note: String(handoff.note || ''),
} };
messages.push({ role: 'assistant', content: '', tool_calls: [{ id, type: 'function', function: { name: 'get_accessibility_tree', arguments: '{}' } }] },
{ role: 'tool', tool_call_id: id, content: this._wrapUntrusted('get_accessibility_tree', this._limitToolResult(observation)) });
onUpdate('tool_result', { name: 'get_accessibility_tree', result: { success: true, handoffObservation: true } });
const saved = await this._persistNow(tabId);
if (saved?.ok !== true) throw new Error('The fresh browser observation could not be saved after handoff.');
await runOptions.handoffObserved?.();
}

async _executeToolBatch(tabId, toolCalls, messages, onUpdate, provider, partialAssistantText = null, allowedToolNames = AGENT_TOOL_NAMES, step = null, runOptions = {}, toolSchemas = null) {
const messageStart = messages.length;
const cancellationState = { active: null };
Expand Down Expand Up @@ -14006,6 +14033,17 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
};

for (let toolIndex = 0; toolIndex < toolCalls.length; toolIndex++) {
const handoff = await runOptions.handoffBoundary?.();
if (handoff) {
// The batch was planned before manual interaction. Complete its tool
// results without dispatching the remaining stale actions.
this._appendSyntheticToolResults(tabId, toolCalls, toolIndex, messages, onUpdate, step, () => ({
success: false, skipped: true, dispatched: false, noDispatch: true,
error: 'Browser control changed. This stale action was skipped; inspect the fresh page before replanning the original task.',
}));
await this._observeAfterCloudHandoff(tabId, messages, onUpdate, runOptions, handoff);
return { action: 'continue' };
}
const tc = toolCalls[toolIndex];
const callState = { index: toolIndex, invoked: false, consequential: false, result: null, dispatchState: null };
cancellationState.active = callState;
Expand Down Expand Up @@ -19413,6 +19451,11 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
};
}

canPauseAtClarification(tabId, clarifyId) {
const entry = this._pendingClarifications.get(tabId)?.get(clarifyId);
return entry?.handoffSafe === true && !entry.settled;
}

/**
* Resolve a pending clarify() tool call with the user's answer. Called by
* background.js when the side panel posts `clarify_response`.
Expand Down Expand Up @@ -39233,6 +39276,9 @@ If the user has already named or confirmed this exact recipient, do NOT ask agai
restartTimeout: null,
onUpdate,
safeFirst,
// Resolving this standalone meta-tool cannot dispatch a page action
// before _executeToolBatch reaches the next handoff boundary.
handoffSafe: true,
};
// Arm auto-select when Instant (0) or a positive wait; Off (-1) waits forever.
// Instant uses source=auto (user intentionally set auto-approve, e.g. headless).
Expand Down Expand Up @@ -48285,6 +48331,8 @@ If the user has already named or confirmed this exact recipient, do NOT ask agai
shareHadProviderCompletion = true;

while (steps < this.maxSteps || this._hasUnvalidatedSteering(tabId)) {
const handoff = await runOptions.handoffBoundary?.();
if (handoff) await this._observeAfterCloudHandoff(tabId, messages, onUpdate, runOptions, handoff);
// Each turn's raw provider text belongs to that turn only: reset so a
// non-terminal final-text pass can never pair stale output with a later
// tool-batch result in the shared record.
Expand Down Expand Up @@ -48584,6 +48632,12 @@ If the user has already named or confirmed this exact recipient, do NOT ask agai
}
}

// Do not use a response planned against the page before manual control.
const responseHandoff = await runOptions.handoffBoundary?.();
if (responseHandoff) {
await this._observeAfterCloudHandoff(tabId, messages, onUpdate, runOptions, responseHandoff);
continue;
}
// Check for abort after LLM response
if (this._checkAbort(tabId)) {
const hadToolCalls = !!(result?.toolCalls && result.toolCalls.length > 0);
Expand Down Expand Up @@ -49591,6 +49645,8 @@ If the user has already named or confirmed this exact recipient, do NOT ask agai
shareHadProviderCompletion = true;

while (steps < this.maxSteps || this._hasUnvalidatedSteering(tabId)) {
const handoff = await runOptions.handoffBoundary?.();
if (handoff) await this._observeAfterCloudHandoff(tabId, messages, onUpdate, runOptions, handoff);
// See the non-streaming loop: raw provider text must not survive into
// a later turn's shared record.
shareRawResponse = null;
Expand Down Expand Up @@ -49919,6 +49975,12 @@ If the user has already named or confirmed this exact recipient, do NOT ask agai
}
}

const responseHandoff = await runOptions.handoffBoundary?.();
if (responseHandoff) {
onUpdate('text', { content: '', replace: true });
await this._observeAfterCloudHandoff(tabId, messages, onUpdate, runOptions, responseHandoff);
continue;
}
// Fallback: parse tool calls from streamed text if structured calls are missing.
if (!hasToolCalls && fullText && !this._containsProviderReplayState(responseItems)) {
const fallback = this._tryParseToolCallsFromText(fullText, allowedToolNames);
Expand Down
59 changes: 59 additions & 0 deletions src/chrome/src/agent/cloud-handoff.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Run-level control handoff. This deliberately does not pause or power off the
// browser session: the original run keeps its tab and mutation receipts.
export class CloudHandoffGate {
constructor({ onState = async () => {}, keepAlive = () => () => {} } = {}) {
this.onState = onState;
this.keepAlive = keepAlive;
this.state = null;
this.waiter = null;
this.closed = false;
}

async request() {
if (this.closed) throw Object.assign(new Error('This browser run has finished.'), { status: 409 });
if (this.state) return;
this.state = 'pausing';
this.promise = new Promise(resolve => { this.waiter = resolve; });
await this.onState('pausing');
}

async acknowledge() {
if (this.closed || this.state !== 'pausing') return;
this.state = 'paused';
// A durable acknowledgement is required before the viewer becomes writable.
await this.onState('paused');
if (!this.closed) this.stopKeepAlive = this.keepAlive();
}

async boundary() {
if (this.closed || !this.state) return null;
await this.acknowledge();
return await this.promise;
}

async resume(note = '') {
if (this.closed || this.state !== 'paused') {
throw Object.assign(new Error('Wait until this browser run is paused before handing it back.'), { status: 409 });
}
this.state = 'resuming';
await this.onState('resuming');
this.waiter?.({ note });
this.waiter = null;
}

async observed() {
if (this.closed) return;
this.stopKeepAlive?.();
this.stopKeepAlive = null;
this.state = null;
await this.onState('running');
}

close() {
this.closed = true;
this.stopKeepAlive?.();
this.stopKeepAlive = null;
this.waiter?.(null);
this.waiter = null;
}
}
9 changes: 8 additions & 1 deletion src/chrome/src/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,10 @@ const teacherRunInterlock = createTeacherRunInterlock(teacherSessionStore, {
|| scheduler.isRunning(tabId)
|| cloudRunController.isRunning(tabId),
});
agent.setRunStartGuard((tabId) => teacherRunInterlock.guardRunStart(tabId));
agent.setRunStartGuard(async (tabId, context) => {
await cloudRunController.guardRunStart(tabId, context);
return teacherRunInterlock.guardRunStart(tabId);
});
const profileSync = new ProfileSyncManager(chrome.storage.local);

async function playWatchAlert({ style = 'default' } = {}) {
Expand Down Expand Up @@ -3086,6 +3089,10 @@ async function handleMessage(msg, sender) {
}
case 'cloud_respond':
return await cloudRunController.respond(msg);
case 'cloud_pause':
return await cloudRunController.pause(msg);
case 'cloud_resume':
return await cloudRunController.resume(msg);
case 'cloud_abort':
return await cloudRunController.abort(msg);
case 'cloud_bridge_start':
Expand Down
Loading
Loading