@ai-sdk/harness 0.0.0 → 1.0.0-beta.14
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/CHANGELOG.md +110 -0
- package/LICENSE +13 -0
- package/README.md +142 -0
- package/agent/index.ts +47 -0
- package/bridge/index.ts +10 -0
- package/dist/agent/index.d.ts +1521 -0
- package/dist/agent/index.js +2958 -0
- package/dist/agent/index.js.map +1 -0
- package/dist/bridge/index.d.ts +111 -0
- package/dist/bridge/index.js +415 -0
- package/dist/bridge/index.js.map +1 -0
- package/dist/index.d.ts +1536 -0
- package/dist/index.js +15834 -0
- package/dist/index.js.map +1 -0
- package/dist/utils/index.d.ts +225 -0
- package/dist/utils/index.js +12148 -0
- package/dist/utils/index.js.map +1 -0
- package/package.json +99 -1
- package/src/agent/harness-agent-session.ts +509 -0
- package/src/agent/harness-agent-settings.ts +131 -0
- package/src/agent/harness-agent-tool-approval-continuation.ts +94 -0
- package/src/agent/harness-agent-types.ts +50 -0
- package/src/agent/harness-agent.ts +819 -0
- package/src/agent/internal/bootstrap-recipe.ts +124 -0
- package/src/agent/internal/bridge-port-registry.ts +52 -0
- package/src/agent/internal/harness-stream-text-result.ts +720 -0
- package/src/agent/internal/lifecycle-state-validation.ts +95 -0
- package/src/agent/internal/permission-mode.ts +50 -0
- package/src/agent/internal/resolve-observability.ts +128 -0
- package/src/agent/internal/run-prompt.ts +813 -0
- package/src/agent/internal/strip-work-dir.ts +68 -0
- package/src/agent/internal/to-harness-stream.ts +75 -0
- package/src/agent/internal/translate-stream-part.ts +221 -0
- package/src/agent/internal/turn-telemetry.ts +359 -0
- package/src/agent/observability/file-reporter.ts +206 -0
- package/src/agent/observability/index.ts +15 -0
- package/src/agent/observability/trace-tree-reporter.ts +122 -0
- package/src/agent/observability/types.ts +86 -0
- package/src/agent/prewarm.ts +47 -0
- package/src/bridge/index.ts +702 -0
- package/src/errors/harness-capability-unsupported-error.ts +41 -0
- package/src/errors/harness-error.ts +22 -0
- package/src/index.ts +3 -0
- package/src/utils/bridge-ready.ts +277 -0
- package/src/utils/classify-disk-log.ts +43 -0
- package/src/utils/index.ts +15 -0
- package/src/utils/sandbox-channel.ts +453 -0
- package/src/v1/harness-v1-bootstrap.ts +46 -0
- package/src/v1/harness-v1-bridge-protocol.ts +310 -0
- package/src/v1/harness-v1-builtin-tool.ts +138 -0
- package/src/v1/harness-v1-call-warning.ts +22 -0
- package/src/v1/harness-v1-diagnostic.ts +66 -0
- package/src/v1/harness-v1-lifecycle-state.ts +65 -0
- package/src/v1/harness-v1-metadata.ts +13 -0
- package/src/v1/harness-v1-network-sandbox-session.ts +123 -0
- package/src/v1/harness-v1-observability.ts +20 -0
- package/src/v1/harness-v1-permission-mode.ts +11 -0
- package/src/v1/harness-v1-prompt-control.ts +41 -0
- package/src/v1/harness-v1-prompt.ts +11 -0
- package/src/v1/harness-v1-sandbox-provider.ts +76 -0
- package/src/v1/harness-v1-session.ts +272 -0
- package/src/v1/harness-v1-skill.ts +36 -0
- package/src/v1/harness-v1-stream-part.ts +363 -0
- package/src/v1/harness-v1-tool-spec.ts +31 -0
- package/src/v1/harness-v1.ts +83 -0
- package/src/v1/index.ts +93 -0
- package/utils/index.ts +1 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { generateId, type ModelMessage } from '@ai-sdk/provider-utils';
|
|
2
|
+
import { createTelemetryDispatcher } from 'ai/internal';
|
|
3
|
+
import type { TelemetryOptions } from 'ai';
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* Drives AI SDK's pluggable `Telemetry` lifecycle from a harness turn.
|
|
7
|
+
*
|
|
8
|
+
* A harness turn is not a `streamText` call — it has no language model, prompt
|
|
9
|
+
* standardization, or sampling settings — but the AI SDK telemetry contract is
|
|
10
|
+
* shaped around `generateText`/`streamText` events, and `@ai-sdk/otel` (the
|
|
11
|
+
* main integration) only produces spans when the full lifecycle fires. So we
|
|
12
|
+
* map the turn onto that contract: turn = operation, each `finish-step` = a
|
|
13
|
+
* step boundary, tool-calls = tool executions, `finish` = operation end. The
|
|
14
|
+
* model-call-only event fields the harness has no value for (sampling params,
|
|
15
|
+
* standardized prompt) are left `undefined` / cast; the fields the integrations
|
|
16
|
+
* actually read (`callId`, `operationId`, `provider`, `modelId`, `messages`,
|
|
17
|
+
* `toolCall`, `usage`, `finishReason`) carry real values.
|
|
18
|
+
*
|
|
19
|
+
* Telemetry is opt-in: the framework only drives it when `settings.telemetry`
|
|
20
|
+
* is set (the dispatcher then also honours globally-registered integrations).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
type Dispatcher = ReturnType<typeof createTelemetryDispatcher>;
|
|
24
|
+
type EventArg<K extends keyof Dispatcher> = Dispatcher[K] extends
|
|
25
|
+
| ((event: infer E) => unknown)
|
|
26
|
+
| undefined
|
|
27
|
+
? E
|
|
28
|
+
: never;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* An output content part accumulated over a step — the model's assistant turn.
|
|
32
|
+
* Shaped for the gen_ai output-message conventions `@ai-sdk/otel` reads.
|
|
33
|
+
*/
|
|
34
|
+
export type TurnContentPart =
|
|
35
|
+
| { type: 'text'; text: string }
|
|
36
|
+
| { type: 'reasoning'; text: string }
|
|
37
|
+
| { type: 'tool-call'; toolCallId: string; toolName: string; input: unknown };
|
|
38
|
+
|
|
39
|
+
export interface TurnTelemetry {
|
|
40
|
+
/**
|
|
41
|
+
* Begin the operation span. Called on `stream-start`, optionally with the
|
|
42
|
+
* model the runtime resolved to (overriding the session's configured id).
|
|
43
|
+
* Idempotent — the first call wins.
|
|
44
|
+
*/
|
|
45
|
+
start(modelId?: string): void;
|
|
46
|
+
/** Open a step span lazily, before the first content of a step. */
|
|
47
|
+
ensureStepOpen(): void;
|
|
48
|
+
/** Close the current step (on a harness `finish-step`). */
|
|
49
|
+
stepFinish(info: {
|
|
50
|
+
finishReason: unknown;
|
|
51
|
+
usage: unknown;
|
|
52
|
+
providerMetadata?: unknown;
|
|
53
|
+
/** The model's output content for this step (text/reasoning/tool-calls). */
|
|
54
|
+
content?: TurnContentPart[];
|
|
55
|
+
}): void;
|
|
56
|
+
/** A tool execution began (on a `tool-call`). */
|
|
57
|
+
toolStart(call: {
|
|
58
|
+
toolCallId: string;
|
|
59
|
+
toolName: string;
|
|
60
|
+
input: unknown;
|
|
61
|
+
}): void;
|
|
62
|
+
/**
|
|
63
|
+
* A tool execution completed (on its `tool-result` or after host execution).
|
|
64
|
+
* Idempotent per `toolCallId` — the first caller wins, so provider-executed
|
|
65
|
+
* and host-executed paths can both call it without double-counting.
|
|
66
|
+
*/
|
|
67
|
+
toolEnd(
|
|
68
|
+
toolCallId: string,
|
|
69
|
+
output: { ok: true; output: unknown } | { ok: false; error: unknown },
|
|
70
|
+
): void;
|
|
71
|
+
/** The turn ended (on a harness `finish`). */
|
|
72
|
+
end(info: { finishReason: unknown; usage: unknown }): void;
|
|
73
|
+
/** The turn failed. */
|
|
74
|
+
error(err: unknown): void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const NOOP: TurnTelemetry = {
|
|
78
|
+
start() {},
|
|
79
|
+
ensureStepOpen() {},
|
|
80
|
+
stepFinish() {},
|
|
81
|
+
toolStart() {},
|
|
82
|
+
toolEnd() {},
|
|
83
|
+
end() {},
|
|
84
|
+
error() {},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export function createTurnTelemetry(opts: {
|
|
88
|
+
telemetry: TelemetryOptions | undefined;
|
|
89
|
+
harnessId: string;
|
|
90
|
+
modelId: string | undefined;
|
|
91
|
+
instructions: string | undefined;
|
|
92
|
+
promptText: string;
|
|
93
|
+
runtimeContext: unknown;
|
|
94
|
+
}): TurnTelemetry {
|
|
95
|
+
// Opt-in: with no telemetry settings we do no work and construct no events.
|
|
96
|
+
if (opts.telemetry == null) return NOOP;
|
|
97
|
+
|
|
98
|
+
const dispatcher = createTelemetryDispatcher({ telemetry: opts.telemetry });
|
|
99
|
+
|
|
100
|
+
const callId = generateId();
|
|
101
|
+
const provider = opts.harnessId;
|
|
102
|
+
// The configured session model; `start(modelId)` may override it with the
|
|
103
|
+
// model the runtime actually resolved to.
|
|
104
|
+
let modelId = opts.modelId ?? '';
|
|
105
|
+
const runtimeContext = opts.runtimeContext;
|
|
106
|
+
const inputMessages: ModelMessage[] = [
|
|
107
|
+
{ role: 'user', content: opts.promptText },
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
let started = false;
|
|
111
|
+
let stepOpen = false;
|
|
112
|
+
let stepNumber = 0;
|
|
113
|
+
let ended = false;
|
|
114
|
+
/** Tool calls started in the current turn and not yet ended. */
|
|
115
|
+
const openTools = new Map<
|
|
116
|
+
string,
|
|
117
|
+
{ toolCallId: string; toolName: string; input: unknown }
|
|
118
|
+
>();
|
|
119
|
+
|
|
120
|
+
const cast = <K extends keyof Dispatcher>(event: unknown): EventArg<K> =>
|
|
121
|
+
event as EventArg<K>;
|
|
122
|
+
|
|
123
|
+
// onStart — open the operation (root) span. Deferred until `start()` so the
|
|
124
|
+
// runtime-resolved model can be attached to the operation span + trace label.
|
|
125
|
+
const fireStart = (): void => {
|
|
126
|
+
if (started) return;
|
|
127
|
+
started = true;
|
|
128
|
+
dispatcher.onStart?.(
|
|
129
|
+
cast<'onStart'>({
|
|
130
|
+
callId,
|
|
131
|
+
operationId: 'ai.harness',
|
|
132
|
+
provider,
|
|
133
|
+
modelId,
|
|
134
|
+
tools: undefined,
|
|
135
|
+
toolChoice: undefined,
|
|
136
|
+
activeTools: undefined,
|
|
137
|
+
maxRetries: 0,
|
|
138
|
+
timeout: undefined,
|
|
139
|
+
headers: undefined,
|
|
140
|
+
providerOptions: undefined,
|
|
141
|
+
output: undefined,
|
|
142
|
+
toolsContext: undefined,
|
|
143
|
+
runtimeContext,
|
|
144
|
+
instructions: opts.instructions,
|
|
145
|
+
messages: inputMessages,
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const start = (overrideModelId?: string): void => {
|
|
151
|
+
if (started) return;
|
|
152
|
+
if (overrideModelId) modelId = overrideModelId;
|
|
153
|
+
fireStart();
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const ensureStepOpen = (): void => {
|
|
157
|
+
if (!started) fireStart();
|
|
158
|
+
if (stepOpen || ended) return;
|
|
159
|
+
stepOpen = true;
|
|
160
|
+
dispatcher.onStepStart?.(
|
|
161
|
+
cast<'onStepStart'>({
|
|
162
|
+
callId,
|
|
163
|
+
provider,
|
|
164
|
+
modelId,
|
|
165
|
+
stepNumber,
|
|
166
|
+
tools: undefined,
|
|
167
|
+
toolChoice: undefined,
|
|
168
|
+
activeTools: undefined,
|
|
169
|
+
steps: new Array(stepNumber),
|
|
170
|
+
providerOptions: undefined,
|
|
171
|
+
output: undefined,
|
|
172
|
+
runtimeContext,
|
|
173
|
+
messages: inputMessages,
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
176
|
+
// Open the inference (language-model call) span — the gen_ai home for the
|
|
177
|
+
// step's input and (on end) output messages.
|
|
178
|
+
dispatcher.onLanguageModelCallStart?.(
|
|
179
|
+
cast<'onLanguageModelCallStart'>({
|
|
180
|
+
callId,
|
|
181
|
+
provider,
|
|
182
|
+
modelId,
|
|
183
|
+
messages: inputMessages,
|
|
184
|
+
tools: undefined,
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/** Close the inference span with the step's output content. */
|
|
190
|
+
const inferenceEnd = (info: {
|
|
191
|
+
finishReason: unknown;
|
|
192
|
+
usage: unknown;
|
|
193
|
+
content: TurnContentPart[];
|
|
194
|
+
}): void => {
|
|
195
|
+
dispatcher.onLanguageModelCallEnd?.(
|
|
196
|
+
cast<'onLanguageModelCallEnd'>({
|
|
197
|
+
callId,
|
|
198
|
+
finishReason: info.finishReason,
|
|
199
|
+
responseId: callId,
|
|
200
|
+
usage: info.usage,
|
|
201
|
+
content: info.content,
|
|
202
|
+
}),
|
|
203
|
+
);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const closeOpenTools = (): void => {
|
|
207
|
+
for (const call of openTools.values()) {
|
|
208
|
+
dispatcher.onToolExecutionEnd?.(
|
|
209
|
+
cast<'onToolExecutionEnd'>({
|
|
210
|
+
callId,
|
|
211
|
+
toolExecutionMs: 0,
|
|
212
|
+
messages: [],
|
|
213
|
+
toolCall: {
|
|
214
|
+
type: 'tool-call',
|
|
215
|
+
toolCallId: call.toolCallId,
|
|
216
|
+
toolName: call.toolName,
|
|
217
|
+
input: call.input,
|
|
218
|
+
dynamic: true,
|
|
219
|
+
},
|
|
220
|
+
toolContext: undefined,
|
|
221
|
+
toolOutput: { type: 'error', error: new Error('tool span unclosed') },
|
|
222
|
+
}),
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
openTools.clear();
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
start,
|
|
230
|
+
ensureStepOpen,
|
|
231
|
+
|
|
232
|
+
stepFinish(info) {
|
|
233
|
+
if (!stepOpen) return;
|
|
234
|
+
const content = info.content ?? [];
|
|
235
|
+
closeOpenTools();
|
|
236
|
+
inferenceEnd({
|
|
237
|
+
finishReason: info.finishReason,
|
|
238
|
+
usage: info.usage,
|
|
239
|
+
content,
|
|
240
|
+
});
|
|
241
|
+
dispatcher.onStepEnd?.(
|
|
242
|
+
cast<'onStepEnd'>({
|
|
243
|
+
callId,
|
|
244
|
+
finishReason: info.finishReason,
|
|
245
|
+
usage: info.usage,
|
|
246
|
+
providerMetadata: info.providerMetadata,
|
|
247
|
+
content,
|
|
248
|
+
response: {
|
|
249
|
+
id: callId,
|
|
250
|
+
modelId,
|
|
251
|
+
timestamp: new Date(0),
|
|
252
|
+
messages: [],
|
|
253
|
+
},
|
|
254
|
+
}),
|
|
255
|
+
);
|
|
256
|
+
stepOpen = false;
|
|
257
|
+
stepNumber += 1;
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
toolStart(call) {
|
|
261
|
+
ensureStepOpen();
|
|
262
|
+
openTools.set(call.toolCallId, call);
|
|
263
|
+
dispatcher.onToolExecutionStart?.(
|
|
264
|
+
cast<'onToolExecutionStart'>({
|
|
265
|
+
callId,
|
|
266
|
+
messages: [],
|
|
267
|
+
toolCall: {
|
|
268
|
+
type: 'tool-call',
|
|
269
|
+
toolCallId: call.toolCallId,
|
|
270
|
+
toolName: call.toolName,
|
|
271
|
+
input: call.input,
|
|
272
|
+
dynamic: true,
|
|
273
|
+
},
|
|
274
|
+
toolContext: undefined,
|
|
275
|
+
}),
|
|
276
|
+
);
|
|
277
|
+
},
|
|
278
|
+
|
|
279
|
+
toolEnd(toolCallId, output) {
|
|
280
|
+
const call = openTools.get(toolCallId);
|
|
281
|
+
if (call == null) return;
|
|
282
|
+
openTools.delete(toolCallId);
|
|
283
|
+
dispatcher.onToolExecutionEnd?.(
|
|
284
|
+
cast<'onToolExecutionEnd'>({
|
|
285
|
+
callId,
|
|
286
|
+
toolExecutionMs: 0,
|
|
287
|
+
messages: [],
|
|
288
|
+
toolCall: {
|
|
289
|
+
type: 'tool-call',
|
|
290
|
+
toolCallId: call.toolCallId,
|
|
291
|
+
toolName: call.toolName,
|
|
292
|
+
input: call.input,
|
|
293
|
+
dynamic: true,
|
|
294
|
+
},
|
|
295
|
+
toolContext: undefined,
|
|
296
|
+
toolOutput: output.ok
|
|
297
|
+
? { type: 'tool-result', output: output.output }
|
|
298
|
+
: { type: 'error', error: output.error },
|
|
299
|
+
}),
|
|
300
|
+
);
|
|
301
|
+
},
|
|
302
|
+
|
|
303
|
+
end(info) {
|
|
304
|
+
if (ended) return;
|
|
305
|
+
if (!started) fireStart();
|
|
306
|
+
if (stepOpen) {
|
|
307
|
+
closeOpenTools();
|
|
308
|
+
inferenceEnd({
|
|
309
|
+
finishReason: info.finishReason,
|
|
310
|
+
usage: info.usage,
|
|
311
|
+
content: [],
|
|
312
|
+
});
|
|
313
|
+
dispatcher.onStepEnd?.(
|
|
314
|
+
cast<'onStepEnd'>({
|
|
315
|
+
callId,
|
|
316
|
+
finishReason: info.finishReason,
|
|
317
|
+
usage: info.usage,
|
|
318
|
+
providerMetadata: undefined,
|
|
319
|
+
content: [],
|
|
320
|
+
response: {
|
|
321
|
+
id: callId,
|
|
322
|
+
modelId,
|
|
323
|
+
timestamp: new Date(0),
|
|
324
|
+
messages: [],
|
|
325
|
+
},
|
|
326
|
+
}),
|
|
327
|
+
);
|
|
328
|
+
stepOpen = false;
|
|
329
|
+
}
|
|
330
|
+
ended = true;
|
|
331
|
+
dispatcher.onEnd?.(
|
|
332
|
+
cast<'onEnd'>({
|
|
333
|
+
callId,
|
|
334
|
+
operationId: 'ai.harness',
|
|
335
|
+
finishReason: info.finishReason,
|
|
336
|
+
usage: info.usage,
|
|
337
|
+
totalUsage: info.usage,
|
|
338
|
+
content: [],
|
|
339
|
+
steps: new Array(stepNumber),
|
|
340
|
+
response: {
|
|
341
|
+
id: callId,
|
|
342
|
+
modelId,
|
|
343
|
+
timestamp: new Date(0),
|
|
344
|
+
messages: [],
|
|
345
|
+
},
|
|
346
|
+
runtimeContext,
|
|
347
|
+
}),
|
|
348
|
+
);
|
|
349
|
+
},
|
|
350
|
+
|
|
351
|
+
error(err) {
|
|
352
|
+
if (ended) return;
|
|
353
|
+
if (!started) fireStart();
|
|
354
|
+
closeOpenTools();
|
|
355
|
+
ended = true;
|
|
356
|
+
dispatcher.onError?.(err);
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import type { Telemetry } from 'ai';
|
|
3
|
+
import type { HarnessDiagnostic, HarnessDiagnosticConsumer } from './types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A harness observability reporter that writes a unified, non-lossy
|
|
7
|
+
* `events.jsonl` containing **both** the telemetry span lifecycle (turn / step
|
|
8
|
+
* / tool) **and** the forwarded bridge diagnostics (console lines + structured
|
|
9
|
+
* events). It is a single object registered in `telemetry.integrations`: the
|
|
10
|
+
* framework drives its `Telemetry` methods for spans and calls
|
|
11
|
+
* `ingestDiagnostic` for diagnostics.
|
|
12
|
+
*
|
|
13
|
+
* No external collector or OTel setup required — this is the AI-SDK-idiomatic
|
|
14
|
+
* replacement for the original SDK's host-side artifact files.
|
|
15
|
+
*/
|
|
16
|
+
export interface FileReporterOptions {
|
|
17
|
+
/** Directory for `events.jsonl` (created if absent). */
|
|
18
|
+
dir: string;
|
|
19
|
+
/**
|
|
20
|
+
* Buffer a turn's records in memory and write them only if the turn produced
|
|
21
|
+
* an error (an `error`-level diagnostic, a failed tool, or an error finish).
|
|
22
|
+
* Default false (write everything).
|
|
23
|
+
*/
|
|
24
|
+
failOnly?: boolean;
|
|
25
|
+
/** File name within `dir`. Default `events.jsonl`. */
|
|
26
|
+
fileName?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type Record_ = { ts: number } & Record<string, unknown>;
|
|
30
|
+
|
|
31
|
+
export type FileReporter = Telemetry & HarnessDiagnosticConsumer;
|
|
32
|
+
|
|
33
|
+
export function createFileReporter(options: FileReporterOptions): FileReporter {
|
|
34
|
+
const fileName = options.fileName ?? 'events.jsonl';
|
|
35
|
+
const path = `${options.dir}/${fileName}`;
|
|
36
|
+
const failOnly = options.failOnly ?? false;
|
|
37
|
+
|
|
38
|
+
// Per-turn buffers, keyed by the telemetry callId. Diagnostics (which carry a
|
|
39
|
+
// sessionId, not a callId) attach to the most recently started, open turn.
|
|
40
|
+
const turns = new Map<string, { lines: Record_[]; errored: boolean }>();
|
|
41
|
+
let lastOpenCallId: string | undefined;
|
|
42
|
+
let dirReady = false;
|
|
43
|
+
|
|
44
|
+
const bucketFor = (callId: string) => {
|
|
45
|
+
let bucket = turns.get(callId);
|
|
46
|
+
if (!bucket) {
|
|
47
|
+
bucket = { lines: [], errored: false };
|
|
48
|
+
turns.set(callId, bucket);
|
|
49
|
+
}
|
|
50
|
+
return bucket;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const record = (callId: string | undefined, rec: Record_): void => {
|
|
54
|
+
const id = callId ?? lastOpenCallId;
|
|
55
|
+
if (id == null) {
|
|
56
|
+
// No active turn — write standalone (best-effort).
|
|
57
|
+
flushLines([rec]);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
bucketFor(id).lines.push(rec);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const flushLines = (lines: Record_[]): void => {
|
|
64
|
+
if (lines.length === 0) return;
|
|
65
|
+
try {
|
|
66
|
+
if (!dirReady) {
|
|
67
|
+
mkdirSync(options.dir, { recursive: true });
|
|
68
|
+
dirReady = true;
|
|
69
|
+
}
|
|
70
|
+
appendFileSync(path, lines.map(l => JSON.stringify(l)).join('\n') + '\n');
|
|
71
|
+
} catch {
|
|
72
|
+
// Best-effort: never let observability break a turn.
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const finishTurn = (callId: string): void => {
|
|
77
|
+
const bucket = turns.get(callId);
|
|
78
|
+
if (!bucket) return;
|
|
79
|
+
turns.delete(callId);
|
|
80
|
+
if (lastOpenCallId === callId) lastOpenCallId = undefined;
|
|
81
|
+
if (failOnly && !bucket.errored) return;
|
|
82
|
+
flushLines(bucket.lines);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
onStart(event) {
|
|
87
|
+
const e = event as {
|
|
88
|
+
callId: string;
|
|
89
|
+
operationId?: string;
|
|
90
|
+
modelId?: string;
|
|
91
|
+
provider?: string;
|
|
92
|
+
messages?: unknown;
|
|
93
|
+
instructions?: unknown;
|
|
94
|
+
recordInputs?: boolean;
|
|
95
|
+
};
|
|
96
|
+
lastOpenCallId = e.callId;
|
|
97
|
+
record(e.callId, {
|
|
98
|
+
ts: Date.now(),
|
|
99
|
+
kind: 'turn-start',
|
|
100
|
+
callId: e.callId,
|
|
101
|
+
operationId: e.operationId,
|
|
102
|
+
provider: e.provider,
|
|
103
|
+
modelId: e.modelId,
|
|
104
|
+
// Input prompt, unless the consumer opted out via `recordInputs: false`.
|
|
105
|
+
...(e.recordInputs === false
|
|
106
|
+
? {}
|
|
107
|
+
: { input: { messages: e.messages, instructions: e.instructions } }),
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
onStepStart(event) {
|
|
111
|
+
const e = event as { callId: string; stepNumber?: number };
|
|
112
|
+
record(e.callId, {
|
|
113
|
+
ts: Date.now(),
|
|
114
|
+
kind: 'step-start',
|
|
115
|
+
callId: e.callId,
|
|
116
|
+
step: e.stepNumber,
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
onToolExecutionStart(event) {
|
|
120
|
+
const e = event as {
|
|
121
|
+
callId: string;
|
|
122
|
+
toolCall: { toolName: string; toolCallId: string; input: unknown };
|
|
123
|
+
};
|
|
124
|
+
record(e.callId, {
|
|
125
|
+
ts: Date.now(),
|
|
126
|
+
kind: 'tool-start',
|
|
127
|
+
callId: e.callId,
|
|
128
|
+
toolName: e.toolCall.toolName,
|
|
129
|
+
toolCallId: e.toolCall.toolCallId,
|
|
130
|
+
input: e.toolCall.input,
|
|
131
|
+
});
|
|
132
|
+
},
|
|
133
|
+
onToolExecutionEnd(event) {
|
|
134
|
+
const e = event as {
|
|
135
|
+
callId: string;
|
|
136
|
+
toolCall: { toolCallId: string };
|
|
137
|
+
toolOutput: { type: string; output?: unknown; error?: unknown };
|
|
138
|
+
};
|
|
139
|
+
const isError = e.toolOutput.type === 'error';
|
|
140
|
+
if (isError) bucketFor(e.callId).errored = true;
|
|
141
|
+
record(e.callId, {
|
|
142
|
+
ts: Date.now(),
|
|
143
|
+
kind: 'tool-end',
|
|
144
|
+
callId: e.callId,
|
|
145
|
+
toolCallId: e.toolCall.toolCallId,
|
|
146
|
+
isError,
|
|
147
|
+
output: isError ? e.toolOutput.error : e.toolOutput.output,
|
|
148
|
+
});
|
|
149
|
+
},
|
|
150
|
+
onStepFinish(event) {
|
|
151
|
+
const e = event as {
|
|
152
|
+
callId: string;
|
|
153
|
+
usage?: unknown;
|
|
154
|
+
content?: unknown[];
|
|
155
|
+
recordOutputs?: boolean;
|
|
156
|
+
};
|
|
157
|
+
record(e.callId, {
|
|
158
|
+
ts: Date.now(),
|
|
159
|
+
kind: 'step-finish',
|
|
160
|
+
callId: e.callId,
|
|
161
|
+
usage: e.usage,
|
|
162
|
+
// The model's output content, unless `recordOutputs: false`.
|
|
163
|
+
...(e.recordOutputs === false || e.content == null
|
|
164
|
+
? {}
|
|
165
|
+
: { output: e.content }),
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
onEnd(event) {
|
|
169
|
+
const e = event as {
|
|
170
|
+
callId: string;
|
|
171
|
+
finishReason?: unknown;
|
|
172
|
+
usage?: unknown;
|
|
173
|
+
totalUsage?: unknown;
|
|
174
|
+
};
|
|
175
|
+
record(e.callId, {
|
|
176
|
+
ts: Date.now(),
|
|
177
|
+
kind: 'turn-finish',
|
|
178
|
+
callId: e.callId,
|
|
179
|
+
finishReason: e.finishReason,
|
|
180
|
+
usage: e.totalUsage ?? e.usage,
|
|
181
|
+
});
|
|
182
|
+
finishTurn(e.callId);
|
|
183
|
+
},
|
|
184
|
+
onError(error) {
|
|
185
|
+
if (lastOpenCallId != null) bucketFor(lastOpenCallId).errored = true;
|
|
186
|
+
record(lastOpenCallId, {
|
|
187
|
+
ts: Date.now(),
|
|
188
|
+
kind: 'error',
|
|
189
|
+
error:
|
|
190
|
+
error instanceof Error
|
|
191
|
+
? { name: error.name, message: error.message }
|
|
192
|
+
: error,
|
|
193
|
+
});
|
|
194
|
+
},
|
|
195
|
+
ingestDiagnostic(diagnostic: HarnessDiagnostic) {
|
|
196
|
+
if (diagnostic.level === 'error' && lastOpenCallId != null) {
|
|
197
|
+
bucketFor(lastOpenCallId).errored = true;
|
|
198
|
+
}
|
|
199
|
+
record(lastOpenCallId, {
|
|
200
|
+
ts: diagnostic.timestamp,
|
|
201
|
+
kind: 'diagnostic',
|
|
202
|
+
diagnostic,
|
|
203
|
+
});
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createFileReporter,
|
|
3
|
+
type FileReporter,
|
|
4
|
+
type FileReporterOptions,
|
|
5
|
+
} from './file-reporter';
|
|
6
|
+
export {
|
|
7
|
+
createTraceTreeReporter,
|
|
8
|
+
type TraceTreeReporterOptions,
|
|
9
|
+
} from './trace-tree-reporter';
|
|
10
|
+
export type {
|
|
11
|
+
HarnessDebugConfig,
|
|
12
|
+
HarnessDebugLevel,
|
|
13
|
+
HarnessDiagnostic,
|
|
14
|
+
HarnessDiagnosticConsumer,
|
|
15
|
+
} from './types';
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { Telemetry } from 'ai';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A harness observability reporter that renders an ASCII trace tree of a
|
|
5
|
+
* turn's span lifecycle (turn → steps → tools) to a stream at turn end. It is a
|
|
6
|
+
* `Telemetry` integration — register it in `telemetry.integrations`. Useful for
|
|
7
|
+
* zero-setup local debugging when no OTel collector is wired up; a real OTel
|
|
8
|
+
* backend (via `@ai-sdk/otel`) is a strict superset.
|
|
9
|
+
*/
|
|
10
|
+
export interface TraceTreeReporterOptions {
|
|
11
|
+
/** Where to write the rendered tree. Default `process.stderr.write`. */
|
|
12
|
+
write?: (chunk: string) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type Node = {
|
|
16
|
+
label: string;
|
|
17
|
+
startMs: number;
|
|
18
|
+
endMs?: number;
|
|
19
|
+
children: Node[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function createTraceTreeReporter(
|
|
23
|
+
options: TraceTreeReporterOptions = {},
|
|
24
|
+
): Telemetry {
|
|
25
|
+
const write =
|
|
26
|
+
options.write ?? ((chunk: string) => void process.stderr.write(chunk));
|
|
27
|
+
|
|
28
|
+
type TurnState = {
|
|
29
|
+
root: Node;
|
|
30
|
+
step?: Node;
|
|
31
|
+
tools: Map<string, Node>;
|
|
32
|
+
};
|
|
33
|
+
const turns = new Map<string, TurnState>();
|
|
34
|
+
|
|
35
|
+
const render = (node: Node, depth: number): string => {
|
|
36
|
+
const indent = ' '.repeat(depth);
|
|
37
|
+
const dur =
|
|
38
|
+
node.endMs != null
|
|
39
|
+
? `${Math.max(0, Math.round(node.endMs - node.startMs))}ms`
|
|
40
|
+
: '(open)';
|
|
41
|
+
let out = `${indent}- ${node.label} ${dur}\n`;
|
|
42
|
+
for (const child of node.children) out += render(child, depth + 1);
|
|
43
|
+
return out;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
onStart(event) {
|
|
48
|
+
const e = event as {
|
|
49
|
+
callId: string;
|
|
50
|
+
operationId?: string;
|
|
51
|
+
modelId?: string;
|
|
52
|
+
};
|
|
53
|
+
turns.set(e.callId, {
|
|
54
|
+
root: {
|
|
55
|
+
label: `${e.operationId ?? 'turn'}${e.modelId ? ` ${e.modelId}` : ''}`,
|
|
56
|
+
startMs: Date.now(),
|
|
57
|
+
children: [],
|
|
58
|
+
},
|
|
59
|
+
tools: new Map(),
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
onStepStart(event) {
|
|
63
|
+
const e = event as { callId: string; stepNumber?: number };
|
|
64
|
+
const turn = turns.get(e.callId);
|
|
65
|
+
if (!turn) return;
|
|
66
|
+
const step: Node = {
|
|
67
|
+
label: `step ${(e.stepNumber ?? turn.root.children.length) + 1}`,
|
|
68
|
+
startMs: Date.now(),
|
|
69
|
+
children: [],
|
|
70
|
+
};
|
|
71
|
+
turn.step = step;
|
|
72
|
+
turn.root.children.push(step);
|
|
73
|
+
},
|
|
74
|
+
onToolExecutionStart(event) {
|
|
75
|
+
const e = event as {
|
|
76
|
+
callId: string;
|
|
77
|
+
toolCall: { toolName: string; toolCallId: string };
|
|
78
|
+
};
|
|
79
|
+
const turn = turns.get(e.callId);
|
|
80
|
+
const parent = turn?.step ?? turn?.root;
|
|
81
|
+
if (!turn || !parent) return;
|
|
82
|
+
const node: Node = {
|
|
83
|
+
label: `tool ${e.toolCall.toolName}`,
|
|
84
|
+
startMs: Date.now(),
|
|
85
|
+
children: [],
|
|
86
|
+
};
|
|
87
|
+
turn.tools.set(e.toolCall.toolCallId, node);
|
|
88
|
+
parent.children.push(node);
|
|
89
|
+
},
|
|
90
|
+
onToolExecutionEnd(event) {
|
|
91
|
+
const e = event as {
|
|
92
|
+
callId: string;
|
|
93
|
+
toolCall: { toolCallId: string };
|
|
94
|
+
toolOutput: { type: string };
|
|
95
|
+
};
|
|
96
|
+
const node = turns.get(e.callId)?.tools.get(e.toolCall.toolCallId);
|
|
97
|
+
if (!node) return;
|
|
98
|
+
node.endMs = Date.now();
|
|
99
|
+
if (e.toolOutput.type === 'error') node.label += ' [error]';
|
|
100
|
+
},
|
|
101
|
+
onStepFinish(event) {
|
|
102
|
+
const e = event as { callId: string };
|
|
103
|
+
const turn = turns.get(e.callId);
|
|
104
|
+
if (turn?.step) {
|
|
105
|
+
turn.step.endMs = Date.now();
|
|
106
|
+
turn.step = undefined;
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
onEnd(event) {
|
|
110
|
+
const e = event as { callId: string };
|
|
111
|
+
const turn = turns.get(e.callId);
|
|
112
|
+
if (!turn) return;
|
|
113
|
+
turn.root.endMs = Date.now();
|
|
114
|
+
turns.delete(e.callId);
|
|
115
|
+
try {
|
|
116
|
+
write(`\n${render(turn.root, 0)}`);
|
|
117
|
+
} catch {
|
|
118
|
+
// Never let rendering break the turn.
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|