Skip to content

Commit 0591756

Browse files
authored
Merge pull request #516 from alectimison-maker/feat/atif-trace-export
feat: add ATIF trace converter
2 parents 65c6f8d + 8c7fd89 commit 0591756

3 files changed

Lines changed: 632 additions & 0 deletions

File tree

docs/export-and-workflow-formats.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,32 @@ Screenshot events can include `screenshot_base64` or `screenshot_dataUrl`.
5959
Consumers should check `schema`, tolerate additional fields, and avoid relying
6060
on undocumented event internals.
6161

62+
### Convert a trace to ATIF v1.7
63+
64+
The dependency-free converter turns one Traces-page JSON export into the
65+
[Agent Trajectory Interchange Format (ATIF)](https://github.com/harbor-framework/harbor/blob/main/rfcs/0001-trajectory-format.md):
66+
67+
```bash
68+
node scripts/trace-to-atif.mjs webbrain-trace-model-run.json
69+
```
70+
71+
By default this writes `webbrain-trace-model-run.atif.json` next to the source
72+
file. Pass a second path to choose another destination, or `-` to write the
73+
trajectory to standard output:
74+
75+
```bash
76+
node scripts/trace-to-atif.mjs trace.json trajectory.json
77+
node scripts/trace-to-atif.mjs trace.json -
78+
```
79+
80+
The converter maps user and agent messages, tool calls and observations, token
81+
metrics, errors, model metadata, and final content. It does not upload data.
82+
Screenshots and verbose diagnostic event kinds are counted in
83+
`extra.omitted_event_counts` but are not copied: ATIF represents images as
84+
files referenced alongside the trajectory, while a WebBrain JSON export embeds
85+
them as data URLs or base64. The converted trajectory remains sensitive because
86+
it still contains prompts, tool arguments, URLs, and results.
87+
6288
### Convert a trace to OpenTelemetry OTLP JSON
6389

6490
The repository includes an offline converter for sending an exported

scripts/trace-to-atif.mjs

Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Convert a Traces-page `webbrain-trace/1` JSON export to ATIF v1.7.
5+
*
6+
* This deliberately stays offline and dependency-free. Screenshots and verbose
7+
* diagnostic events are counted but not copied because ATIF represents images
8+
* as files referenced alongside the trajectory, not embedded data URLs.
9+
*/
10+
11+
import fs from 'node:fs/promises';
12+
import path from 'node:path';
13+
import process from 'node:process';
14+
import { fileURLToPath } from 'node:url';
15+
16+
const SOURCE_SCHEMA = 'webbrain-trace/1';
17+
const ATIF_SCHEMA_VERSION = 'ATIF-v1.7';
18+
19+
function isObject(value) {
20+
return !!value && typeof value === 'object' && !Array.isArray(value);
21+
}
22+
23+
function finiteNumber(value) {
24+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
25+
}
26+
27+
function timestamp(value) {
28+
if (value == null || value === '') return undefined;
29+
const date = new Date(value);
30+
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
31+
}
32+
33+
function jsonSafe(value, fallback) {
34+
try {
35+
const serialized = JSON.stringify(value);
36+
return serialized === undefined ? fallback : JSON.parse(serialized);
37+
} catch {
38+
return fallback;
39+
}
40+
}
41+
42+
function compactObject(value) {
43+
return Object.fromEntries(
44+
Object.entries(value).filter(([, item]) => item !== undefined),
45+
);
46+
}
47+
48+
function parseArguments(value) {
49+
if (isObject(value)) return { arguments: jsonSafe(value, {}) };
50+
if (typeof value === 'string') {
51+
try {
52+
const parsed = JSON.parse(value);
53+
if (isObject(parsed)) return { arguments: parsed };
54+
} catch {}
55+
return {
56+
arguments: {},
57+
extra: { raw_arguments: value },
58+
};
59+
}
60+
if (value == null) return { arguments: {} };
61+
return {
62+
arguments: {},
63+
extra: { raw_arguments: String(value) },
64+
};
65+
}
66+
67+
function resultContent(value) {
68+
if (value === undefined) return '(missing tool result)';
69+
if (typeof value === 'string') return value;
70+
try {
71+
const serialized = JSON.stringify(value);
72+
return serialized === undefined ? String(value) : serialized;
73+
} catch {
74+
return String(value);
75+
}
76+
}
77+
78+
function usageMetrics(usage) {
79+
if (!isObject(usage)) return undefined;
80+
const extra = {};
81+
// WebBrain records provider-native cost. Some providers report USD, while
82+
// others do not guarantee a currency, so never mislabel it as ATIF cost_usd.
83+
const reportedCost = finiteNumber(usage.cost);
84+
if (reportedCost !== undefined) extra.webbrain_reported_cost = reportedCost;
85+
const metrics = compactObject({
86+
prompt_tokens: finiteNumber(usage.prompt_tokens),
87+
completion_tokens: finiteNumber(usage.completion_tokens),
88+
cached_tokens: finiteNumber(
89+
usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens,
90+
),
91+
extra: Object.keys(extra).length ? extra : undefined,
92+
});
93+
return Object.keys(metrics).length ? metrics : undefined;
94+
}
95+
96+
function eventOrder(left, right) {
97+
const leftSeq = finiteNumber(left?.seq) ?? Number.MAX_SAFE_INTEGER;
98+
const rightSeq = finiteNumber(right?.seq) ?? Number.MAX_SAFE_INTEGER;
99+
if (leftSeq !== rightSeq) return leftSeq - rightSeq;
100+
return (finiteNumber(left?.ts) ?? 0) - (finiteNumber(right?.ts) ?? 0);
101+
}
102+
103+
function sameToolName(left, right) {
104+
return String(left || '') === String(right || '');
105+
}
106+
107+
export function webbrainTraceToAtif(input) {
108+
if (!isObject(input)) throw new TypeError('WebBrain trace export must be an object.');
109+
if (input.schema !== SOURCE_SCHEMA) {
110+
throw new TypeError(`Expected schema "${SOURCE_SCHEMA}".`);
111+
}
112+
if (!isObject(input.run)) throw new TypeError('WebBrain trace run must be an object.');
113+
if (!Array.isArray(input.events)) throw new TypeError('WebBrain trace events must be an array.');
114+
if (typeof input.run.runId !== 'string' || !input.run.runId.trim()) {
115+
throw new TypeError('WebBrain trace run.runId must be a non-empty string.');
116+
}
117+
118+
const run = input.run;
119+
const runId = String(run.runId).trim();
120+
const version = String(
121+
run.webbrainVersion || input.exportedByWebBrainVersion || 'unknown',
122+
);
123+
const steps = [];
124+
const omittedEventCounts = {};
125+
let lastAgentMessage = '';
126+
let pendingAgent = null;
127+
const allocatedToolCallIds = new Set();
128+
129+
function appendStep(step) {
130+
const complete = { step_id: steps.length + 1, ...compactObject(step) };
131+
steps.push(complete);
132+
return complete;
133+
}
134+
135+
appendStep({
136+
timestamp: timestamp(run.startedAt),
137+
source: 'user',
138+
message: String(run.userMessage || ''),
139+
});
140+
141+
for (const event of [...input.events].sort(eventOrder)) {
142+
if (!isObject(event)) continue;
143+
if (event.runId != null && String(event.runId) !== runId) {
144+
throw new TypeError(`Trace event runId "${event.runId}" does not match run.runId "${runId}".`);
145+
}
146+
const data = isObject(event.data) ? event.data : {};
147+
const eventSeq = finiteNumber(event.seq);
148+
149+
if (event.kind === 'llm_response') {
150+
const toolCalls = Array.isArray(data.toolCalls)
151+
? data.toolCalls.map((call, index) => {
152+
const parsed = parseArguments(call?.args);
153+
const recordedId = String(call?.id || '').trim();
154+
const fallbackId = `webbrain-${runId}-${eventSeq ?? 'unknown'}-${index + 1}`;
155+
const toolCallId = recordedId && !allocatedToolCallIds.has(recordedId)
156+
? recordedId
157+
: fallbackId;
158+
allocatedToolCallIds.add(toolCallId);
159+
return compactObject({
160+
tool_call_id: toolCallId,
161+
function_name: String(call?.name || 'unknown_tool'),
162+
arguments: parsed.arguments,
163+
extra: parsed.extra,
164+
});
165+
})
166+
: [];
167+
const message = String(data.content || '');
168+
const extra = compactObject({
169+
webbrain_seq: eventSeq,
170+
webbrain_step: finiteNumber(data.step),
171+
phase: data.phase ? String(data.phase) : undefined,
172+
latency_ms: finiteNumber(data.latencyMs),
173+
});
174+
const step = appendStep({
175+
timestamp: timestamp(event.ts),
176+
source: 'agent',
177+
model_name: data.model ? String(data.model) : undefined,
178+
message,
179+
tool_calls: toolCalls.length ? toolCalls : undefined,
180+
metrics: usageMetrics(data.usage),
181+
llm_call_count: 1,
182+
extra: Object.keys(extra).length ? extra : undefined,
183+
});
184+
pendingAgent = {
185+
step,
186+
webbrainStep: finiteNumber(data.step),
187+
usedCallIds: new Set(),
188+
};
189+
if (message.trim()) lastAgentMessage = message.trim();
190+
continue;
191+
}
192+
193+
if (event.kind === 'tool') {
194+
const toolName = String(data.name || 'unknown_tool');
195+
const pendingCall = pendingAgent?.step.tool_calls?.find((call) => (
196+
!pendingAgent.usedCallIds.has(call.tool_call_id)
197+
&& sameToolName(call.function_name, toolName)
198+
&& (
199+
pendingAgent.webbrainStep === undefined
200+
|| finiteNumber(data.step) === undefined
201+
|| pendingAgent.webbrainStep === finiteNumber(data.step)
202+
)
203+
));
204+
const observationExtra = compactObject({
205+
latency_ms: finiteNumber(data.latencyMs),
206+
webbrain_seq: eventSeq,
207+
});
208+
if (pendingCall) {
209+
pendingAgent.usedCallIds.add(pendingCall.tool_call_id);
210+
if (!pendingAgent.step.observation) pendingAgent.step.observation = { results: [] };
211+
pendingAgent.step.observation.results.push({
212+
source_call_id: pendingCall.tool_call_id,
213+
content: resultContent(data.result),
214+
...(Object.keys(observationExtra).length ? { extra: observationExtra } : {}),
215+
});
216+
continue;
217+
}
218+
219+
const parsed = parseArguments(data.args);
220+
let toolCallId = `webbrain-${runId}-${eventSeq ?? steps.length + 1}-1`;
221+
let suffix = 2;
222+
while (allocatedToolCallIds.has(toolCallId)) {
223+
toolCallId = `webbrain-${runId}-${eventSeq ?? steps.length + 1}-${suffix}`;
224+
suffix += 1;
225+
}
226+
allocatedToolCallIds.add(toolCallId);
227+
const stepExtra = compactObject({
228+
webbrain_seq: eventSeq,
229+
webbrain_step: finiteNumber(data.step),
230+
});
231+
appendStep({
232+
timestamp: timestamp(event.ts),
233+
source: 'agent',
234+
message: '',
235+
tool_calls: [compactObject({
236+
tool_call_id: toolCallId,
237+
function_name: toolName,
238+
arguments: parsed.arguments,
239+
extra: parsed.extra,
240+
})],
241+
observation: {
242+
results: [{
243+
source_call_id: toolCallId,
244+
content: resultContent(data.result),
245+
...(Object.keys(observationExtra).length ? { extra: observationExtra } : {}),
246+
}],
247+
},
248+
llm_call_count: 0,
249+
extra: Object.keys(stepExtra).length ? stepExtra : undefined,
250+
});
251+
pendingAgent = null;
252+
continue;
253+
}
254+
255+
if (event.kind === 'error') {
256+
const errorExtra = compactObject({
257+
webbrain_seq: eventSeq,
258+
webbrain_step: finiteNumber(data.step),
259+
phase: data.phase ? String(data.phase) : undefined,
260+
});
261+
appendStep({
262+
timestamp: timestamp(event.ts),
263+
source: 'system',
264+
message: 'WebBrain runtime error',
265+
observation: {
266+
results: [{ content: String(data.message || 'Unknown error') }],
267+
},
268+
extra: Object.keys(errorExtra).length ? errorExtra : undefined,
269+
});
270+
pendingAgent = null;
271+
continue;
272+
}
273+
274+
const kind = String(event.kind || 'unknown');
275+
omittedEventCounts[kind] = (omittedEventCounts[kind] || 0) + 1;
276+
}
277+
278+
const finalContent = String(run.finalContent || '').trim();
279+
if (finalContent && finalContent !== lastAgentMessage) {
280+
appendStep({
281+
timestamp: timestamp(run.endedAt),
282+
source: 'agent',
283+
message: finalContent,
284+
extra: { webbrain_final_content: true },
285+
});
286+
}
287+
288+
const agentExtra = compactObject({
289+
provider_id: run.providerId ? String(run.providerId) : undefined,
290+
provider_class: run.providerClass ? String(run.providerClass) : undefined,
291+
mode: run.mode ? String(run.mode) : undefined,
292+
});
293+
const rootExtra = compactObject({
294+
source_schema: SOURCE_SCHEMA,
295+
source_exported_at: timestamp(input.exportedAt),
296+
source_exported_by_webbrain_version: input.exportedByWebBrainVersion
297+
? String(input.exportedByWebBrainVersion)
298+
: undefined,
299+
status: run.status ? String(run.status) : undefined,
300+
tab_url: run.tabUrl ? String(run.tabUrl) : undefined,
301+
tab_title: run.tabTitle ? String(run.tabTitle) : undefined,
302+
omitted_event_counts: Object.keys(omittedEventCounts).length
303+
? omittedEventCounts
304+
: undefined,
305+
});
306+
const finalMetrics = compactObject({
307+
total_prompt_tokens: finiteNumber(run.totalInputTokens),
308+
total_completion_tokens: finiteNumber(run.totalOutputTokens),
309+
total_steps: steps.length,
310+
});
311+
312+
return {
313+
schema_version: ATIF_SCHEMA_VERSION,
314+
session_id: runId,
315+
trajectory_id: runId,
316+
agent: compactObject({
317+
name: 'webbrain',
318+
version,
319+
model_name: run.model ? String(run.model) : undefined,
320+
extra: Object.keys(agentExtra).length ? agentExtra : undefined,
321+
}),
322+
steps,
323+
final_metrics: finalMetrics,
324+
extra: rootExtra,
325+
};
326+
}
327+
328+
async function main(argv) {
329+
const [inputPath, outputPath] = argv;
330+
if (!inputPath || argv.length > 2) {
331+
throw new Error('Usage: node scripts/trace-to-atif.mjs <trace.json> [trajectory.json|-]');
332+
}
333+
const raw = await fs.readFile(inputPath, 'utf8');
334+
const trajectory = webbrainTraceToAtif(JSON.parse(raw));
335+
const serialized = `${JSON.stringify(trajectory, null, 2)}\n`;
336+
if (outputPath === '-') {
337+
process.stdout.write(serialized);
338+
return;
339+
}
340+
const destination = outputPath || path.join(
341+
path.dirname(inputPath),
342+
`${path.basename(inputPath, path.extname(inputPath))}.atif.json`,
343+
);
344+
await fs.writeFile(destination, serialized, 'utf8');
345+
process.stderr.write(`Wrote ${destination}\n`);
346+
}
347+
348+
const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : '';
349+
if (invokedPath === fileURLToPath(import.meta.url)) {
350+
main(process.argv.slice(2)).catch((error) => {
351+
process.stderr.write(`${error?.message || error}\n`);
352+
process.exitCode = 1;
353+
});
354+
}

0 commit comments

Comments
 (0)