Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
160 changes: 144 additions & 16 deletions src/chrome/src/agent/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -1135,17 +1135,58 @@ export class Agent {
};
}

_shouldStreamInteractiveAsk(provider, mode, runOptions = {}, disabledForRun = false) {
_interactiveAskStreamingDecision(provider, mode, runOptions = {}, disabledForRun = false) {
const streamingEnabled = runOptions?.askStreamingEnabled
?? runOptions?.openaiAskStreamingEnabled;
return mode === 'ask'
&& runOptions?.interactiveChat === true
&& streamingEnabled !== false
&& runOptions?.trustedContinuation !== true
&& runOptions?.cloudRun !== true
&& disabledForRun !== true
&& typeof provider?.chatStream === 'function'
&& provider?._supportsInteractiveAskStreaming?.() === true;
if (mode !== 'ask') return { eligible: false, reason: 'mode_not_ask' };
if (runOptions?.interactiveChat !== true) return { eligible: false, reason: 'not_interactive_chat' };
if (streamingEnabled === false) return { eligible: false, reason: 'disabled_in_settings' };
if (runOptions?.trustedContinuation === true) return { eligible: false, reason: 'trusted_continuation' };
if (runOptions?.cloudRun === true) return { eligible: false, reason: 'cloud_run' };
if (disabledForRun === true) return { eligible: false, reason: 'disabled_after_fallback' };
if (typeof provider?.chatStream !== 'function') return { eligible: false, reason: 'provider_missing_chat_stream' };
try {
if (provider?._supportsInteractiveAskStreaming?.() !== true) {
return { eligible: false, reason: 'provider_not_supported' };
}
} catch {
return { eligible: false, reason: 'provider_capability_check_failed' };
}
return { eligible: true, reason: 'eligible' };
}

_shouldStreamInteractiveAsk(provider, mode, runOptions = {}, disabledForRun = false) {
return this._interactiveAskStreamingDecision(provider, mode, runOptions, disabledForRun).eligible;
}

_interactiveAskStreamingProtocol(provider) {
try {
if (typeof provider?._usesResponsesApi === 'function') {
return provider._usesResponsesApi() === true ? 'responses' : 'chat_completions';
}
return 'provider_native';
} catch {
return 'unknown';
}
}

_interactiveAskStreamingFailure(error) {
const rawMessage = String(error?.message || error || 'Streaming request failed.');
const message = rawMessage
.replace(/\b(Bearer)\s+[^\s,;]+/gi, '$1 [redacted]')
.replace(/((?:^|[^a-zA-Z0-9_])["']?(?:api[_ -]?key|access[_ -]?token|token|secret|password)["']?\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;}\]]+)/gi, '$1[redacted]')
.replace(/([?&](?:api[_-]?key|access[_-]?token|token|key)=)[^&\s]+/gi, '$1[redacted]')
.replace(/\b(?:sk-(?:or-v1-)?|gsk_|xai-)[a-zA-Z0-9_-]{12,}\b/g, '[redacted]')
.slice(0, 500);
const rawCode = error?.incompleteReason || error?.code || '';
const errorCode = String(rawCode).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 80);
let reason = 'stream_error';
if (this._isCostAllowanceError(error)) reason = 'cost_limit';
else if (errorCode === 'missing_response_completed' || /before (?:its )?terminal event/i.test(rawMessage)) {
reason = 'missing_terminal_event';
} else if (this._shouldFallbackAskStream(error)) reason = 'transport_error';
else if (error?.isAskStreamTerminalError === true) reason = 'provider_stream_error';
return { reason, errorCode: errorCode || null, message };
}

// Backward-compatible aliases for integrations/tests that used the original
Expand Down Expand Up @@ -18405,6 +18446,14 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
let runId = null;
let finalResponse = '';
let _traceStatus = 'done'; // updated on early exits
let askStreamingTraceWrite = Promise.resolve();
let shouldOrderInteractiveAskTrace = false;
const queueAskStreamingTraceWrite = (write) => {
askStreamingTraceWrite = askStreamingTraceWrite
.then(write)
.catch(() => {});
return askStreamingTraceWrite;
};

if (mode === 'dev' && provider.promptTier === 'compact') {
const msg = this._devModeBlockedMessage(provider);
Expand Down Expand Up @@ -18516,13 +18565,35 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
let compressionPlaceholderRecoveryAttempted = false;
let askStreamingDisabledForRun = false;

// Keep trace persistence ordered without putting IndexedDB on the token
// delivery path. Once generation settles, later response/finalization
// writes flush this queue so lifecycle events cannot arrive afterward.
shouldOrderInteractiveAskTrace = mode === 'ask' && runOptions?.interactiveChat === true;
const recordAskStreaming = (payload) => {
const traceRunId = runId;
const traceStep = steps;
if (!traceRunId) return;
queueAskStreamingTraceWrite(
() => trace.recordStreaming(traceRunId, traceStep, payload),
);
};

const chatMainTurn = async (chatMessages, chatOptions, requestContext) => {
if (!this._shouldStreamInteractiveAsk(
const decision = this._interactiveAskStreamingDecision(
provider,
mode,
runOptions,
askStreamingDisabledForRun,
)) {
);
const protocol = this._interactiveAskStreamingProtocol(provider);
if (!decision.eligible) {
if (mode === 'ask' && runOptions?.interactiveChat === true) {
recordAskStreaming({
status: 'skipped',
reason: decision.reason,
protocol,
});
}
return this._chatWithCostAllowance(
provider,
chatMessages,
Expand All @@ -18532,23 +18603,56 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
);
}

let firstDeltaMs = null;
let textDeltaCount = 0;
let textChars = 0;
let emittedText = false;
recordAskStreaming({
status: 'attempted',
reason: decision.reason,
protocol,
});
const streamStartedAt = Date.now();
const streamMetrics = () => ({
durationMs: Date.now() - streamStartedAt,
firstDeltaMs,
textDeltaCount,
textChars,
});
try {
return await this._chatStreamWithCostAllowance(
const result = await this._chatStreamWithCostAllowance(
provider,
chatMessages,
chatOptions,
costState,
requestContext,
(delta) => {
emittedText = true;
if (firstDeltaMs == null) firstDeltaMs = Date.now() - streamStartedAt;
textDeltaCount += 1;
textChars += delta.length;
onUpdate('text_delta', { content: delta });
},
);
recordAskStreaming({
status: 'completed',
reason: 'terminal_event_received',
protocol,
...streamMetrics(),
toolCallCount: Array.isArray(result?.toolCalls) ? result.toolCalls.length : 0,
});
return result;
} catch (error) {
const fallbackSafe = this._shouldFallbackAskStream(error);
recordAskStreaming({
status: fallbackSafe ? 'fallback' : 'failed',
protocol,
...streamMetrics(),
...this._interactiveAskStreamingFailure(error),
});
if (this._isCostAllowanceError(error)) throw error;
if (emittedText) onUpdate('text', { content: '', replace: true });
if (!this._shouldFallbackAskStream(error)) throw error;
if (!fallbackSafe) throw error;
askStreamingDisabledForRun = true;
onUpdate('warning', {
code: 'ask_stream_fallback',
Expand Down Expand Up @@ -18631,7 +18735,16 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
const chatOpts = { tools: useTools ? tools : undefined, temperature: plannerTemperature, maxTokens: 4096 };
const prunedMessages = this._pruneOldImages(messages, provider);
this._logDebug({ type: 'llm_request', step: steps, provider: provider.constructor.name, messages: prunedMessages, options: chatOpts });
if (runId) trace.recordLLMRequest(runId, steps, { providerClass: provider.constructor.name, model: provider.model, messageCount: prunedMessages.length, toolsCount: (chatOpts.tools || []).length });
if (runId) {
const writeRequestTrace = () => trace.recordLLMRequest(runId, steps, {
providerClass: provider.constructor.name,
model: provider.model,
messageCount: prunedMessages.length,
toolsCount: (chatOpts.tools || []).length,
});
if (shouldOrderInteractiveAskTrace) queueAskStreamingTraceWrite(writeRequestTrace);
else writeRequestTrace();
}
const _llmStart = Date.now();
result = await chatMainTurn(prunedMessages, chatOpts, { tabId, generationName: 'main' });
if (result?.usage?.prompt_tokens) {
Expand All @@ -18642,7 +18755,17 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
}
const _llmLatency = Date.now() - _llmStart;
this._logDebug({ type: 'llm_response', step: steps, content: result.content, toolCalls: result.toolCalls });
if (runId) trace.recordLLMResponse(runId, steps, { content: result.content, toolCalls: result.toolCalls, usage: result.usage, latencyMs: _llmLatency, model: provider.model });
if (runId) {
const writeResponseTrace = () => trace.recordLLMResponse(runId, steps, {
content: result.content,
toolCalls: result.toolCalls,
usage: result.usage,
latencyMs: _llmLatency,
model: provider.model,
});
if (shouldOrderInteractiveAskTrace) await queueAskStreamingTraceWrite(writeResponseTrace);
else writeResponseTrace();
}
} catch (e) {
this._logDebug({ type: 'llm_error', step: steps, error: e.message });
if (this._isCostAllowanceError(e)) {
Expand Down Expand Up @@ -18936,10 +19059,15 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
const message = error?.message || String(error);
_traceStatus = 'error';
finalResponse = `Error: ${message}`;
if (runId) trace.recordError(runId, null, 'agent', message);
if (runId) {
const writeErrorTrace = () => trace.recordError(runId, null, 'agent', message);
if (shouldOrderInteractiveAskTrace) await queueAskStreamingTraceWrite(writeErrorTrace);
else writeErrorTrace();
}
throw error;
} finally {
this.currentCostState.delete(tabId);
await askStreamingTraceWrite;
this._endTraceRun(tabId, runId, _traceStatus, finalResponse);
}
}
Expand Down
27 changes: 23 additions & 4 deletions src/chrome/src/agent/trace-export.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@
* the right source for a tool chain; `this.conversations` is not (it is compacted,
* enriched, and wrapped — see the closed PR #348 review).
*
* This renders the TOOL CHAIN: user/assistant/planner prose, tool calls (name,
* args, result), and errors — in order. Screenshot / note / vision_sub_call events
* are recorded but deliberately not rendered; the file says so in its footer, so it
* never claims to be a complete record. The complete record is the Traces-page JSON.
* This renders the TOOL CHAIN: user/assistant/planner prose, streaming lifecycle
* metadata, tool calls (name, args, result), and errors — in order. Screenshot /
* note / vision_sub_call events are recorded but deliberately not rendered; the
* file says so in its footer, so it never claims to be a complete record. The
* complete record is the Traces-page JSON.
*
* Pure and browser-neutral → unit-tested in test/run.js without a DOM or IndexedDB.
*
Expand Down Expand Up @@ -82,6 +83,22 @@ function renderResult(result) {
return { text: truncate(oneLine(s), RESULT_LIMIT), failed };
}

function renderStreaming(data) {
const d = data || {};
const details = [
oneLine(d.protocol),
oneLine(d.reason),
d.errorCode ? `code ${oneLine(d.errorCode)}` : '',
Number.isFinite(d.textDeltaCount) ? `${d.textDeltaCount} text delta${d.textDeltaCount === 1 ? '' : 's'}` : '',
Number.isFinite(d.textChars) ? `${d.textChars} chars` : '',
Number.isFinite(d.firstDeltaMs) ? `first delta ${d.firstDeltaMs} ms` : '',
Number.isFinite(d.durationMs) ? `${d.durationMs} ms total` : '',
Number.isFinite(d.toolCallCount) ? `${d.toolCallCount} tool call${d.toolCallCount === 1 ? '' : 's'}` : '',
].filter(Boolean);
const message = oneLine(d.message);
return `- 🌊 Ask stream ${oneLine(d.status || 'event')}${details.length ? ` · ${details.join(' · ')}` : ''}${message ? `: ${message}` : ''}\n`;
}

function exportedRunStatus(run, events = []) {
const status = oneLine(run?.status || '');
const sawLoopError = events.some(ev => ev?.kind === 'error' && ev?.data?.phase === 'loop');
Expand Down Expand Up @@ -137,6 +154,8 @@ export function tracesToMarkdown(runsWithEvents, {
toolCount += 1;
const { text, failed } = renderResult(d.result);
md += `- 🔧 \`${d.name || 'tool'}\`(${stringifyArgs(d.args)}) → ${failed ? '✗ ' : ''}${text}\n`;
} else if (ev.kind === 'streaming') {
md += renderStreaming(d);
} else if (ev.kind === 'error') {
md += `- ⚠️ error${d.phase ? ` (${d.phase})` : ''}: ${oneLine(d.message || '')}\n`;
}
Expand Down
24 changes: 24 additions & 0 deletions src/chrome/src/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -1483,6 +1483,29 @@ function cancelDetachedRunStart(tabId) {
return true;
}

async function stopActiveRunBeforeConversationClear(tabId) {
const activeStart = detachedRunStarts.get(tabId) || null;
const running = agent.activeRunState(tabId)?.running === true;
if (!activeStart && !running) return false;

cancelDetachedRunStart(tabId);
try { agent.abort(tabId); } catch { /* best effort */ }

// Keep the old conversation alive until its run has unwound. Clearing it
// first leaves the per-tab run guard active while the UI already looks like
// a fresh chat, so the next send fails with "run already in progress".
if (activeStart?.promise) {
await activeStart.promise.catch(() => {});
}
// Direct chat/chat_stream callers do not have a detached-start promise.
// Do not clear their conversation until processMessage's finally block has
// released the agent's per-tab run guard.
while (agent.activeRunState(tabId)?.running) {
await new Promise(resolve => setTimeout(resolve, 50));
}
return true;
}

function acquireRunKeepalive() {
let released = false;
const touch = () => {
Expand Down Expand Up @@ -2338,6 +2361,7 @@ async function handleMessage(msg, sender) {
const tabId = msg.tabId || sender.tab?.id;
if (tabId) {
const conversationId = await agent.getConversationId(tabId);
await stopActiveRunBeforeConversationClear(tabId);
await scheduler.cancelForConversation(tabId, conversationId);
agent.clearConversation(tabId);
clearRunUiSnapshot(tabId);
Expand Down
9 changes: 9 additions & 0 deletions src/chrome/src/trace/recorder.js
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,15 @@ export function recordError(runId, step, phase, message) {
return _appendEvent(runId, 'error', { step, phase, message });
}

/**
* Record the lifecycle of an interactive Ask streaming attempt without
* persisting token contents. Payloads contain only decision/outcome codes,
* protocol, aggregate counts, timing, and a redacted error summary.
*/
export function recordStreaming(runId, step, payload = {}) {
return _appendEvent(runId, 'streaming', { step, ...payload });
}

/**
* Record a vision sub-call: the agent asked a dedicated vision model to
* describe a screenshot so the main planning model receives text instead
Expand Down
Loading
Loading