Skip to content

Commit 852542f

Browse files
authored
Merge pull request #348 from AlsoTheZv3n/agent/export-tool-chain
feat: add /export-with-traces command
2 parents 810a620 + 2f28680 commit 852542f

45 files changed

Lines changed: 953 additions & 71 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/chrome/src/agent/agent.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
PDF_PASSTHROUGH_MAX_BYTES,
2727
} from './pdf-tools.js';
2828
import * as trace from '../trace/recorder.js';
29+
import { tracesToMarkdown } from './trace-export.js';
2930
import { solveCaptcha, detectCaptcha, injectToken } from './captcha-solver.js';
3031
import { getRecordingStateFresh as recorderStateFresh } from '../recorder/host.js';
3132
import { Capability, CAPABILITY_LABEL, capabilitiesFor, requiredHosts, frameHostMatches, isNetworkMutation, normalizeHost, PermissionManager, UNTRUSTED_CONTENT_TOOLS } from './permission-gate.js';
@@ -6307,6 +6308,72 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
63076308
return idx >= 0 ? this._messageText(messages[idx]?.content) : '';
63086309
}
63096310

6311+
/**
6312+
* Serialize a tab's conversation to Markdown for /export-with-traces, sourced
6313+
* from the trace store (compaction-immune, raw structured results) — NOT from
6314+
* this.conversations. Hydrates first so it works across service-worker restarts.
6315+
* Returns { ok, markdown|null, turnCount, reason }: reason 'no-conversation', or
6316+
* 'no-traces' (tracing off / nothing recorded) so the UI can say so instead of
6317+
* downloading an empty-but-official-looking file.
6318+
*/
6319+
async exportTraces(tabId) {
6320+
await this._hydrate(tabId);
6321+
const conversationId = this.conversationIds.get(tabId);
6322+
if (!conversationId) return { ok: true, markdown: null, turnCount: 0, reason: 'no-conversation' };
6323+
// Cap matching runs for this conversation only (not a global newest-N).
6324+
const RUN_LIMIT = 500;
6325+
let runs;
6326+
let truncated = false;
6327+
try {
6328+
const matched = await trace.listRuns({ limit: RUN_LIMIT, conversationId });
6329+
truncated = matched.length >= RUN_LIMIT;
6330+
runs = matched
6331+
.filter((r) => r && r.conversationId === conversationId)
6332+
.sort((a, b) => (a.startedAt || 0) - (b.startedAt || 0));
6333+
} catch (e) {
6334+
return { ok: false, error: String((e && e.message) || e) };
6335+
}
6336+
if (runs.length === 0) return { ok: true, markdown: null, turnCount: 0, reason: 'no-traces' };
6337+
const withEvents = [];
6338+
let failedEventLoads = 0;
6339+
for (const run of runs) {
6340+
let events = [];
6341+
try {
6342+
events = await trace.getRunEvents(run.runId);
6343+
} catch (e) {
6344+
events = [];
6345+
failedEventLoads += 1;
6346+
}
6347+
withEvents.push({ run, events });
6348+
}
6349+
if (failedEventLoads === runs.length) {
6350+
return { ok: false, error: 'Could not read any trace events for this conversation.' };
6351+
}
6352+
const notes = [];
6353+
if (failedEventLoads > 0) {
6354+
notes.push(`${failedEventLoads} of ${runs.length} turn(s) could not load their event log.`);
6355+
}
6356+
if (truncated) {
6357+
notes.push(`Export limited to the ${RUN_LIMIT} most recent traced turns for this conversation.`);
6358+
}
6359+
let markdown;
6360+
let turnCount;
6361+
let toolCount;
6362+
try {
6363+
({ markdown, turnCount, toolCount } = tracesToMarkdown(withEvents, { notes }));
6364+
} catch (e) {
6365+
return { ok: false, error: String((e && e.message) || e) };
6366+
}
6367+
return {
6368+
ok: true,
6369+
markdown,
6370+
turnCount,
6371+
toolCount,
6372+
partial: failedEventLoads > 0,
6373+
truncated,
6374+
};
6375+
}
6376+
63106377
_isAgentInjectedUserContent(content) {
63116378
const c = this._messageText(content).trimStart();
63126379
return c.startsWith('[Site guidance')
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/**
2+
* Pure trace → Markdown serializer for /export-with-traces.
3+
*
4+
* Consumes the trace store's per-run event log (trace/recorder.js) — an
5+
* append-only, compaction-immune record whose tool results are the RAW structured
6+
* values (pre-truncated by the recorder, never `_wrapUntrusted`-wrapped). That is
7+
* the right source for a tool chain; `this.conversations` is not (it is compacted,
8+
* enriched, and wrapped — see the closed PR #348 review).
9+
*
10+
* This renders the TOOL CHAIN: user/assistant/planner prose, tool calls (name,
11+
* args, result), and errors — in order. Screenshot / note / vision_sub_call events
12+
* are recorded but deliberately not rendered; the file says so in its footer, so it
13+
* never claims to be a complete record. The complete record is the Traces-page JSON.
14+
*
15+
* Pure and browser-neutral → unit-tested in test/run.js without a DOM or IndexedDB.
16+
*
17+
* @param {Array<{run: object, events: Array}>} runsWithEvents chronological runs,
18+
* each with its ordered event list.
19+
*/
20+
21+
const ARGS_LIMIT = 300;
22+
const RESULT_LIMIT = 600;
23+
const FOOTER = '_Screenshots, notes and vision sub-calls are recorded but not rendered here — see the Traces page for the complete record._';
24+
25+
function oneLine(t) { return String(t ?? '').replace(/\s+/g, ' ').trim(); }
26+
function humanSize(n) { return n >= 1024 ? `${(n / 1024).toFixed(1)}kb` : `${n}b`; }
27+
28+
function truncate(text, limit) {
29+
const s = String(text ?? '');
30+
if (s.length <= limit) return s;
31+
return `${s.slice(0, limit)}… +${humanSize(s.length - limit)} truncated`;
32+
}
33+
34+
// Wrap text in a fenced code block that survives content which is ITSELF fenced.
35+
// Planner responses usually arrive already wrapped in ```json … ```; naively
36+
// re-fencing them produces ```\n```json\n…, which no Markdown renderer parses.
37+
// So: unwrap a single enclosing fence (keeping its language hint), then choose a
38+
// fence longer than any backtick run left inside, per CommonMark, so nothing can
39+
// close the block early.
40+
function fencedBlock(content) {
41+
let body = String(content ?? '').trim();
42+
let info = '';
43+
const wrapped = body.match(/^```([^\n]*)\n([\s\S]*?)\n```$/);
44+
if (wrapped) { info = wrapped[1].trim(); body = wrapped[2].trim(); }
45+
const longestRun = (body.match(/`+/g) || []).reduce((n, s) => Math.max(n, s.length), 0);
46+
const fence = '`'.repeat(Math.max(3, longestRun + 1));
47+
return `${fence}${info}\n${body}\n${fence}`;
48+
}
49+
50+
// IndexedDB can retain values that JSON.stringify rejects (circular / bigint /
51+
// sparse). Never throw mid-export — fall back to a readable marker.
52+
function safeJsonStringify(value) {
53+
try {
54+
return JSON.stringify(value);
55+
} catch {
56+
try {
57+
return String(value);
58+
} catch {
59+
return '(unserializable)';
60+
}
61+
}
62+
}
63+
64+
function stringifyArgs(args) {
65+
if (args == null) return '';
66+
const s = typeof args === 'string' ? args : safeJsonStringify(args);
67+
return truncate(oneLine(s), ARGS_LIMIT);
68+
}
69+
70+
// A trace tool result is a RAW value: a structured object ({success,error,...}),
71+
// a string, or the recorder's large-result marker { _truncated, length, head }.
72+
function renderResult(result) {
73+
if (result == null) return { text: '(no result recorded)', failed: false };
74+
if (typeof result === 'object' && result._truncated) {
75+
return {
76+
text: `${truncate(oneLine(String(result.head ?? '')), RESULT_LIMIT)} [recorder-truncated, ${humanSize(result.length || 0)} total]`,
77+
failed: false,
78+
};
79+
}
80+
const failed = typeof result === 'object' ? (result.success === false || !!result.error) : false;
81+
const s = typeof result === 'string' ? result : safeJsonStringify(result);
82+
return { text: truncate(oneLine(s), RESULT_LIMIT), failed };
83+
}
84+
85+
export function tracesToMarkdown(runsWithEvents, {
86+
title = 'WebBrain Conversation — tool chain',
87+
notes = [],
88+
} = {}) {
89+
const runs = Array.isArray(runsWithEvents) ? runsWithEvents : [];
90+
let md = `# ${title}\n\n`;
91+
let turnCount = 0;
92+
let toolCount = 0;
93+
94+
for (const entry of runs) {
95+
if (!entry || !entry.run) continue;
96+
turnCount += 1;
97+
const run = entry.run;
98+
const user = oneLine(run.userMessage || '');
99+
const meta = [run.model, run.status].filter(Boolean).join(' · ');
100+
md += `## Turn ${turnCount}${user ? ` — ${user}` : ''}\n`;
101+
if (meta) md += `_${meta}_\n`;
102+
md += '\n';
103+
104+
const events = Array.isArray(entry.events) ? [...entry.events].sort((a, b) => (a?.seq || 0) - (b?.seq || 0)) : [];
105+
for (const ev of events) {
106+
const d = (ev && ev.data) || {};
107+
if (ev.kind === 'llm_response') {
108+
const content = String(d.content || '').trim();
109+
if (!content) continue;
110+
// Plan-before-Act runs record the planner call with phase:'planner'; keep
111+
// it (derails often start in the plan) but label it and preserve its shape.
112+
if (d.phase === 'planner') {
113+
md += `**Planner:**\n${fencedBlock(content)}\n`;
114+
} else {
115+
md += `**WebBrain:** ${oneLine(content)}\n`;
116+
}
117+
} else if (ev.kind === 'tool') {
118+
toolCount += 1;
119+
const { text, failed } = renderResult(d.result);
120+
md += `- 🔧 \`${d.name || 'tool'}\`(${stringifyArgs(d.args)}) → ${failed ? '✗ ' : ''}${text}\n`;
121+
} else if (ev.kind === 'error') {
122+
md += `- ⚠️ error${d.phase ? ` (${d.phase})` : ''}: ${oneLine(d.message || '')}\n`;
123+
}
124+
// screenshot / note / vision_sub_call intentionally omitted — see FOOTER.
125+
}
126+
md += '\n';
127+
}
128+
129+
for (const note of Array.isArray(notes) ? notes : []) {
130+
const line = oneLine(note);
131+
if (line) md += `_Note: ${line}_\n`;
132+
}
133+
md += `${FOOTER}\n`;
134+
return { markdown: md, turnCount, toolCount };
135+
}

src/chrome/src/background.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1862,6 +1862,12 @@ async function handleMessage(msg, sender) {
18621862
return { ok: true, ...(await agent.getScratchpad(tabId)) };
18631863
}
18641864

1865+
case 'export_traces': {
1866+
const tabId = msg.tabId || sender.tab?.id;
1867+
if (!tabId) return { ok: false, error: 'No tab ID' };
1868+
return { ok: true, ...(await agent.exportTraces(tabId)) };
1869+
}
1870+
18651871
case 'get_progress': {
18661872
const tabId = msg.tabId || sender.tab?.id;
18671873
if (!tabId) return { ok: false, error: 'No tab ID' };

src/chrome/src/trace/recorder.js

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -301,19 +301,24 @@ export async function endRun(runId, { status = 'done', finalContent = null } = {
301301

302302
// ----- Reader API (used by traces.html) --------------------------------------
303303

304-
export async function listRuns({ limit = 500 } = {}) {
304+
export async function listRuns({ limit = 500, conversationId = null } = {}) {
305305
const db = await openDB();
306306
const idx = tx(db, ['runs'], 'readonly').objectStore('runs').index('startedAt');
307307
const out = [];
308-
await new Promise((resolve) => {
308+
// When conversationId is set, only matching runs count toward `limit`, so a
309+
// chat's tool-chain export is not starved by unrelated newer runs.
310+
await new Promise((resolve, reject) => {
309311
const req = idx.openCursor(null, 'prev');
310312
req.onsuccess = () => {
311313
const c = req.result;
312314
if (!c || out.length >= limit) return resolve();
313-
out.push(c.value);
315+
const row = c.value;
316+
if (!conversationId || row?.conversationId === conversationId) {
317+
out.push(row);
318+
}
314319
c.continue();
315320
};
316-
req.onerror = () => resolve();
321+
req.onerror = () => reject(req.error || new Error('listRuns failed'));
317322
});
318323
return out;
319324
}
@@ -327,15 +332,15 @@ export async function getRunEvents(runId) {
327332
const db = await openDB();
328333
const idx = tx(db, ['events'], 'readonly').objectStore('events').index('runId');
329334
const out = [];
330-
await new Promise((resolve) => {
335+
await new Promise((resolve, reject) => {
331336
const req = idx.openCursor(IDBKeyRange.only(runId));
332337
req.onsuccess = () => {
333338
const c = req.result;
334339
if (!c) return resolve();
335340
out.push(c.value);
336341
c.continue();
337342
};
338-
req.onerror = () => resolve();
343+
req.onerror = () => reject(req.error || new Error('getRunEvents failed'));
339344
});
340345
out.sort((a, b) => a.seq - b.seq);
341346
return out;

src/chrome/src/ui/locales/ar.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -551,7 +551,7 @@ export default {
551551
'sp.plan.cancelled': 'تم إلغاء الخطة.',
552552
'sp.plan.expired': 'لم تعد هذه الخطة قيد المراجعة — تم إلغاء التشغيل.',
553553
'sp.plan.awaiting_review': 'وافق على الخطة أعلاه أو ألغها قبل إرسال رسالة أخرى.',
554-
'sp.slash.busy_only_oob': 'تُضاف الرسائل إلى قائمة الانتظار بينما يكون WebBrain مشغولًا. يمكن فقط لـ /help و /check-progress و /show-scratchpad و /show-memory و /list-schedules و /dangerously-skip-permissions و /screenshot و /export و /verbose العمل فورًا كأوامر slash.',
554+
'sp.slash.busy_only_oob': 'تُضاف الرسائل إلى قائمة الانتظار بينما يكون WebBrain مشغولًا. يمكن فقط لـ /help و /check-progress و /show-scratchpad و /show-memory و /list-schedules و /dangerously-skip-permissions و /screenshot و /export و /export-with-traces و /verbose العمل فورًا كأوامر slash.',
555555
'tool.go_back': 'العودة للخلف',
556556
'tool.go_forward': 'التقدم للأمام',
557557
'st.display.search.placeholder': 'البحث في الإعدادات العامة',
@@ -704,4 +704,11 @@ export default {
704704
"st.redaction.toggle.label": "إخفاء المحتوى الحساس في لقطات الشاشة",
705705
"st.redaction.toggle.desc": "قبل إرسال لقطة الشاشة إلى نموذج رؤية، يقوم بتغبيش حقول النماذج والنص الذي يبدو كبريد إلكتروني أو رقم هاتف. يعمل الكشف بالكامل على جهازك — لا يُنقل أي شيء إضافي.",
706706
"st.redaction.warning": "⚠️ هذا تعتيم بأفضل جهد ومن نوع fail-open: إذا تعذّر تشغيل التعتيم على صفحة ما (مثلًا مباشرة بعد التنقل، أو في عارضات PDF، أو على صفحات المتصفح المقيّدة)، فستُرسل لقطة الشاشة كما هي دون تعتيم. يعتمد الكشف فقط على استدلالات DOM — النص المرسوم على عنصر canvas، أو البيانات الشخصية داخل الصور، أو أي شيء لا يُعرف كحقل نموذج أو نص بريد/هاتف قد يفلت من الكشف، كما أن نص الصفحة المُرسل إلى النموذج لا يُعتَّم بهذا الإعداد. هذا ليس ضمانًا أمنيًا. للخصوصية الكاملة، استخدم نموذجًا محليًا/دون اتصال (llama.cpp أو Ollama): عندها لن تغادر لقطات الشاشة جهازك إطلاقًا ولن تعود هناك حاجة للتعتيم.",
707+
"sp.slash.export_traces": "تصدير سلسلة الأدوات (التتبعات)",
708+
"sp.export_traces.none": "لا توجد تتبعات لهذه المحادثة. فعّل «تسجيل التتبعات» في الإعدادات ثم أعد التشغيل.",
709+
"sp.export_traces.error": "تعذّر تصدير التتبعات.",
710+
"sp.export_traces.done": "تم تصدير سلسلة الأدوات.",
711+
"sp.export_traces.no_conversation": "لا يوجد ما يُصدَّر بعد — ابدأ محادثة أولًا.",
712+
"sp.export_traces.partial": "تم تصدير سلسلة الأدوات، لكن تعذّر قراءة بعض أحداث الجولات.",
713+
"sp.export_traces.truncated": "تم تصدير سلسلة الأدوات. قد تُفقد الجولات الأقدم إذا كان لهذه المحادثة كثير من التتبعات.",
707714
};

0 commit comments

Comments
 (0)