@narumitw/pi-langfuse 0.51.2 → 0.52.0
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 +46 -3
- package/dist/chunks/chunk-N7WSLB5Q.ts +88 -0
- package/dist/chunks/chunk-N7WSLB5Q.ts.map +7 -0
- package/dist/chunks/{runtime-6ZHLVC3A.ts → runtime-GLRAF7FE.ts} +2 -2
- package/dist/chunks/runtime-GLRAF7FE.ts.map +7 -0
- package/dist/config.d.ts +1 -1
- package/dist/durable-projection.d.ts +9 -0
- package/dist/durable-recorder.d.ts +49 -0
- package/dist/durable.d.ts +25 -0
- package/dist/durable.js +638 -0
- package/dist/durable.js.map +7 -0
- package/dist/index.js +248 -159
- package/dist/index.js.map +4 -4
- package/dist/index.ts +244 -16
- package/dist/index.ts.map +4 -4
- package/dist/library-chunks/chunk-B64FXCNE.js +84 -0
- package/dist/library-chunks/chunk-B64FXCNE.js.map +7 -0
- package/dist/library-chunks/chunk-ETFOPFEP.js +10 -0
- package/dist/library-chunks/chunk-ETFOPFEP.js.map +7 -0
- package/dist/library-chunks/chunk-JPFN6GGS.js +153 -0
- package/dist/library-chunks/chunk-JPFN6GGS.js.map +7 -0
- package/dist/library-chunks/{runtime-A6SQ6VEH.js → runtime-6KYOZVOE.js} +5 -3
- package/dist/library-chunks/runtime-6KYOZVOE.js.map +7 -0
- package/dist/public-api.d.ts +1 -3
- package/dist/runtime-api.d.ts +3 -0
- package/dist/runtime-defaults.d.ts +1 -0
- package/dist/runtime.d.ts +1 -1
- package/dist/sanitizer.d.ts +1 -1
- package/docs/durable.md +126 -0
- package/package.json +22 -2
- package/src/config.ts +4 -1
- package/src/durable-projection.ts +106 -0
- package/src/durable-recorder.ts +493 -0
- package/src/durable.ts +195 -0
- package/src/public-api.ts +6 -12
- package/src/runtime-api.ts +8 -0
- package/src/runtime-core.ts +2 -1
- package/src/runtime-defaults.ts +1 -0
- package/src/runtime.ts +2 -1
- package/src/sanitizer.ts +16 -4
- package/dist/chunks/chunk-DBQAWNKG.ts +0 -316
- package/dist/chunks/chunk-DBQAWNKG.ts.map +0 -7
- package/dist/chunks/runtime-6ZHLVC3A.ts.map +0 -7
- package/dist/library-chunks/chunk-SAJ76IYU.js +0 -316
- package/dist/library-chunks/chunk-SAJ76IYU.js.map +0 -7
- package/dist/library-chunks/runtime-A6SQ6VEH.js.map +0 -7
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { EntryRecord } from "@earendil-works/pi-durable";
|
|
2
|
+
import { sanitizeTraceValue } from "./sanitizer.js";
|
|
3
|
+
import type { ObservationAttributes } from "./tracing.js";
|
|
4
|
+
|
|
5
|
+
type Message = NonNullable<EntryRecord["model"]>[number];
|
|
6
|
+
const MAX_PROJECTED_ITEMS = 199;
|
|
7
|
+
|
|
8
|
+
export function durableAttributes(attributes: ObservationAttributes): ObservationAttributes {
|
|
9
|
+
const copy = { ...attributes };
|
|
10
|
+
if (attributes.metadata) {
|
|
11
|
+
const value = sanitizeTraceValue(attributes.metadata, true);
|
|
12
|
+
copy.metadata =
|
|
13
|
+
value && typeof value === "object" && !Array.isArray(value)
|
|
14
|
+
? (value as Record<string, unknown>)
|
|
15
|
+
: { "pi.durable.metadata_omitted": true };
|
|
16
|
+
}
|
|
17
|
+
for (const key of ["name", "model", "sessionId", "userId", "statusMessage", "version"] as const) {
|
|
18
|
+
if (typeof copy[key] === "string") copy[key] = String(sanitizeTraceValue(copy[key], true));
|
|
19
|
+
}
|
|
20
|
+
return copy;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function durableDiagnosticCount(entry: EntryRecord | undefined): number {
|
|
24
|
+
const data = entry?.data;
|
|
25
|
+
return data &&
|
|
26
|
+
typeof data === "object" &&
|
|
27
|
+
!Array.isArray(data) &&
|
|
28
|
+
"diagnostics" in data &&
|
|
29
|
+
Array.isArray(data.diagnostics)
|
|
30
|
+
? data.diagnostics.length
|
|
31
|
+
: 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Project committed messages without provider configuration, deltas or raw diagnostics. */
|
|
35
|
+
export function durableContent(
|
|
36
|
+
messages: readonly Message[] | undefined,
|
|
37
|
+
capture: boolean,
|
|
38
|
+
diagnosticCount = 0,
|
|
39
|
+
): unknown {
|
|
40
|
+
if (!capture) return "[content capture disabled]";
|
|
41
|
+
const projected: unknown[] | undefined = messages?.slice(0, MAX_PROJECTED_ITEMS).map((message) => {
|
|
42
|
+
if (message.role === "toolResult" && message.isError)
|
|
43
|
+
return { role: message.role, content: "[tool error content omitted]" };
|
|
44
|
+
if (message.role === "assistant" && message.stopReason === "error")
|
|
45
|
+
return { role: message.role, content: "[assistant error content omitted]" };
|
|
46
|
+
// The public ToolResultEntry shape appends rendered diagnostics as its last text block.
|
|
47
|
+
// That block can contain raw errors, headers or credentials even when the tool succeeds.
|
|
48
|
+
const omitDiagnosticBlock =
|
|
49
|
+
message.role === "toolResult" && diagnosticCount > 0 && message.content.at(-1)?.type === "text";
|
|
50
|
+
const blockCount = message.content.length - (omitDiagnosticBlock ? 1 : 0);
|
|
51
|
+
return {
|
|
52
|
+
role: message.role,
|
|
53
|
+
content:
|
|
54
|
+
typeof message.content === "string"
|
|
55
|
+
? message.content
|
|
56
|
+
: message.content.slice(0, Math.min(MAX_PROJECTED_ITEMS, blockCount)).map((block) => {
|
|
57
|
+
switch (block.type) {
|
|
58
|
+
case "text":
|
|
59
|
+
return { type: "text", text: block.text };
|
|
60
|
+
case "thinking":
|
|
61
|
+
return { type: "thinking", thinking: block.thinking };
|
|
62
|
+
case "toolCall":
|
|
63
|
+
return { type: "toolCall", id: block.id, name: block.name, arguments: block.arguments };
|
|
64
|
+
case "image":
|
|
65
|
+
return { type: "image", mimeType: block.mimeType, data: "[base64 omitted]" };
|
|
66
|
+
default:
|
|
67
|
+
return { type: "unsupported", content: "[block omitted]" };
|
|
68
|
+
}
|
|
69
|
+
}),
|
|
70
|
+
...(Array.isArray(message.content) && blockCount > MAX_PROJECTED_ITEMS
|
|
71
|
+
? { blocksOmitted: blockCount - MAX_PROJECTED_ITEMS }
|
|
72
|
+
: {}),
|
|
73
|
+
...(omitDiagnosticBlock ? { diagnosticsOmitted: diagnosticCount } : {}),
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
if (messages && messages.length > MAX_PROJECTED_ITEMS)
|
|
77
|
+
projected?.push({ messagesOmitted: messages.length - MAX_PROJECTED_ITEMS });
|
|
78
|
+
return sanitizeTraceValue(projected, true);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function durableUsage(message: Message): ObservationAttributes {
|
|
82
|
+
if ((message.role !== "assistant" && message.role !== "toolResult") || !message.usage) return {};
|
|
83
|
+
const usageDetails: Record<string, number> = {};
|
|
84
|
+
const costDetails: Record<string, number> = {};
|
|
85
|
+
for (const [key, field] of [
|
|
86
|
+
["input", "input"],
|
|
87
|
+
["output", "output"],
|
|
88
|
+
["cacheRead", "cache_read_input_tokens"],
|
|
89
|
+
["cacheWrite", "cache_creation_input_tokens"],
|
|
90
|
+
["totalTokens", "total"],
|
|
91
|
+
] as const) {
|
|
92
|
+
const value = message.usage[key];
|
|
93
|
+
if (typeof value === "number" && Number.isFinite(value) && value >= 0) usageDetails[field] = value;
|
|
94
|
+
}
|
|
95
|
+
for (const [key, field] of [
|
|
96
|
+
["input", "input"],
|
|
97
|
+
["output", "output"],
|
|
98
|
+
["cacheRead", "cache_read"],
|
|
99
|
+
["cacheWrite", "cache_write"],
|
|
100
|
+
["total", "total"],
|
|
101
|
+
] as const) {
|
|
102
|
+
const value = message.usage.cost[key];
|
|
103
|
+
if (typeof value === "number" && Number.isFinite(value) && value >= 0) costDetails[field] = value;
|
|
104
|
+
}
|
|
105
|
+
return { usageDetails, costDetails };
|
|
106
|
+
}
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentEvent,
|
|
3
|
+
EntryId,
|
|
4
|
+
EntryRecord,
|
|
5
|
+
SnapshotEvent,
|
|
6
|
+
SubmissionId,
|
|
7
|
+
SubmissionRecord,
|
|
8
|
+
} from "@earendil-works/pi-durable";
|
|
9
|
+
import { durableAttributes, durableContent, durableDiagnosticCount, durableUsage } from "./durable-projection.js";
|
|
10
|
+
import { sanitizeTraceValue } from "./sanitizer.js";
|
|
11
|
+
import type { Observation, ObservationAttributes, ObservationType, TraceBackend } from "./tracing.js";
|
|
12
|
+
|
|
13
|
+
export interface DurableCorrelation {
|
|
14
|
+
requestId?: string;
|
|
15
|
+
userId?: string;
|
|
16
|
+
applicationId?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface DurableRecorderOptions extends DurableCorrelation {
|
|
20
|
+
conversationId: number;
|
|
21
|
+
sessionId?: string;
|
|
22
|
+
traceName?: string;
|
|
23
|
+
captureContent?: boolean;
|
|
24
|
+
metadata?: Readonly<Record<string, unknown>>;
|
|
25
|
+
submissionCorrelation?: (record: SubmissionRecord) => DurableCorrelation;
|
|
26
|
+
onTraceId?: (traceId: string) => void | Promise<void>;
|
|
27
|
+
onError?: (stage: string) => void | Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const SAFE_UNANSWERED_REASONS = new Set([
|
|
31
|
+
"aborted",
|
|
32
|
+
"model_error",
|
|
33
|
+
"no_model",
|
|
34
|
+
"reset",
|
|
35
|
+
"stale",
|
|
36
|
+
"faulted",
|
|
37
|
+
"missing_task",
|
|
38
|
+
"task_too_old",
|
|
39
|
+
"migration_failed",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
interface Span {
|
|
43
|
+
observation?: Observation;
|
|
44
|
+
ended: boolean;
|
|
45
|
+
}
|
|
46
|
+
interface SubmissionSpan {
|
|
47
|
+
span: Span;
|
|
48
|
+
correlation: DurableCorrelation;
|
|
49
|
+
terminal: boolean;
|
|
50
|
+
}
|
|
51
|
+
interface RunSpan {
|
|
52
|
+
span: Span;
|
|
53
|
+
primary: SubmissionId;
|
|
54
|
+
inputs: readonly SubmissionId[];
|
|
55
|
+
correlation: DurableCorrelation;
|
|
56
|
+
round: number;
|
|
57
|
+
generation?: Span;
|
|
58
|
+
tools: Map<string, Span>;
|
|
59
|
+
completedTools: Set<string>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Durable state machine using the same trace backend, ownership and capture core as the Pi adapter. */
|
|
63
|
+
export class DurableRecorder {
|
|
64
|
+
private readonly root: Span;
|
|
65
|
+
private run?: RunSpan;
|
|
66
|
+
private readonly submissions = new Map<SubmissionId, SubmissionSpan>();
|
|
67
|
+
private readonly pendingSubmissions = new Set<SubmissionId>();
|
|
68
|
+
private readonly entries = new Set<number>();
|
|
69
|
+
private readonly completedRuns = new Set<SubmissionId>();
|
|
70
|
+
private disposed = false;
|
|
71
|
+
private gaps = 0;
|
|
72
|
+
|
|
73
|
+
constructor(
|
|
74
|
+
private readonly backend: TraceBackend,
|
|
75
|
+
private readonly options: DurableRecorderOptions,
|
|
76
|
+
) {
|
|
77
|
+
this.root = this.start(options.traceName ?? "pi.durable.conversation", "agent", undefined, {
|
|
78
|
+
metadata: {
|
|
79
|
+
...this.customMetadata(),
|
|
80
|
+
"pi.runtime": "durable",
|
|
81
|
+
"pi.durable.schema_version": "1",
|
|
82
|
+
"pi.durable.conversation_id": options.conversationId,
|
|
83
|
+
...(options.applicationId ? { "pi.application.id": options.applicationId } : {}),
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
this.safe(() => {
|
|
87
|
+
this.root.observation?.updateTrace?.(
|
|
88
|
+
durableAttributes({
|
|
89
|
+
name: options.traceName ?? "pi.durable.conversation",
|
|
90
|
+
sessionId: options.sessionId ?? String(options.conversationId),
|
|
91
|
+
...(options.userId ? { userId: options.userId } : {}),
|
|
92
|
+
metadata: this.customMetadata(),
|
|
93
|
+
version: "durable-1",
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
const traceId = this.root.observation?.traceId;
|
|
98
|
+
if (traceId) this.callback("trace-id callback", () => options.onTraceId?.(traceId));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
get submissionIds(): readonly SubmissionId[] {
|
|
102
|
+
return [...this.pendingSubmissions];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
report(stage: string): void {
|
|
106
|
+
this.callback("error callback", () => this.options.onError?.(stage), false);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private callback(stage: string, action: () => void | Promise<void>, report = true): void {
|
|
110
|
+
try {
|
|
111
|
+
Promise.resolve(action()).catch(() => {
|
|
112
|
+
if (report) this.report(stage);
|
|
113
|
+
});
|
|
114
|
+
} catch {
|
|
115
|
+
if (report) this.report(stage);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private customMetadata(): Record<string, unknown> {
|
|
120
|
+
const value = sanitizeTraceValue(this.options.metadata, true);
|
|
121
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
122
|
+
return Object.fromEntries(Object.entries(value).filter(([key]) => !key.startsWith("pi.")));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private safe(action: () => void): void {
|
|
126
|
+
try {
|
|
127
|
+
action();
|
|
128
|
+
} catch {
|
|
129
|
+
this.report("exporter observation");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private start(name: string, type: ObservationType, parent?: Span, attributes: ObservationAttributes = {}): Span {
|
|
134
|
+
if (this.disposed || (parent && (parent.ended || !parent.observation))) return { ended: true };
|
|
135
|
+
const span: Span = { ended: false };
|
|
136
|
+
const inherited = parent === this.run?.span ? this.correlationAttributes(this.run?.correlation ?? {}) : {};
|
|
137
|
+
this.safe(() => {
|
|
138
|
+
span.observation = this.backend.start(
|
|
139
|
+
String(sanitizeTraceValue(name, true)),
|
|
140
|
+
durableAttributes({
|
|
141
|
+
version: "durable-1",
|
|
142
|
+
sessionId: this.options.sessionId ?? String(this.options.conversationId),
|
|
143
|
+
...(this.options.userId ? { userId: this.options.userId } : {}),
|
|
144
|
+
...inherited,
|
|
145
|
+
...attributes,
|
|
146
|
+
metadata: { ...inherited.metadata, ...attributes.metadata },
|
|
147
|
+
}),
|
|
148
|
+
{ asType: type, parent: parent?.observation },
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
// A missing parent handle must never become a new exporter root for descendants.
|
|
152
|
+
if (!span.observation) span.ended = true;
|
|
153
|
+
if (this.disposed) this.interrupted(span, "Observer disposed during exporter start.");
|
|
154
|
+
return span;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private update(span: Span, attributes: ObservationAttributes): void {
|
|
158
|
+
if (!span.ended)
|
|
159
|
+
this.safe(() => {
|
|
160
|
+
span.observation?.update(durableAttributes(attributes));
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private end(span: Span | undefined, attributes: ObservationAttributes = {}): void {
|
|
165
|
+
if (!span || span.ended) return;
|
|
166
|
+
span.ended = true;
|
|
167
|
+
const observation = span.observation;
|
|
168
|
+
// Release ended SDK handles/payloads, and claim the end before any callback can reenter disposal.
|
|
169
|
+
span.observation = undefined;
|
|
170
|
+
this.safe(() => {
|
|
171
|
+
observation?.update(durableAttributes(attributes));
|
|
172
|
+
});
|
|
173
|
+
this.safe(() => {
|
|
174
|
+
observation?.end();
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private interrupted(span: Span | undefined, reason: string): void {
|
|
179
|
+
this.end(span, {
|
|
180
|
+
level: "WARNING",
|
|
181
|
+
statusMessage: reason,
|
|
182
|
+
metadata: { "pi.durable.outcome": "observation_interrupted" },
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private correlationAttributes(correlation: DurableCorrelation): ObservationAttributes {
|
|
187
|
+
return {
|
|
188
|
+
metadata: {
|
|
189
|
+
...(correlation.userId ? { "pi.user.id": correlation.userId } : {}),
|
|
190
|
+
...(correlation.requestId ? { "pi.request.id": correlation.requestId } : {}),
|
|
191
|
+
...(correlation.applicationId ? { "pi.application.id": correlation.applicationId } : {}),
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async submission(
|
|
197
|
+
record: SubmissionRecord,
|
|
198
|
+
readEntry: (id: EntryId) => Promise<EntryRecord | undefined>,
|
|
199
|
+
correlation?: DurableCorrelation,
|
|
200
|
+
snapshot = false,
|
|
201
|
+
): Promise<void> {
|
|
202
|
+
if (this.disposed || record.conversationId !== this.options.conversationId) return;
|
|
203
|
+
let state = this.submissions.get(record.id);
|
|
204
|
+
if (!state) {
|
|
205
|
+
let host: DurableCorrelation = {};
|
|
206
|
+
try {
|
|
207
|
+
host = this.options.submissionCorrelation?.(record) ?? {};
|
|
208
|
+
} catch {
|
|
209
|
+
this.report("submission correlation");
|
|
210
|
+
}
|
|
211
|
+
if (this.disposed) return;
|
|
212
|
+
const ids = sanitizeTraceValue(
|
|
213
|
+
{
|
|
214
|
+
requestId: record.requestId,
|
|
215
|
+
userId: this.options.userId,
|
|
216
|
+
applicationId: this.options.applicationId,
|
|
217
|
+
...host,
|
|
218
|
+
...correlation,
|
|
219
|
+
},
|
|
220
|
+
true,
|
|
221
|
+
) as DurableCorrelation;
|
|
222
|
+
state = {
|
|
223
|
+
span: this.start("pi.durable.submission", "span", this.root, {
|
|
224
|
+
...this.correlationAttributes(ids),
|
|
225
|
+
metadata: {
|
|
226
|
+
...this.correlationAttributes(ids).metadata,
|
|
227
|
+
"pi.durable.submission_id": record.id,
|
|
228
|
+
"pi.durable.submission_type": record.type,
|
|
229
|
+
"pi.durable.snapshot_observation": snapshot,
|
|
230
|
+
},
|
|
231
|
+
}),
|
|
232
|
+
correlation: ids,
|
|
233
|
+
terminal: false,
|
|
234
|
+
};
|
|
235
|
+
if (this.disposed) return;
|
|
236
|
+
this.submissions.set(record.id, state);
|
|
237
|
+
this.pendingSubmissions.add(record.id);
|
|
238
|
+
}
|
|
239
|
+
if (state.terminal) return;
|
|
240
|
+
// Status reads can be newer than the run snapshot. Only ordered placement events extend membership.
|
|
241
|
+
if (!snapshot && record.status === "placed" && this.run && !this.run.inputs.includes(record.id)) {
|
|
242
|
+
this.run.inputs = [...this.run.inputs, record.id];
|
|
243
|
+
this.update(this.run.span, { metadata: { "pi.durable.inputs": this.run.inputs } });
|
|
244
|
+
}
|
|
245
|
+
if (correlation) {
|
|
246
|
+
state.correlation = sanitizeTraceValue({ ...state.correlation, ...correlation }, true) as DurableCorrelation;
|
|
247
|
+
this.update(state.span, this.correlationAttributes(state.correlation));
|
|
248
|
+
}
|
|
249
|
+
const run = this.run;
|
|
250
|
+
if (run?.primary === record.id) {
|
|
251
|
+
run.correlation = state.correlation;
|
|
252
|
+
const attributes = this.correlationAttributes(state.correlation);
|
|
253
|
+
this.update(run.span, attributes);
|
|
254
|
+
if (run.generation) this.update(run.generation, attributes);
|
|
255
|
+
for (const tool of run.tools.values()) this.update(tool, attributes);
|
|
256
|
+
}
|
|
257
|
+
this.update(state.span, { metadata: { "pi.durable.submission_status": record.status } });
|
|
258
|
+
if (record.entry !== undefined && record.type === "input") {
|
|
259
|
+
const entry = await readEntry(record.entry);
|
|
260
|
+
if (this.disposed || state.terminal) return;
|
|
261
|
+
this.update(state.span, { input: durableContent(entry?.model, this.options.captureContent ?? true) });
|
|
262
|
+
}
|
|
263
|
+
if (record.status === "done" || record.status === "unanswered") {
|
|
264
|
+
let output: unknown;
|
|
265
|
+
if (record.status === "done" && record.type === "input") {
|
|
266
|
+
const entry = await readEntry(record.answer);
|
|
267
|
+
if (this.disposed || state.terminal) return;
|
|
268
|
+
// The answer ID is authoritative; no streamed fragments or last intermediate assistant inference.
|
|
269
|
+
output = durableContent(entry?.model, this.options.captureContent ?? true);
|
|
270
|
+
}
|
|
271
|
+
const reason =
|
|
272
|
+
record.status === "unanswered" && SAFE_UNANSWERED_REASONS.has(record.reason) ? record.reason : undefined;
|
|
273
|
+
state.terminal = true;
|
|
274
|
+
this.pendingSubmissions.delete(record.id);
|
|
275
|
+
this.end(state.span, {
|
|
276
|
+
...(output !== undefined ? { output } : {}),
|
|
277
|
+
level:
|
|
278
|
+
record.status === "done"
|
|
279
|
+
? "DEFAULT"
|
|
280
|
+
: reason === "model_error" || reason === "no_model" || reason === "faulted"
|
|
281
|
+
? "ERROR"
|
|
282
|
+
: "WARNING",
|
|
283
|
+
// Only known structural reason codes are safe; arbitrary reason/detail can contain provider secrets.
|
|
284
|
+
metadata: {
|
|
285
|
+
"pi.durable.submission_status": record.status,
|
|
286
|
+
"pi.durable.answer_entry_id": record.answer,
|
|
287
|
+
...(record.status === "unanswered" ? { "pi.durable.unanswered_reason": reason ?? "unknown" } : {}),
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
snapshot(snapshot: SnapshotEvent, initial = false): void {
|
|
294
|
+
if (this.disposed) return;
|
|
295
|
+
for (const entry of snapshot.entries) this.entries.add(entry.id);
|
|
296
|
+
if (!initial) {
|
|
297
|
+
this.gaps += 1;
|
|
298
|
+
this.update(this.root, {
|
|
299
|
+
metadata: { "pi.durable.resync_count": this.gaps, "pi.durable.history_complete": false },
|
|
300
|
+
});
|
|
301
|
+
} else
|
|
302
|
+
this.update(this.root, {
|
|
303
|
+
metadata: { "pi.durable.history_complete": false, "pi.durable.attachment": "snapshot" },
|
|
304
|
+
});
|
|
305
|
+
const primary = snapshot.run?.inputs[0];
|
|
306
|
+
if (this.run && this.run.primary !== primary)
|
|
307
|
+
this.closeRun("Run boundary not observed; snapshot replaced the observation.");
|
|
308
|
+
if (this.run) this.closeRound("Snapshot replaced generation/tool observation; activity may have been missed.");
|
|
309
|
+
if (snapshot.run) {
|
|
310
|
+
this.beginRun(snapshot.run.inputs, true);
|
|
311
|
+
// Do not turn snapshot transcript/partials/tools into historical starts or completions.
|
|
312
|
+
if (this.run) this.update(this.run.span, { metadata: { "pi.durable.observation_incomplete": true } });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private beginRun(inputs: readonly SubmissionId[], snapshot = false): void {
|
|
317
|
+
const primary = inputs[0];
|
|
318
|
+
if (primary === undefined || this.completedRuns.has(primary)) return;
|
|
319
|
+
if (this.run?.primary === primary) {
|
|
320
|
+
this.run.inputs = [...inputs];
|
|
321
|
+
this.update(this.run.span, { metadata: { "pi.durable.inputs": this.run.inputs } });
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (this.run) this.closeRun("Unexpected run replacement.");
|
|
325
|
+
const correlation = this.submissions.get(primary)?.correlation ?? {};
|
|
326
|
+
this.run = {
|
|
327
|
+
span: this.start("pi.durable.run", "agent", this.root, {
|
|
328
|
+
...this.correlationAttributes(correlation),
|
|
329
|
+
metadata: {
|
|
330
|
+
...this.correlationAttributes(correlation).metadata,
|
|
331
|
+
"pi.durable.primary_submission_id": primary,
|
|
332
|
+
"pi.durable.inputs": [...inputs],
|
|
333
|
+
"pi.durable.snapshot_observation": snapshot,
|
|
334
|
+
},
|
|
335
|
+
}),
|
|
336
|
+
primary,
|
|
337
|
+
inputs: [...inputs],
|
|
338
|
+
correlation,
|
|
339
|
+
round: 0,
|
|
340
|
+
tools: new Map(),
|
|
341
|
+
completedTools: new Set(),
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private closeRound(reason: string): void {
|
|
346
|
+
const run = this.run;
|
|
347
|
+
if (!run) return;
|
|
348
|
+
this.interrupted(run.generation, reason);
|
|
349
|
+
run.generation = undefined;
|
|
350
|
+
for (const tool of run.tools.values()) this.interrupted(tool, reason);
|
|
351
|
+
run.tools.clear();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private closeRun(reason?: string): void {
|
|
355
|
+
const run = this.run;
|
|
356
|
+
if (!run) return;
|
|
357
|
+
this.closeRound(reason ?? "Run ended without an observed descendant completion.");
|
|
358
|
+
this.end(
|
|
359
|
+
run.span,
|
|
360
|
+
reason
|
|
361
|
+
? { level: "WARNING", statusMessage: reason, metadata: { "pi.durable.observation_incomplete": true } }
|
|
362
|
+
: {},
|
|
363
|
+
);
|
|
364
|
+
this.completedRuns.add(run.primary);
|
|
365
|
+
this.run = undefined;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private generation(): Span | undefined {
|
|
369
|
+
const run = this.run;
|
|
370
|
+
if (!run) return undefined;
|
|
371
|
+
if (!run.generation) {
|
|
372
|
+
run.round += 1;
|
|
373
|
+
run.generation = this.start("pi.durable.llm", "generation", run.span, {
|
|
374
|
+
metadata: { "pi.durable.observed_generation_index": run.round },
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
return run.generation;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
event(event: AgentEvent): void {
|
|
381
|
+
if (this.disposed) return;
|
|
382
|
+
switch (event.type) {
|
|
383
|
+
case "run_start":
|
|
384
|
+
this.beginRun(event.inputs);
|
|
385
|
+
break;
|
|
386
|
+
case "run_end":
|
|
387
|
+
if (this.run?.primary === event.inputs[0]) this.closeRun();
|
|
388
|
+
break;
|
|
389
|
+
case "message_start":
|
|
390
|
+
if (event.message.role === "assistant") this.generation();
|
|
391
|
+
break;
|
|
392
|
+
case "message_end": {
|
|
393
|
+
const entry = event.entry;
|
|
394
|
+
if (this.entries.has(entry.id)) break;
|
|
395
|
+
this.entries.add(entry.id);
|
|
396
|
+
const message = entry.model?.[0];
|
|
397
|
+
if (message?.role !== "assistant") break;
|
|
398
|
+
const generation = this.generation();
|
|
399
|
+
if (!generation) break;
|
|
400
|
+
this.end(generation, {
|
|
401
|
+
output: durableContent(entry.model, this.options.captureContent ?? true),
|
|
402
|
+
model: message.model,
|
|
403
|
+
...durableUsage(message),
|
|
404
|
+
level: message.stopReason === "error" ? "ERROR" : message.stopReason === "aborted" ? "WARNING" : "DEFAULT",
|
|
405
|
+
metadata: {
|
|
406
|
+
"pi.durable.entry_id": entry.id,
|
|
407
|
+
"pi.durable.task_id": entry.byTaskId,
|
|
408
|
+
"pi.durable.stop_reason": message.stopReason,
|
|
409
|
+
"pi.durable.provider": message.provider,
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
if (this.run) this.run.generation = undefined;
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
case "tool_execution_start": {
|
|
416
|
+
if (
|
|
417
|
+
!this.run ||
|
|
418
|
+
this.run.tools.has(event.toolCallId) ||
|
|
419
|
+
this.run.completedTools.has(`${this.run.round}:${event.toolCallId}`)
|
|
420
|
+
)
|
|
421
|
+
break;
|
|
422
|
+
this.run.tools.set(
|
|
423
|
+
event.toolCallId,
|
|
424
|
+
this.start("pi.durable.tool", "tool", this.run.span, {
|
|
425
|
+
input:
|
|
426
|
+
this.options.captureContent === false
|
|
427
|
+
? "[content capture disabled]"
|
|
428
|
+
: sanitizeTraceValue(event.args, true),
|
|
429
|
+
metadata: {
|
|
430
|
+
"pi.durable.tool_call_id": event.toolCallId,
|
|
431
|
+
"pi.durable.tool_name": sanitizeTraceValue(event.toolName),
|
|
432
|
+
"pi.durable.observed_generation_index": this.run.round,
|
|
433
|
+
},
|
|
434
|
+
}),
|
|
435
|
+
);
|
|
436
|
+
break;
|
|
437
|
+
}
|
|
438
|
+
case "tool_execution_end": {
|
|
439
|
+
const run = this.run;
|
|
440
|
+
if (!run) break;
|
|
441
|
+
const key = `${run.round}:${event.toolCallId}`;
|
|
442
|
+
if (run.completedTools.has(key) || (event.entry && this.entries.has(event.entry.id))) break;
|
|
443
|
+
run.completedTools.add(key);
|
|
444
|
+
if (event.entry) this.entries.add(event.entry.id);
|
|
445
|
+
const tool =
|
|
446
|
+
run.tools.get(event.toolCallId) ??
|
|
447
|
+
this.start("pi.durable.tool", "tool", run.span, {
|
|
448
|
+
metadata: { "pi.durable.start_not_observed": true, "pi.durable.tool_call_id": event.toolCallId },
|
|
449
|
+
});
|
|
450
|
+
const message = event.entry?.model?.[0];
|
|
451
|
+
this.end(tool, {
|
|
452
|
+
output: durableContent(
|
|
453
|
+
event.entry?.model,
|
|
454
|
+
this.options.captureContent ?? true,
|
|
455
|
+
durableDiagnosticCount(event.entry),
|
|
456
|
+
),
|
|
457
|
+
...(message ? durableUsage(message) : {}),
|
|
458
|
+
level: message?.role === "toolResult" && message.isError ? "ERROR" : event.entry ? "DEFAULT" : "WARNING",
|
|
459
|
+
metadata: { "pi.durable.result_entry_id": event.entry?.id, "pi.durable.result_missing": !event.entry },
|
|
460
|
+
});
|
|
461
|
+
run.tools.delete(event.toolCallId);
|
|
462
|
+
break;
|
|
463
|
+
}
|
|
464
|
+
case "auto_retry_start":
|
|
465
|
+
this.end(this.run?.generation, {
|
|
466
|
+
level: "ERROR",
|
|
467
|
+
statusMessage: "Provider attempt will retry.",
|
|
468
|
+
metadata: { "pi.durable.retry_attempt": event.attempt },
|
|
469
|
+
});
|
|
470
|
+
if (this.run) this.run.generation = undefined;
|
|
471
|
+
break;
|
|
472
|
+
case "task_failed":
|
|
473
|
+
if (this.run)
|
|
474
|
+
this.update(this.run.span, { level: "ERROR", metadata: { "pi.durable.failed_task_id": event.taskId } });
|
|
475
|
+
break;
|
|
476
|
+
// turn_end is a round boundary, never a submission boundary. Deltas and arbitrary diagnostics are not captured.
|
|
477
|
+
default:
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
dispose(reason = "Tracing observer disposed; execution outcome is unknown."): void {
|
|
483
|
+
if (this.disposed) return;
|
|
484
|
+
this.disposed = true;
|
|
485
|
+
this.closeRun(reason);
|
|
486
|
+
for (const state of this.submissions.values()) if (!state.terminal) this.interrupted(state.span, reason);
|
|
487
|
+
this.end(this.root, { metadata: { "pi.durable.resync_count": this.gaps } });
|
|
488
|
+
this.submissions.clear();
|
|
489
|
+
this.pendingSubmissions.clear();
|
|
490
|
+
this.entries.clear();
|
|
491
|
+
this.completedRuns.clear();
|
|
492
|
+
}
|
|
493
|
+
}
|