@traceai/codex-sdk 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +172 -2
- package/dist/esm/attributes.d.ts +101 -0
- package/dist/esm/attributes.d.ts.map +1 -0
- package/dist/esm/attributes.js +104 -0
- package/dist/esm/attributes.js.map +1 -0
- package/dist/esm/codexWrapper.d.ts +5 -0
- package/dist/esm/codexWrapper.d.ts.map +1 -0
- package/dist/esm/codexWrapper.js +63 -0
- package/dist/esm/codexWrapper.js.map +1 -0
- package/dist/esm/index.d.ts +8 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +5 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/instrumentation.d.ts +85 -0
- package/dist/esm/instrumentation.d.ts.map +1 -0
- package/dist/esm/instrumentation.js +195 -0
- package/dist/esm/instrumentation.js.map +1 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/spans.d.ts +152 -0
- package/dist/esm/spans.d.ts.map +1 -0
- package/dist/esm/spans.js +678 -0
- package/dist/esm/spans.js.map +1 -0
- package/dist/esm/threadWrapper.d.ts +35 -0
- package/dist/esm/threadWrapper.d.ts.map +1 -0
- package/dist/esm/threadWrapper.js +208 -0
- package/dist/esm/threadWrapper.js.map +1 -0
- package/dist/esm/types.d.ts +123 -0
- package/dist/esm/types.d.ts.map +1 -0
- package/dist/esm/types.js +10 -0
- package/dist/esm/types.js.map +1 -0
- package/dist/esm/version.d.ts +2 -0
- package/dist/esm/version.d.ts.map +1 -0
- package/dist/esm/version.js +2 -0
- package/dist/esm/version.js.map +1 -0
- package/dist/esnext/attributes.d.ts +101 -0
- package/dist/esnext/attributes.d.ts.map +1 -0
- package/dist/esnext/attributes.js +104 -0
- package/dist/esnext/attributes.js.map +1 -0
- package/dist/esnext/codexWrapper.d.ts +5 -0
- package/dist/esnext/codexWrapper.d.ts.map +1 -0
- package/dist/esnext/codexWrapper.js +65 -0
- package/dist/esnext/codexWrapper.js.map +1 -0
- package/dist/esnext/index.d.ts +8 -0
- package/dist/esnext/index.d.ts.map +1 -0
- package/dist/esnext/index.js +5 -0
- package/dist/esnext/index.js.map +1 -0
- package/dist/esnext/instrumentation.d.ts +85 -0
- package/dist/esnext/instrumentation.d.ts.map +1 -0
- package/dist/esnext/instrumentation.js +200 -0
- package/dist/esnext/instrumentation.js.map +1 -0
- package/dist/esnext/spans.d.ts +152 -0
- package/dist/esnext/spans.d.ts.map +1 -0
- package/dist/esnext/spans.js +688 -0
- package/dist/esnext/spans.js.map +1 -0
- package/dist/esnext/threadWrapper.d.ts +35 -0
- package/dist/esnext/threadWrapper.d.ts.map +1 -0
- package/dist/esnext/threadWrapper.js +206 -0
- package/dist/esnext/threadWrapper.js.map +1 -0
- package/dist/esnext/types.d.ts +123 -0
- package/dist/esnext/types.d.ts.map +1 -0
- package/dist/esnext/types.js +10 -0
- package/dist/esnext/types.js.map +1 -0
- package/dist/esnext/version.d.ts +2 -0
- package/dist/esnext/version.d.ts.map +1 -0
- package/dist/esnext/version.js +2 -0
- package/dist/esnext/version.js.map +1 -0
- package/dist/src/attributes.d.ts +101 -0
- package/dist/src/attributes.d.ts.map +1 -0
- package/dist/src/attributes.js +107 -0
- package/dist/src/attributes.js.map +1 -0
- package/dist/src/codexWrapper.d.ts +5 -0
- package/dist/src/codexWrapper.d.ts.map +1 -0
- package/dist/src/codexWrapper.js +68 -0
- package/dist/src/codexWrapper.js.map +1 -0
- package/dist/src/index.d.ts +8 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +21 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/instrumentation.d.ts +85 -0
- package/dist/src/instrumentation.d.ts.map +1 -0
- package/dist/src/instrumentation.js +222 -0
- package/dist/src/instrumentation.js.map +1 -0
- package/dist/src/spans.d.ts +152 -0
- package/dist/src/spans.d.ts.map +1 -0
- package/dist/src/spans.js +698 -0
- package/dist/src/spans.js.map +1 -0
- package/dist/src/threadWrapper.d.ts +35 -0
- package/dist/src/threadWrapper.d.ts.map +1 -0
- package/dist/src/threadWrapper.js +229 -0
- package/dist/src/threadWrapper.js.map +1 -0
- package/dist/src/types.d.ts +123 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +11 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/version.d.ts +2 -0
- package/dist/src/version.d.ts.map +1 -0
- package/dist/src/version.js +5 -0
- package/dist/src/version.js.map +1 -0
- package/package.json +76 -3
- package/src/attributes.ts +113 -0
- package/src/codexWrapper.ts +71 -0
- package/src/index.ts +27 -0
- package/src/instrumentation.ts +227 -0
- package/src/spans.ts +729 -0
- package/src/threadWrapper.ts +221 -0
- package/src/types.ts +113 -0
- package/src/version.ts +1 -0
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Span model for one Codex turn (one `Thread.run()` or `Thread.runStreamed()`).
|
|
3
|
+
*
|
|
4
|
+
* The SDK yields `ThreadEvent`s, not spans. `TurnTracer` turns them into:
|
|
5
|
+
*
|
|
6
|
+
* codex.turn (AGENT) session.id = thread id, usage
|
|
7
|
+
* ├─ codex.reasoning (CHAIN)
|
|
8
|
+
* ├─ codex.command_execution (TOOL)
|
|
9
|
+
* ├─ codex.file_change (TOOL)
|
|
10
|
+
* ├─ codex.mcp_tool_call (TOOL)
|
|
11
|
+
* ├─ codex.web_search (TOOL)
|
|
12
|
+
* ├─ codex.todo_list (CHAIN)
|
|
13
|
+
* ├─ codex.agent_message (LLM)
|
|
14
|
+
* └─ codex.error (CHAIN, codex.warning=true) non-fatal error item
|
|
15
|
+
*
|
|
16
|
+
* Every public method swallows its own errors: tracing never fails the agent.
|
|
17
|
+
*/
|
|
18
|
+
import { SpanKind, SpanStatusCode, diag, trace, } from "@opentelemetry/api";
|
|
19
|
+
import { getAttributesFromContext } from "@traceai/fi-core";
|
|
20
|
+
import { MimeType } from "@traceai/fi-semantic-conventions";
|
|
21
|
+
import { AGENT_NAME, CodexAttributes as C, ITEM_SPANS, PROVIDER_NAME, SpanNames, StandardAttributes as S, UNKNOWN_ITEM_SPAN, } from "./attributes.js";
|
|
22
|
+
const CONTENT_MAX = 5000;
|
|
23
|
+
const ERROR_MESSAGE_MAX = 500;
|
|
24
|
+
const LOG = "@traceai/codex-sdk";
|
|
25
|
+
/**
|
|
26
|
+
* One clock for every start and end time the wrapper sets. OTel falls back to
|
|
27
|
+
* millisecond `Date.now()` for the end of a span whose start time was given,
|
|
28
|
+
* so mixing explicit and implicit times can order a child after its parent.
|
|
29
|
+
*/
|
|
30
|
+
export function clockMs() {
|
|
31
|
+
const perf = globalThis.performance;
|
|
32
|
+
if (perf && typeof perf.now === "function" && typeof perf.timeOrigin === "number") {
|
|
33
|
+
return perf.timeOrigin + perf.now();
|
|
34
|
+
}
|
|
35
|
+
return Date.now();
|
|
36
|
+
}
|
|
37
|
+
export function toHrTime(ms) {
|
|
38
|
+
const seconds = Math.floor(ms / 1000);
|
|
39
|
+
const nanos = Math.round((ms - seconds * 1000) * 1e6);
|
|
40
|
+
return nanos >= 1e9 ? [seconds + 1, nanos - 1e9] : [seconds, nanos];
|
|
41
|
+
}
|
|
42
|
+
export function truncate(value, max) {
|
|
43
|
+
return value.length > max ? `${value.slice(0, max)}...[truncated]` : value;
|
|
44
|
+
}
|
|
45
|
+
export function safeJson(value, max = CONTENT_MAX) {
|
|
46
|
+
try {
|
|
47
|
+
const text = typeof value === "string" ? value : JSON.stringify(value);
|
|
48
|
+
return truncate(text ?? String(value), max);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return truncate(String(value), max);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Same normalization as the SDK's `normalizeInput`: text parts joined by a blank line. */
|
|
55
|
+
export function normalizeInput(input) {
|
|
56
|
+
if (typeof input === "string") {
|
|
57
|
+
return { prompt: input, images: 0 };
|
|
58
|
+
}
|
|
59
|
+
const parts = [];
|
|
60
|
+
let images = 0;
|
|
61
|
+
if (Array.isArray(input)) {
|
|
62
|
+
for (const part of input) {
|
|
63
|
+
if (part && part.type === "text" && typeof part.text === "string")
|
|
64
|
+
parts.push(part.text);
|
|
65
|
+
else if (part && part.type === "local_image")
|
|
66
|
+
images += 1;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { prompt: parts.join("\n\n"), images };
|
|
70
|
+
}
|
|
71
|
+
function isAbortLike(error, signal) {
|
|
72
|
+
if (signal?.aborted)
|
|
73
|
+
return true;
|
|
74
|
+
const name = error?.name;
|
|
75
|
+
return name === "AbortError";
|
|
76
|
+
}
|
|
77
|
+
function errorMessage(error) {
|
|
78
|
+
if (error instanceof Error)
|
|
79
|
+
return error.message;
|
|
80
|
+
return typeof error === "string" ? error : safeJson(error, ERROR_MESSAGE_MAX);
|
|
81
|
+
}
|
|
82
|
+
function errorName(error) {
|
|
83
|
+
const name = error?.name;
|
|
84
|
+
return typeof name === "string" && name ? name : "Error";
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The SDK builds its thrown messages from raw data: `Codex Exec exited with
|
|
88
|
+
* code 1: <the binary's stderr>` and `Failed to parse item: <the raw line>`
|
|
89
|
+
* (dist/index.js of 0.162.1). Stderr can hold config lines, local paths and
|
|
90
|
+
* the user name; the raw line can hold the prompt or command output. With
|
|
91
|
+
* outputs hidden only this fixed summary is exported.
|
|
92
|
+
*/
|
|
93
|
+
export function errorSummary(error) {
|
|
94
|
+
const message = error instanceof Error ? error.message : typeof error === "string" ? error : "";
|
|
95
|
+
const exited = /^Codex Exec exited with (code -?\d+|signal [A-Z0-9]+)/.exec(message);
|
|
96
|
+
if (exited)
|
|
97
|
+
return `Codex Exec exited with ${exited[1]}`;
|
|
98
|
+
if (message.startsWith("Failed to parse item"))
|
|
99
|
+
return "Failed to parse item";
|
|
100
|
+
return errorName(error);
|
|
101
|
+
}
|
|
102
|
+
function finite(value) {
|
|
103
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
104
|
+
}
|
|
105
|
+
const USAGE_KEYS = [
|
|
106
|
+
"input_tokens",
|
|
107
|
+
"cached_input_tokens",
|
|
108
|
+
"cache_write_input_tokens",
|
|
109
|
+
"output_tokens",
|
|
110
|
+
"reasoning_output_tokens",
|
|
111
|
+
];
|
|
112
|
+
function usageTotals(usage) {
|
|
113
|
+
const totals = {};
|
|
114
|
+
for (const key of USAGE_KEYS) {
|
|
115
|
+
const value = finite(usage[key]);
|
|
116
|
+
if (value !== undefined)
|
|
117
|
+
totals[key] = value;
|
|
118
|
+
}
|
|
119
|
+
return totals;
|
|
120
|
+
}
|
|
121
|
+
function maxUsage(a, b) {
|
|
122
|
+
const out = {};
|
|
123
|
+
for (const key of USAGE_KEYS) {
|
|
124
|
+
const value = Math.max(a[key] ?? 0, b[key] ?? 0);
|
|
125
|
+
if (a[key] !== undefined || b[key] !== undefined)
|
|
126
|
+
out[key] = value;
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
/** `current - previous` per key, or `undefined` when any key went down. */
|
|
131
|
+
function subtractUsage(current, previous) {
|
|
132
|
+
const delta = {};
|
|
133
|
+
for (const key of USAGE_KEYS) {
|
|
134
|
+
const value = current[key];
|
|
135
|
+
if (value === undefined)
|
|
136
|
+
continue;
|
|
137
|
+
const diff = value - (previous[key] ?? 0);
|
|
138
|
+
if (diff < 0)
|
|
139
|
+
return undefined;
|
|
140
|
+
delta[key] = diff;
|
|
141
|
+
}
|
|
142
|
+
return delta;
|
|
143
|
+
}
|
|
144
|
+
const MAX_TRACKED_THREADS = 1000;
|
|
145
|
+
/**
|
|
146
|
+
* Last running total seen per thread id in this process (LRU, 1000 threads).
|
|
147
|
+
* Exact while a thread's turns run in one process; see the README.
|
|
148
|
+
*/
|
|
149
|
+
class UsageBaselines {
|
|
150
|
+
constructor() {
|
|
151
|
+
this.totals = new Map();
|
|
152
|
+
}
|
|
153
|
+
get(threadId) {
|
|
154
|
+
return this.totals.get(threadId);
|
|
155
|
+
}
|
|
156
|
+
set(threadId, totals) {
|
|
157
|
+
this.totals.delete(threadId);
|
|
158
|
+
this.totals.set(threadId, totals);
|
|
159
|
+
while (this.totals.size > MAX_TRACKED_THREADS) {
|
|
160
|
+
const oldest = this.totals.keys().next().value;
|
|
161
|
+
if (oldest === undefined)
|
|
162
|
+
break;
|
|
163
|
+
this.totals.delete(oldest);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
clear() {
|
|
167
|
+
this.totals.clear();
|
|
168
|
+
}
|
|
169
|
+
get size() {
|
|
170
|
+
return this.totals.size;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
export const usageBaselines = new UsageBaselines();
|
|
174
|
+
export class TurnTracer {
|
|
175
|
+
constructor(args) {
|
|
176
|
+
this.finished = false;
|
|
177
|
+
this.completed = false;
|
|
178
|
+
this.streamErrorCount = 0;
|
|
179
|
+
this.items = new Map();
|
|
180
|
+
this.anonymousItems = 0;
|
|
181
|
+
this.tracer = args.tracer;
|
|
182
|
+
this.policy = args.policy;
|
|
183
|
+
this.parentContext = args.parentContext;
|
|
184
|
+
this.input = args.input;
|
|
185
|
+
this.turnOptions = args.turnOptions;
|
|
186
|
+
this.threadOptions = args.threadOptions;
|
|
187
|
+
this.startTimeMs = args.startTimeMs ?? clockMs();
|
|
188
|
+
this.lastEventMs = this.startTimeMs;
|
|
189
|
+
this.contextAttributes = safeContextAttributes(args.parentContext);
|
|
190
|
+
this.appSetSession = this.contextAttributes[S.SESSION_ID] != null;
|
|
191
|
+
// A thread with an id before its first event was resumed (resumeThread or a second run()).
|
|
192
|
+
this.resumed = typeof args.threadId === "string" && args.threadId.length > 0;
|
|
193
|
+
if (this.resumed)
|
|
194
|
+
this.threadId = args.threadId;
|
|
195
|
+
}
|
|
196
|
+
get hideErrorText() {
|
|
197
|
+
return this.policy.hideOutputs || this.policy.hideErrorDetails;
|
|
198
|
+
}
|
|
199
|
+
get isFinished() {
|
|
200
|
+
return this.finished;
|
|
201
|
+
}
|
|
202
|
+
/** Open the turn span. Called on the first `next()`; idempotent. */
|
|
203
|
+
start() {
|
|
204
|
+
if (this.turn || this.finished)
|
|
205
|
+
return;
|
|
206
|
+
try {
|
|
207
|
+
const span = this.tracer.startSpan(SpanNames.TURN, { kind: SpanKind.INTERNAL, startTime: toHrTime(this.startTimeMs) }, this.parentContext);
|
|
208
|
+
this.turn = span;
|
|
209
|
+
this.turnContext = trace.setSpan(this.parentContext, span);
|
|
210
|
+
span.setAttributes(this.contextAttributes);
|
|
211
|
+
span.setAttributes(this.turnStartAttributes());
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
diag.debug(`${LOG}: failed to start turn span: ${error}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/** Record one SDK event. Never throws and never mutates the event. */
|
|
218
|
+
onEvent(event) {
|
|
219
|
+
if (this.finished || !this.turn || !event || typeof event !== "object")
|
|
220
|
+
return;
|
|
221
|
+
try {
|
|
222
|
+
const nowMs = clockMs();
|
|
223
|
+
const e = event;
|
|
224
|
+
switch (e.type) {
|
|
225
|
+
case "thread.started":
|
|
226
|
+
if (typeof e.thread_id === "string" && e.thread_id)
|
|
227
|
+
this.setThreadId(e.thread_id);
|
|
228
|
+
break;
|
|
229
|
+
case "turn.completed":
|
|
230
|
+
this.completed = true;
|
|
231
|
+
this.setUsage(e.usage);
|
|
232
|
+
break;
|
|
233
|
+
case "turn.failed":
|
|
234
|
+
this.failedMessage = typeof e.error?.message === "string" ? e.error.message : "turn failed";
|
|
235
|
+
break;
|
|
236
|
+
case "error":
|
|
237
|
+
// Codex also sends `error` for errors it retries ("Reconnecting... 1/5"),
|
|
238
|
+
// and the turn can still complete.
|
|
239
|
+
this.streamErrorCount += 1;
|
|
240
|
+
this.streamErrorMessage = typeof e.message === "string" ? e.message : "stream error";
|
|
241
|
+
this.turn.addEvent("codex.stream_error", this.hideErrorText ? {} : { message: truncate(this.streamErrorMessage, ERROR_MESSAGE_MAX) });
|
|
242
|
+
break;
|
|
243
|
+
case "item.started":
|
|
244
|
+
this.onItemStarted(e.item, nowMs);
|
|
245
|
+
break;
|
|
246
|
+
case "item.updated":
|
|
247
|
+
this.onItemUpdated(e.item);
|
|
248
|
+
break;
|
|
249
|
+
case "item.completed":
|
|
250
|
+
this.onItemCompleted(e.item, nowMs);
|
|
251
|
+
break;
|
|
252
|
+
default:
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
this.lastEventMs = nowMs;
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
diag.debug(`${LOG}: failed to record event: ${error}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* End every open span for this turn, the turn span last. Idempotent.
|
|
263
|
+
* The spans always end, even if recording their attributes throws: item
|
|
264
|
+
* spans that already ended were exported with the turn as their parent.
|
|
265
|
+
*/
|
|
266
|
+
finish(outcome) {
|
|
267
|
+
if (this.finished)
|
|
268
|
+
return;
|
|
269
|
+
this.finished = true;
|
|
270
|
+
const turn = this.turn;
|
|
271
|
+
if (!turn)
|
|
272
|
+
return;
|
|
273
|
+
const endMs = clockMs();
|
|
274
|
+
try {
|
|
275
|
+
this.recordFinish(turn, outcome, endMs);
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
diag.debug(`${LOG}: failed to finish turn: ${error}`);
|
|
279
|
+
}
|
|
280
|
+
finally {
|
|
281
|
+
for (const [id, state] of this.items) {
|
|
282
|
+
this.items.delete(id);
|
|
283
|
+
endSpan(state.span, endMs);
|
|
284
|
+
}
|
|
285
|
+
endSpan(turn, endMs);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/** Statuses and attributes for `finish()`; closes open item spans. */
|
|
289
|
+
recordFinish(turn, outcome, endMs) {
|
|
290
|
+
// A stream error the turn recovered from (turn.completed followed) is not a failure.
|
|
291
|
+
const failedText = this.failedMessage ?? (!this.completed && this.streamErrorMessage !== undefined ? this.streamErrorMessage : undefined);
|
|
292
|
+
const failedLabel = this.failedMessage !== undefined ? "turn failed" : "stream error";
|
|
293
|
+
const cancelled = (outcome.kind === "returned" && !this.completed && failedText === undefined) ||
|
|
294
|
+
(outcome.kind === "error" && isAbortLike(outcome.error, this.turnOptions?.signal));
|
|
295
|
+
const errored = outcome.kind === "error" && !cancelled;
|
|
296
|
+
if (this.streamErrorCount > 0)
|
|
297
|
+
turn.setAttribute(C.STREAM_ERROR_COUNT, this.streamErrorCount);
|
|
298
|
+
for (const [id, state] of this.items) {
|
|
299
|
+
this.items.delete(id);
|
|
300
|
+
try {
|
|
301
|
+
if (cancelled) {
|
|
302
|
+
state.span.setAttribute(C.CANCELLED, true);
|
|
303
|
+
state.span.setStatus({ code: SpanStatusCode.ERROR, message: "cancelled" });
|
|
304
|
+
}
|
|
305
|
+
else if (errored || failedText !== undefined) {
|
|
306
|
+
state.span.setStatus({ code: SpanStatusCode.ERROR, message: "item not completed (turn failed)" });
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
// todo_list completes with the turn; anything else still open is incomplete, not failed.
|
|
310
|
+
if (state.type !== "todo_list")
|
|
311
|
+
state.span.setAttribute(C.ITEM_INCOMPLETE, true);
|
|
312
|
+
this.applyItemAttributes(state.span, state.type, state.last);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
diag.debug(`${LOG}: failed to record open item: ${error}`);
|
|
317
|
+
}
|
|
318
|
+
finally {
|
|
319
|
+
endSpan(state.span, endMs);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (this.finalResponse !== undefined && !this.policy.hideOutputs) {
|
|
323
|
+
turn.setAttribute(S.OUTPUT_VALUE, truncate(this.finalResponse, CONTENT_MAX));
|
|
324
|
+
turn.setAttribute(S.OUTPUT_MIME_TYPE, MimeType.TEXT);
|
|
325
|
+
}
|
|
326
|
+
if (cancelled) {
|
|
327
|
+
turn.setAttribute(C.CANCELLED, true);
|
|
328
|
+
turn.setStatus({ code: SpanStatusCode.ERROR, message: "cancelled" });
|
|
329
|
+
}
|
|
330
|
+
else if (errored) {
|
|
331
|
+
const error = outcome.error;
|
|
332
|
+
turn.setAttribute(C.ERROR_TYPE, errorName(error));
|
|
333
|
+
if (this.policy.hideErrorDetails) {
|
|
334
|
+
turn.recordException({ name: errorName(error) });
|
|
335
|
+
turn.setStatus({ code: SpanStatusCode.ERROR });
|
|
336
|
+
}
|
|
337
|
+
else if (this.policy.hideOutputs) {
|
|
338
|
+
// Error text is content (stderr, raw lines, paths): a fixed summary, no stack trace.
|
|
339
|
+
const summary = errorSummary(error);
|
|
340
|
+
turn.setAttribute(C.ERROR_MESSAGE, summary);
|
|
341
|
+
turn.recordException({ name: errorName(error), message: summary });
|
|
342
|
+
turn.setStatus({ code: SpanStatusCode.ERROR, message: summary });
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
const message = truncate(errorMessage(error), ERROR_MESSAGE_MAX);
|
|
346
|
+
turn.setAttribute(C.ERROR_MESSAGE, message);
|
|
347
|
+
if (error instanceof Error)
|
|
348
|
+
turn.recordException(error);
|
|
349
|
+
turn.setStatus({ code: SpanStatusCode.ERROR, message });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
else if (failedText !== undefined) {
|
|
353
|
+
// Provider error bodies and Codex messages can carry content: text only with outputs visible.
|
|
354
|
+
const message = this.policy.hideOutputs ? failedLabel : truncate(failedText, ERROR_MESSAGE_MAX);
|
|
355
|
+
if (!this.policy.hideErrorDetails)
|
|
356
|
+
turn.setAttribute(C.ERROR_MESSAGE, message);
|
|
357
|
+
turn.setStatus({ code: SpanStatusCode.ERROR, ...(this.policy.hideErrorDetails ? {} : { message }) });
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
turn.setStatus({ code: SpanStatusCode.OK });
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
turnStartAttributes() {
|
|
364
|
+
const attributes = {
|
|
365
|
+
[S.FI_SPAN_KIND]: "AGENT",
|
|
366
|
+
[S.GEN_AI_SPAN_KIND]: "AGENT",
|
|
367
|
+
[S.AGENT_NAME]: AGENT_NAME,
|
|
368
|
+
[S.PROVIDER]: PROVIDER_NAME,
|
|
369
|
+
[C.THREAD_RESUMED]: this.resumed,
|
|
370
|
+
};
|
|
371
|
+
const options = this.threadOptions ?? {};
|
|
372
|
+
if (typeof options.model === "string" && options.model)
|
|
373
|
+
attributes[S.MODEL_NAME] = options.model;
|
|
374
|
+
if (typeof options.sandboxMode === "string")
|
|
375
|
+
attributes[C.SANDBOX_MODE] = options.sandboxMode;
|
|
376
|
+
if (typeof options.approvalPolicy === "string")
|
|
377
|
+
attributes[C.APPROVAL_POLICY] = options.approvalPolicy;
|
|
378
|
+
if (typeof options.modelReasoningEffort === "string")
|
|
379
|
+
attributes[C.REASONING_EFFORT] = options.modelReasoningEffort;
|
|
380
|
+
if (typeof options.webSearchMode === "string")
|
|
381
|
+
attributes[C.WEB_SEARCH_MODE] = options.webSearchMode;
|
|
382
|
+
const { prompt, images } = normalizeInput(this.input);
|
|
383
|
+
attributes[C.INPUT_LENGTH] = prompt.length;
|
|
384
|
+
if (images > 0)
|
|
385
|
+
attributes[C.INPUT_IMAGE_COUNT] = images;
|
|
386
|
+
if (!this.policy.hideInputs) {
|
|
387
|
+
attributes[S.INPUT_VALUE] = truncate(prompt, CONTENT_MAX);
|
|
388
|
+
attributes[S.INPUT_MIME_TYPE] = MimeType.TEXT;
|
|
389
|
+
}
|
|
390
|
+
if (this.threadId)
|
|
391
|
+
Object.assign(attributes, this.sessionAttributes(this.threadId));
|
|
392
|
+
return attributes;
|
|
393
|
+
}
|
|
394
|
+
sessionAttributes(threadId) {
|
|
395
|
+
const attributes = { [C.THREAD_ID]: threadId, [S.CONVERSATION_ID]: threadId };
|
|
396
|
+
// An app that set session.id through fi-core context keeps it.
|
|
397
|
+
if (!this.appSetSession)
|
|
398
|
+
attributes[S.SESSION_ID] = threadId;
|
|
399
|
+
return attributes;
|
|
400
|
+
}
|
|
401
|
+
setThreadId(threadId) {
|
|
402
|
+
this.threadId = threadId;
|
|
403
|
+
this.turn?.setAttributes(this.sessionAttributes(threadId));
|
|
404
|
+
// A new thread starts at zero, even if this first turn fails or is cancelled:
|
|
405
|
+
// the next turn then promotes its delta (which includes this turn's spend).
|
|
406
|
+
if (!this.resumed && !usageBaselines.get(threadId))
|
|
407
|
+
usageBaselines.set(threadId, {});
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* `turn.completed.usage` is the thread's running total, not the turn's own
|
|
411
|
+
* spend (measured on 0.162.1: each resumed turn reports the previous total
|
|
412
|
+
* plus its own requests, also across processes). The promoted
|
|
413
|
+
* `gen_ai.usage.*` keys carry only this turn's new spend: the total minus
|
|
414
|
+
* the last total this process saw for the thread. A new thread starts at
|
|
415
|
+
* zero. A resumed thread with no total seen in this process promotes nothing
|
|
416
|
+
* (`codex.usage.baseline_unknown`); later turns promote their delta.
|
|
417
|
+
* A failed or cancelled turn reports no usage, so its spend lands on the
|
|
418
|
+
* thread's next completed turn. Running totals are always on
|
|
419
|
+
* `codex.cumulative.*`.
|
|
420
|
+
*/
|
|
421
|
+
setUsage(usage) {
|
|
422
|
+
if (!usage || !this.turn)
|
|
423
|
+
return;
|
|
424
|
+
const cumulative = usageTotals(usage);
|
|
425
|
+
const attributes = {};
|
|
426
|
+
for (const [key, value] of Object.entries(cumulative)) {
|
|
427
|
+
if (value !== undefined)
|
|
428
|
+
attributes[`${C.CUMULATIVE_PREFIX}${key}`] = value;
|
|
429
|
+
}
|
|
430
|
+
let baseline = this.threadId ? usageBaselines.get(this.threadId) : undefined;
|
|
431
|
+
if (!baseline && !this.resumed)
|
|
432
|
+
baseline = {};
|
|
433
|
+
if (!baseline) {
|
|
434
|
+
if (this.threadId)
|
|
435
|
+
usageBaselines.set(this.threadId, cumulative);
|
|
436
|
+
attributes[C.USAGE_BASELINE_UNKNOWN] = true;
|
|
437
|
+
this.turn.setAttributes(attributes);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
let turnUsage = subtractUsage(cumulative, baseline);
|
|
441
|
+
if (turnUsage) {
|
|
442
|
+
if (this.threadId)
|
|
443
|
+
usageBaselines.set(this.threadId, cumulative);
|
|
444
|
+
}
|
|
445
|
+
else {
|
|
446
|
+
// The total went down (a reset within the supported cumulative-usage SDKs): use it as
|
|
447
|
+
// reported, and keep the larger baseline so a later turn is not counted twice.
|
|
448
|
+
turnUsage = cumulative;
|
|
449
|
+
attributes[C.USAGE_NON_MONOTONIC] = true;
|
|
450
|
+
if (this.threadId)
|
|
451
|
+
usageBaselines.set(this.threadId, maxUsage(baseline, cumulative));
|
|
452
|
+
}
|
|
453
|
+
const { input_tokens: input, output_tokens: output } = turnUsage;
|
|
454
|
+
// OpenAI accounting: input_tokens includes cached tokens, output_tokens includes reasoning.
|
|
455
|
+
if (input !== undefined)
|
|
456
|
+
attributes[S.INPUT_TOKENS] = input;
|
|
457
|
+
if (output !== undefined)
|
|
458
|
+
attributes[S.OUTPUT_TOKENS] = output;
|
|
459
|
+
if (input !== undefined && output !== undefined)
|
|
460
|
+
attributes[S.TOTAL_TOKENS] = input + output;
|
|
461
|
+
if (turnUsage.cached_input_tokens !== undefined) {
|
|
462
|
+
attributes[S.CACHE_READ_TOKENS] = turnUsage.cached_input_tokens;
|
|
463
|
+
attributes[C.USAGE_CACHED_INPUT_TOKENS] = turnUsage.cached_input_tokens;
|
|
464
|
+
}
|
|
465
|
+
if (turnUsage.cache_write_input_tokens !== undefined) {
|
|
466
|
+
attributes[S.CACHE_WRITE_TOKENS] = turnUsage.cache_write_input_tokens;
|
|
467
|
+
attributes[C.USAGE_CACHE_WRITE_INPUT_TOKENS] = turnUsage.cache_write_input_tokens;
|
|
468
|
+
}
|
|
469
|
+
if (turnUsage.reasoning_output_tokens !== undefined) {
|
|
470
|
+
attributes[S.REASONING_TOKENS] = turnUsage.reasoning_output_tokens;
|
|
471
|
+
attributes[C.USAGE_REASONING_OUTPUT_TOKENS] = turnUsage.reasoning_output_tokens;
|
|
472
|
+
}
|
|
473
|
+
this.turn.setAttributes(attributes);
|
|
474
|
+
}
|
|
475
|
+
itemKey(item) {
|
|
476
|
+
if (typeof item.id === "string" && item.id)
|
|
477
|
+
return item.id;
|
|
478
|
+
this.anonymousItems += 1;
|
|
479
|
+
return `__anonymous_${this.anonymousItems}`;
|
|
480
|
+
}
|
|
481
|
+
onItemStarted(item, nowMs) {
|
|
482
|
+
if (!item || typeof item !== "object")
|
|
483
|
+
return;
|
|
484
|
+
const key = this.itemKey(item);
|
|
485
|
+
if (this.items.has(key))
|
|
486
|
+
return;
|
|
487
|
+
const state = this.startItemSpan(item, nowMs, true);
|
|
488
|
+
if (state)
|
|
489
|
+
this.items.set(key, state);
|
|
490
|
+
}
|
|
491
|
+
onItemUpdated(item) {
|
|
492
|
+
if (!item || typeof item !== "object" || typeof item.id !== "string")
|
|
493
|
+
return;
|
|
494
|
+
const state = this.items.get(item.id);
|
|
495
|
+
if (state)
|
|
496
|
+
state.last = item;
|
|
497
|
+
}
|
|
498
|
+
onItemCompleted(item, nowMs) {
|
|
499
|
+
if (!item || typeof item !== "object")
|
|
500
|
+
return;
|
|
501
|
+
const key = typeof item.id === "string" && item.id ? item.id : undefined;
|
|
502
|
+
let state = key ? this.items.get(key) : undefined;
|
|
503
|
+
if (key)
|
|
504
|
+
this.items.delete(key);
|
|
505
|
+
if (!state) {
|
|
506
|
+
// Completed-only items (agent_message, reasoning) start at the previous event.
|
|
507
|
+
state = this.startItemSpan(item, this.lastEventMs, false);
|
|
508
|
+
}
|
|
509
|
+
if (!state)
|
|
510
|
+
return;
|
|
511
|
+
state.last = item;
|
|
512
|
+
if (item.type === "agent_message" && typeof item.text === "string")
|
|
513
|
+
this.finalResponse = item.text;
|
|
514
|
+
try {
|
|
515
|
+
this.applyItemAttributes(state.span, state.type, item);
|
|
516
|
+
this.applyItemStatus(state.span, state.type, item);
|
|
517
|
+
}
|
|
518
|
+
catch (error) {
|
|
519
|
+
diag.debug(`${LOG}: failed to record item: ${error}`);
|
|
520
|
+
}
|
|
521
|
+
finally {
|
|
522
|
+
endSpan(state.span, Math.max(nowMs, this.lastEventMs));
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
startItemSpan(item, startMs, startedEvent) {
|
|
526
|
+
const type = typeof item.type === "string" ? item.type : "unknown";
|
|
527
|
+
const spec = ITEM_SPANS[type] ?? { name: `codex.${type}`, ...UNKNOWN_ITEM_SPAN };
|
|
528
|
+
const attributes = {
|
|
529
|
+
[S.FI_SPAN_KIND]: spec.kind,
|
|
530
|
+
[S.GEN_AI_SPAN_KIND]: spec.kind,
|
|
531
|
+
[C.ITEM_TYPE]: type,
|
|
532
|
+
[C.ITEM_STARTED_EVENT]: startedEvent,
|
|
533
|
+
};
|
|
534
|
+
if (typeof item.id === "string") {
|
|
535
|
+
attributes[C.ITEM_ID] = item.id;
|
|
536
|
+
if (spec.tool)
|
|
537
|
+
attributes[S.TOOL_CALL_ID] = item.id;
|
|
538
|
+
}
|
|
539
|
+
if (this.threadId && !this.appSetSession)
|
|
540
|
+
attributes[S.SESSION_ID] = this.threadId;
|
|
541
|
+
if (spec.tool) {
|
|
542
|
+
const toolName = type === "mcp_tool_call" && typeof item.tool === "string" && item.tool ? item.tool : type;
|
|
543
|
+
attributes[S.TOOL_NAME] = toolName;
|
|
544
|
+
attributes[S.GEN_AI_TOOL_NAME] = toolName;
|
|
545
|
+
}
|
|
546
|
+
if (type === "agent_message") {
|
|
547
|
+
attributes[S.PROVIDER] = PROVIDER_NAME;
|
|
548
|
+
const model = this.threadOptions?.model;
|
|
549
|
+
if (typeof model === "string" && model)
|
|
550
|
+
attributes[S.MODEL_NAME] = model;
|
|
551
|
+
}
|
|
552
|
+
const span = this.tracer.startSpan(spec.name, { kind: SpanKind.INTERNAL, startTime: toHrTime(startMs), attributes }, this.turnContext);
|
|
553
|
+
return { span, type, last: item };
|
|
554
|
+
}
|
|
555
|
+
/** Structural fields always; content only when the policy allows it. */
|
|
556
|
+
applyItemAttributes(span, type, item) {
|
|
557
|
+
const attributes = {};
|
|
558
|
+
if (typeof item.status === "string")
|
|
559
|
+
attributes[C.ITEM_STATUS] = item.status;
|
|
560
|
+
const input = (value) => {
|
|
561
|
+
if (!this.policy.hideInputs) {
|
|
562
|
+
attributes[S.INPUT_VALUE] = truncate(value, CONTENT_MAX);
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
const output = (value, mime = MimeType.TEXT) => {
|
|
566
|
+
if (!this.policy.hideOutputs) {
|
|
567
|
+
attributes[S.OUTPUT_VALUE] = truncate(value, CONTENT_MAX);
|
|
568
|
+
attributes[S.OUTPUT_MIME_TYPE] = mime;
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
switch (type) {
|
|
572
|
+
case "agent_message":
|
|
573
|
+
case "reasoning":
|
|
574
|
+
if (typeof item.text === "string") {
|
|
575
|
+
attributes[C.TEXT_LENGTH] = item.text.length;
|
|
576
|
+
output(item.text);
|
|
577
|
+
}
|
|
578
|
+
break;
|
|
579
|
+
case "command_execution":
|
|
580
|
+
if (typeof item.exit_code === "number")
|
|
581
|
+
attributes[C.COMMAND_EXIT_CODE] = item.exit_code;
|
|
582
|
+
if (typeof item.command === "string")
|
|
583
|
+
input(item.command);
|
|
584
|
+
if (typeof item.aggregated_output === "string") {
|
|
585
|
+
attributes[C.COMMAND_OUTPUT_LENGTH] = item.aggregated_output.length;
|
|
586
|
+
if (item.status !== "failed" || !this.policy.hideErrorDetails)
|
|
587
|
+
output(item.aggregated_output);
|
|
588
|
+
}
|
|
589
|
+
break;
|
|
590
|
+
case "file_change": {
|
|
591
|
+
const changes = Array.isArray(item.changes) ? item.changes : [];
|
|
592
|
+
attributes[C.FILE_CHANGE_COUNT] = changes.length;
|
|
593
|
+
const kinds = [...new Set(changes.map((c) => c?.kind).filter((k) => typeof k === "string"))];
|
|
594
|
+
if (kinds.length)
|
|
595
|
+
attributes[C.FILE_CHANGE_KINDS] = kinds.sort().join(",");
|
|
596
|
+
// Paths can name private files: they are input content.
|
|
597
|
+
if (changes.length)
|
|
598
|
+
input(safeJson(changes));
|
|
599
|
+
break;
|
|
600
|
+
}
|
|
601
|
+
case "mcp_tool_call": {
|
|
602
|
+
if (typeof item.server === "string")
|
|
603
|
+
attributes[C.MCP_SERVER] = item.server;
|
|
604
|
+
if (typeof item.tool === "string")
|
|
605
|
+
attributes[C.MCP_TOOL] = item.tool;
|
|
606
|
+
// `result` is `null` on item.started and on a failed call (see ThreadItemLike).
|
|
607
|
+
if (item.arguments != null)
|
|
608
|
+
input(safeJson(item.arguments));
|
|
609
|
+
const result = item.result;
|
|
610
|
+
if (result != null && (item.status !== "failed" || !this.policy.hideErrorDetails)) {
|
|
611
|
+
output(safeJson(result.content ?? result), MimeType.JSON);
|
|
612
|
+
}
|
|
613
|
+
break;
|
|
614
|
+
}
|
|
615
|
+
case "web_search":
|
|
616
|
+
if (typeof item.query === "string")
|
|
617
|
+
input(item.query);
|
|
618
|
+
break;
|
|
619
|
+
case "todo_list": {
|
|
620
|
+
const todos = Array.isArray(item.items) ? item.items : [];
|
|
621
|
+
attributes[C.TODO_COUNT] = todos.length;
|
|
622
|
+
attributes[C.TODO_COMPLETED] = todos.filter((t) => t?.completed === true).length;
|
|
623
|
+
if (todos.length)
|
|
624
|
+
output(safeJson(todos), MimeType.JSON);
|
|
625
|
+
break;
|
|
626
|
+
}
|
|
627
|
+
case "error":
|
|
628
|
+
// A non-fatal error item: Codex sends warnings, deprecation notices and model
|
|
629
|
+
// reroutes this way. Its text can quote config lines or paths: outputs only.
|
|
630
|
+
attributes[C.WARNING] = true;
|
|
631
|
+
if (typeof item.message === "string") {
|
|
632
|
+
attributes[C.TEXT_LENGTH] = item.message.length;
|
|
633
|
+
if (!this.hideErrorText)
|
|
634
|
+
attributes[C.ERROR_MESSAGE] = truncate(item.message, ERROR_MESSAGE_MAX);
|
|
635
|
+
}
|
|
636
|
+
break;
|
|
637
|
+
default:
|
|
638
|
+
break;
|
|
639
|
+
}
|
|
640
|
+
span.setAttributes(attributes);
|
|
641
|
+
}
|
|
642
|
+
applyItemStatus(span, type, item) {
|
|
643
|
+
if (type === "error") {
|
|
644
|
+
// Non-fatal (`codex.warning=true`): status stays UNSET. Turn failures are on the turn span.
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
if (item.status === "failed") {
|
|
648
|
+
let message = `${type} failed`;
|
|
649
|
+
if (type === "command_execution" && typeof item.exit_code === "number") {
|
|
650
|
+
message = `command failed (exit ${item.exit_code})`;
|
|
651
|
+
}
|
|
652
|
+
else if (type === "mcp_tool_call" && typeof item.error?.message === "string" && !this.hideErrorText) {
|
|
653
|
+
// A tool's error text can carry tool output: only with outputs visible.
|
|
654
|
+
message = truncate(item.error.message, ERROR_MESSAGE_MAX);
|
|
655
|
+
}
|
|
656
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message });
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
function endSpan(span, endMs) {
|
|
663
|
+
try {
|
|
664
|
+
span.end(toHrTime(endMs));
|
|
665
|
+
}
|
|
666
|
+
catch (error) {
|
|
667
|
+
diag.debug(`${LOG}: failed to end span: ${error}`);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
function safeContextAttributes(ctx) {
|
|
671
|
+
try {
|
|
672
|
+
return getAttributesFromContext(ctx);
|
|
673
|
+
}
|
|
674
|
+
catch {
|
|
675
|
+
return {};
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
//# sourceMappingURL=spans.js.map
|