nemo-relay-openclaw 0.0.0 → 0.3.0-beta.2
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 +262 -4
- package/dist/index.d.ts +8 -0
- package/dist/index.js +20 -0
- package/dist/openclaw.plugin.json +154 -0
- package/dist/src/config.d.ts +329 -0
- package/dist/src/config.js +185 -0
- package/dist/src/health.d.ts +48 -0
- package/dist/src/health.js +63 -0
- package/dist/src/hook-replay/correlation.d.ts +34 -0
- package/dist/src/hook-replay/correlation.js +40 -0
- package/dist/src/hook-replay/llm.d.ts +52 -0
- package/dist/src/hook-replay/llm.js +1060 -0
- package/dist/src/hook-replay/marks.d.ts +30 -0
- package/dist/src/hook-replay/marks.js +109 -0
- package/dist/src/hook-replay/session.d.ts +157 -0
- package/dist/src/hook-replay/session.js +204 -0
- package/dist/src/hook-replay/tool.d.ts +12 -0
- package/dist/src/hook-replay/tool.js +101 -0
- package/dist/src/hooks-backend.d.ts +94 -0
- package/dist/src/hooks-backend.js +321 -0
- package/dist/src/modules.d.ts +42 -0
- package/dist/src/modules.js +15 -0
- package/dist/src/openclaw-hook-types.d.ts +186 -0
- package/dist/src/openclaw-hook-types.js +3 -0
- package/dist/src/runtime-state.d.ts +66 -0
- package/dist/src/runtime-state.js +420 -0
- package/dist/src/types.d.ts +27 -0
- package/dist/src/types.js +3 -0
- package/index.ts +23 -0
- package/openclaw.plugin.json +154 -0
- package/package.json +62 -24
- package/src/config.ts +251 -0
- package/src/health.ts +116 -0
- package/src/hook-replay/correlation.ts +75 -0
- package/src/hook-replay/llm.ts +1302 -0
- package/src/hook-replay/marks.ts +141 -0
- package/src/hook-replay/session.ts +396 -0
- package/src/hook-replay/tool.ts +142 -0
- package/src/hooks-backend.ts +457 -0
- package/src/modules.ts +79 -0
- package/src/openclaw-hook-types.ts +211 -0
- package/src/runtime-state.ts +553 -0
- package/src/types.ts +34 -0
- package/index.d.ts +0 -2
- package/index.js +0 -15
|
@@ -0,0 +1,1060 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { emitMark, toJsonRecord, toJsonValue } from './marks.js';
|
|
4
|
+
import { evictExpiredCorrelationRecords, ensureSession, insertBoundedRecord, resolveSessionKey, } from './session.js';
|
|
5
|
+
import { llmKey, modelTimingKey, modelTimingLlmKey, nowMicros, startMicrosFromDuration } from './correlation.js';
|
|
6
|
+
/**
|
|
7
|
+
* Store one OpenClaw llm_input snapshot and replay it immediately if the matching
|
|
8
|
+
* llm_output arrived first.
|
|
9
|
+
*/
|
|
10
|
+
export function recordLlmInput(manager, event, ctx) {
|
|
11
|
+
evictExpiredReplayRecords(manager);
|
|
12
|
+
const session = ensureSession(manager, {
|
|
13
|
+
sessionId: event.sessionId,
|
|
14
|
+
sessionKey: ctx.sessionKey,
|
|
15
|
+
runId: event.runId,
|
|
16
|
+
agentId: ctx.agentId,
|
|
17
|
+
source: 'lazy_session',
|
|
18
|
+
});
|
|
19
|
+
if (!session) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (hasTrajectoryReplay(session, event.runId)) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
rememberAgentRunInputSnapshot(session, event.runId, event.historyMessages, event.prompt, manager.config.correlation.maxRecordsPerKey);
|
|
26
|
+
const key = llmKey(event);
|
|
27
|
+
const input = createInputRecord(session, event);
|
|
28
|
+
insertBoundedRecord(manager.state.llmInputs, key, input, manager.config.correlation.maxRecordsPerKey);
|
|
29
|
+
const pending = shiftOldest(manager.state.llmOutputsPendingInput, key, (record) => record.sessionKey === session.sessionId);
|
|
30
|
+
if (!pending) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
removeRecord(manager.state.llmInputs, key, input);
|
|
34
|
+
clearPendingTimer(pending);
|
|
35
|
+
replayLlmOutput({
|
|
36
|
+
manager,
|
|
37
|
+
event: pending.event,
|
|
38
|
+
ctx: pending.ctx,
|
|
39
|
+
input,
|
|
40
|
+
timing: consumeTimingCandidate(manager, session, pending.event),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Replay one llm_output event or hold it briefly for a late llm_input snapshot.
|
|
45
|
+
*
|
|
46
|
+
* The grace window keeps traces accurate for out-of-order hooks without blocking
|
|
47
|
+
* the OpenClaw process or keeping Node alive.
|
|
48
|
+
*/
|
|
49
|
+
export function recordLlmOutput(manager, event, ctx) {
|
|
50
|
+
evictExpiredReplayRecords(manager);
|
|
51
|
+
const session = ensureSession(manager, {
|
|
52
|
+
sessionId: event.sessionId,
|
|
53
|
+
sessionKey: ctx.sessionKey,
|
|
54
|
+
runId: event.runId,
|
|
55
|
+
agentId: ctx.agentId,
|
|
56
|
+
source: 'lazy_session',
|
|
57
|
+
});
|
|
58
|
+
if (!session) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const key = llmKey(event);
|
|
62
|
+
if (hasTrajectoryReplay(session, event.runId)) {
|
|
63
|
+
shiftOldest(manager.state.llmInputs, key, (record) => record.sessionKey === session.sessionId);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const input = shiftOldest(manager.state.llmInputs, key, (record) => record.sessionKey === session.sessionId);
|
|
67
|
+
if (input) {
|
|
68
|
+
replayLlmOutput({
|
|
69
|
+
manager,
|
|
70
|
+
event,
|
|
71
|
+
ctx,
|
|
72
|
+
input,
|
|
73
|
+
timing: consumeTimingCandidate(manager, session, event),
|
|
74
|
+
});
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const pending = {
|
|
78
|
+
sessionKey: session.sessionId,
|
|
79
|
+
sessionId: event.sessionId,
|
|
80
|
+
runId: event.runId,
|
|
81
|
+
provider: event.provider,
|
|
82
|
+
model: event.model,
|
|
83
|
+
event,
|
|
84
|
+
ctx,
|
|
85
|
+
observedAtMs: Date.now(),
|
|
86
|
+
};
|
|
87
|
+
pending.timer = setTimeout(() => replayExpiredPendingOutput(manager, key, pending), manager.config.correlation.llmOutputGraceMs);
|
|
88
|
+
pending.timer.unref?.();
|
|
89
|
+
insertPendingOutput(manager, key, pending);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Capture assistant message writes as a higher-fidelity fallback for LLM outputs.
|
|
93
|
+
*
|
|
94
|
+
* Some OpenClaw paths expose the clearest assistant text, tool calls, and usage
|
|
95
|
+
* during message persistence rather than through llm_output.
|
|
96
|
+
*/
|
|
97
|
+
export function recordBeforeMessageWrite(manager, event, ctx) {
|
|
98
|
+
evictExpiredReplayRecords(manager);
|
|
99
|
+
const session = existingSessionForMessageWrite(manager, event, ctx);
|
|
100
|
+
if (!session) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const message = isRecord(event.message) ? event.message : undefined;
|
|
104
|
+
if (!message || typeof message.role !== 'string') {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const recordedMessage = toJsonValue(message);
|
|
108
|
+
const historyMessages = session.messageWrites === undefined || session.messageWrites.length === 0
|
|
109
|
+
? initialHistoryFromLlmInputSnapshot(session)
|
|
110
|
+
: [...session.messageWrites];
|
|
111
|
+
if ((session.messageWrites === undefined || session.messageWrites.length === 0) && historyMessages.length > 0) {
|
|
112
|
+
session.messageWrites = [...historyMessages];
|
|
113
|
+
}
|
|
114
|
+
if (message.role === 'assistant') {
|
|
115
|
+
const provider = stringField(message, 'provider');
|
|
116
|
+
const model = stringField(message, 'model');
|
|
117
|
+
const assistantTexts = extractTextBlocks(message);
|
|
118
|
+
const assistantToolCalls = snapshotMessages(extractToolCalls(message));
|
|
119
|
+
const usage = 'usage' in message ? toJsonValue(message.usage) : undefined;
|
|
120
|
+
if (provider && model && (assistantTexts.length > 0 || assistantToolCalls.length > 0 || usage !== undefined)) {
|
|
121
|
+
session.assistantMessageWrites ??= [];
|
|
122
|
+
session.assistantMessageWrites.push({
|
|
123
|
+
sessionKey: session.sessionId,
|
|
124
|
+
provider,
|
|
125
|
+
model,
|
|
126
|
+
assistantTexts,
|
|
127
|
+
assistantToolCalls,
|
|
128
|
+
historyMessages,
|
|
129
|
+
prompt: '',
|
|
130
|
+
observedAtMs: Date.now(),
|
|
131
|
+
replayed: false,
|
|
132
|
+
...(usage === undefined ? {} : { usage }),
|
|
133
|
+
});
|
|
134
|
+
while (session.assistantMessageWrites.length > manager.config.correlation.maxRecordsPerKey) {
|
|
135
|
+
session.assistantMessageWrites.shift();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
session.messageWrites ??= [];
|
|
140
|
+
session.messageWrites.push(recordedMessage);
|
|
141
|
+
while (session.messageWrites.length > manager.config.correlation.maxRecordsPerKey) {
|
|
142
|
+
session.messageWrites.shift();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** Record the start of a provider call when OpenClaw supplies a model call id. */
|
|
146
|
+
export function recordModelCallStarted(manager, event, ctx) {
|
|
147
|
+
evictExpiredReplayRecords(manager);
|
|
148
|
+
const nowMs = Date.now();
|
|
149
|
+
const session = ensureSession(manager, {
|
|
150
|
+
sessionId: event.sessionId ?? ctx.sessionId,
|
|
151
|
+
sessionKey: event.sessionKey ?? ctx.sessionKey,
|
|
152
|
+
runId: event.runId,
|
|
153
|
+
agentId: ctx.agentId,
|
|
154
|
+
source: 'lazy_session',
|
|
155
|
+
timestamp: nowMs * 1000,
|
|
156
|
+
});
|
|
157
|
+
if (!session) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
insertBoundedRecord(manager.state.modelCallsByCallId, modelTimingKey(event), {
|
|
161
|
+
sessionKey: session.sessionId,
|
|
162
|
+
sessionId: session.sessionId,
|
|
163
|
+
runId: event.runId,
|
|
164
|
+
callId: event.callId,
|
|
165
|
+
provider: event.provider,
|
|
166
|
+
model: event.model,
|
|
167
|
+
consumed: false,
|
|
168
|
+
observedAtMs: nowMs,
|
|
169
|
+
startedAtMs: nowMs,
|
|
170
|
+
...(event.api === undefined ? {} : { api: event.api }),
|
|
171
|
+
...(event.transport === undefined ? {} : { transport: event.transport }),
|
|
172
|
+
}, manager.config.correlation.maxRecordsPerKey);
|
|
173
|
+
}
|
|
174
|
+
/** Record provider-call timing and byte counters for later LLM span correlation. */
|
|
175
|
+
export function recordModelCallEnded(manager, event, ctx) {
|
|
176
|
+
evictExpiredReplayRecords(manager);
|
|
177
|
+
const nowMs = Date.now();
|
|
178
|
+
const startMicros = startMicrosFromDuration(nowMs * 1000, event.durationMs) ?? nowMs * 1000;
|
|
179
|
+
const session = ensureSession(manager, {
|
|
180
|
+
sessionId: event.sessionId ?? ctx.sessionId,
|
|
181
|
+
sessionKey: event.sessionKey ?? ctx.sessionKey,
|
|
182
|
+
runId: event.runId,
|
|
183
|
+
agentId: ctx.agentId,
|
|
184
|
+
source: 'lazy_session',
|
|
185
|
+
timestamp: startMicros,
|
|
186
|
+
});
|
|
187
|
+
if (!session) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const byCallKey = modelTimingKey(event);
|
|
191
|
+
const existing = latestUnendedRecord(manager.state.modelCallsByCallId.get(byCallKey), session);
|
|
192
|
+
const record = existing ??
|
|
193
|
+
{
|
|
194
|
+
sessionKey: session.sessionId,
|
|
195
|
+
sessionId: session.sessionId,
|
|
196
|
+
runId: event.runId,
|
|
197
|
+
callId: event.callId,
|
|
198
|
+
provider: event.provider,
|
|
199
|
+
model: event.model,
|
|
200
|
+
consumed: false,
|
|
201
|
+
observedAtMs: nowMs,
|
|
202
|
+
};
|
|
203
|
+
applyModelCallEnd(record, event, nowMs);
|
|
204
|
+
if (!existing) {
|
|
205
|
+
insertBoundedRecord(manager.state.modelCallsByCallId, byCallKey, record, manager.config.correlation.maxRecordsPerKey);
|
|
206
|
+
}
|
|
207
|
+
insertBoundedRecord(manager.state.modelTimingsByLlmKey, modelTimingLlmKey({
|
|
208
|
+
sessionId: session.sessionId,
|
|
209
|
+
runId: event.runId,
|
|
210
|
+
provider: event.provider,
|
|
211
|
+
model: event.model,
|
|
212
|
+
}), record, manager.config.correlation.maxRecordsPerKey);
|
|
213
|
+
}
|
|
214
|
+
/** Flush pending llm_output records for a session before the root span closes. */
|
|
215
|
+
export function replayPendingLlmOutputsForSession(manager, session, options) {
|
|
216
|
+
if (!options.allowPlaceholderRequest) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
for (const [key, records] of [...manager.state.llmOutputsPendingInput]) {
|
|
220
|
+
const remaining = [];
|
|
221
|
+
for (const record of records) {
|
|
222
|
+
if (record.sessionKey !== session.sessionId) {
|
|
223
|
+
remaining.push(record);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
clearPendingTimer(record);
|
|
227
|
+
if (hasTrajectoryReplay(session, record.runId)) {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
replayLlmOutput({
|
|
231
|
+
manager,
|
|
232
|
+
event: record.event,
|
|
233
|
+
ctx: record.ctx,
|
|
234
|
+
input: placeholderInputRecord(record),
|
|
235
|
+
timing: consumeTimingCandidate(manager, session, record.event),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
if (remaining.length === 0) {
|
|
239
|
+
manager.state.llmOutputsPendingInput.delete(key);
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
manager.state.llmOutputsPendingInput.set(key, remaining);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Reconstruct final-run LLM spans from agent_end/message-write data when direct
|
|
248
|
+
* llm_output replay did not already produce the trajectory.
|
|
249
|
+
*/
|
|
250
|
+
export function replayAgentEndMessages(manager, event, ctx, session) {
|
|
251
|
+
const runId = event.runId ?? ctx.runId;
|
|
252
|
+
const runKey = trajectoryRunKey(session, runId);
|
|
253
|
+
const currentRunMessages = currentRunAgentMessages(session, runId, event.messages);
|
|
254
|
+
const finalOutput = finalOutputFromAgentEnd(currentRunMessages, event, runId);
|
|
255
|
+
if (hasTrajectoryReplay(session, runId)) {
|
|
256
|
+
return finalOutput;
|
|
257
|
+
}
|
|
258
|
+
const replayedFromHooks = hookLlmOutputReplayCount(session, runId);
|
|
259
|
+
if (replayedFromHooks > 0) {
|
|
260
|
+
markTrajectoryReplay(session, runKey, manager.config.correlation.maxRecordsPerKey);
|
|
261
|
+
cleanupAgentRunReplayBookkeeping(session, runKey);
|
|
262
|
+
return finalOutput;
|
|
263
|
+
}
|
|
264
|
+
const replayedFromMessageWrites = replayAssistantMessageWrites(manager, session, event, ctx);
|
|
265
|
+
if (replayedFromMessageWrites > 0) {
|
|
266
|
+
markTrajectoryReplay(session, runKey, manager.config.correlation.maxRecordsPerKey);
|
|
267
|
+
cleanupAgentRunReplayBookkeeping(session, runKey);
|
|
268
|
+
return finalOutput;
|
|
269
|
+
}
|
|
270
|
+
cleanupAgentRunReplayBookkeeping(session, runKey);
|
|
271
|
+
return finalOutput;
|
|
272
|
+
}
|
|
273
|
+
/** Emit diagnostic marks for provider-call timing records that could not pair to an LLM span. */
|
|
274
|
+
export function emitUnpairedModelCallTimingMarks(manager, session) {
|
|
275
|
+
for (const records of manager.state.modelCallsByCallId.values()) {
|
|
276
|
+
for (const record of records) {
|
|
277
|
+
if (record.sessionKey !== session.sessionId || record.consumed || record.endedAtMs !== undefined) {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
emitModelTimingMark(manager, session, 'openclaw.model_call_timing_unpaired', record);
|
|
281
|
+
record.consumed = true;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const unpairedEnded = [];
|
|
285
|
+
for (const records of manager.state.modelTimingsByLlmKey.values()) {
|
|
286
|
+
for (const record of records) {
|
|
287
|
+
if (record.sessionKey !== session.sessionId || record.consumed) {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
unpairedEnded.push(record);
|
|
291
|
+
record.consumed = true;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (unpairedEnded.length === 1) {
|
|
295
|
+
const [record] = unpairedEnded;
|
|
296
|
+
if (record) {
|
|
297
|
+
emitModelTimingMark(manager, session, 'openclaw.model_call_timing_unpaired', record);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
else if (unpairedEnded.length > 1) {
|
|
301
|
+
emitModelTimingSummaryMark(manager, session, unpairedEnded);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
/** Build the request payload passed to NeMo Relay for a replayed LLM span. */
|
|
305
|
+
export function buildReplayLlmRequest(input, output, config, source = 'openclaw.hooks') {
|
|
306
|
+
const messages = config.capture.includePrompts && Array.isArray(input.historyMessages)
|
|
307
|
+
? input.historyMessages.map((message) => sanitizePromptMessage(message, config))
|
|
308
|
+
: [];
|
|
309
|
+
const replayMessages = config.capture.includePrompts ? appendPromptIfMissing(messages, input.prompt) : [];
|
|
310
|
+
return toJsonValue({
|
|
311
|
+
headers: {},
|
|
312
|
+
content: {
|
|
313
|
+
provider: output.provider,
|
|
314
|
+
model: output.model,
|
|
315
|
+
prompt: config.capture.includePrompts ? input.prompt : undefined,
|
|
316
|
+
systemPrompt: config.capture.includePrompts ? input.systemPrompt : undefined,
|
|
317
|
+
messages: replayMessages,
|
|
318
|
+
imagesCount: input.imagesCount,
|
|
319
|
+
placeholderRequest: input.placeholderRequest === true,
|
|
320
|
+
source,
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
/** Build the response payload passed to NeMo Relay for a replayed LLM span. */
|
|
325
|
+
export function buildReplayLlmResponse(event, timing, config) {
|
|
326
|
+
const usage = mapUsage(event.usage);
|
|
327
|
+
const assistantToolCallNames = toolCallNames(event.assistantToolCalls);
|
|
328
|
+
return toJsonValue({
|
|
329
|
+
role: 'assistant',
|
|
330
|
+
content: config.capture.includeResponses
|
|
331
|
+
? responseContent(event.assistantTexts, assistantToolCallNames)
|
|
332
|
+
: undefined,
|
|
333
|
+
assistant_texts_count: event.assistantTexts.length,
|
|
334
|
+
resolved_ref: event.resolvedRef,
|
|
335
|
+
harness_id: event.harnessId,
|
|
336
|
+
usage,
|
|
337
|
+
openclaw: {
|
|
338
|
+
duration_ms: timing?.durationMs,
|
|
339
|
+
outcome: timing?.outcome,
|
|
340
|
+
error_category: timing?.errorCategory,
|
|
341
|
+
failure_kind: timing?.failureKind,
|
|
342
|
+
time_to_first_byte_ms: timing?.timeToFirstByteMs,
|
|
343
|
+
request_payload_bytes: timing?.requestPayloadBytes,
|
|
344
|
+
response_stream_bytes: timing?.responseStreamBytes,
|
|
345
|
+
upstream_request_id_hash: timing?.upstreamRequestIdHash,
|
|
346
|
+
assistant_tool_call_names: assistantToolCallNames,
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
/** Replay an output whose matching input never arrived before the grace timeout. */
|
|
351
|
+
function replayExpiredPendingOutput(manager, key, record) {
|
|
352
|
+
try {
|
|
353
|
+
if (!removeRecord(manager.state.llmOutputsPendingInput, key, record)) {
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const session = manager.state.sessions.get(record.sessionKey);
|
|
357
|
+
if (!session) {
|
|
358
|
+
manager.state.counters.skippedEvents += 1;
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (hasTrajectoryReplay(session, record.runId)) {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
replayLlmOutput({
|
|
365
|
+
manager,
|
|
366
|
+
event: record.event,
|
|
367
|
+
ctx: record.ctx,
|
|
368
|
+
input: placeholderInputRecord(record),
|
|
369
|
+
timing: consumeTimingCandidate(manager, session, record.event),
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
manager.state.counters.replayErrors += 1;
|
|
374
|
+
manager.logBoundedWarn(`llm_grace_timer_failed:${key}`, `nemo-relay failed to replay pending llm_output after grace timer: ${error instanceof Error ? error.message : String(error)}`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
/** Emit the actual NeMo Relay LLM span from correlated request, output, and timing data. */
|
|
378
|
+
function replayLlmOutput(params) {
|
|
379
|
+
const { manager, event, ctx, input, timing, source = 'openclaw.llm_output' } = params;
|
|
380
|
+
const observedEndMicros = nowMicros();
|
|
381
|
+
const endMicros = timing?.endedAtMs === undefined ? observedEndMicros : timing.endedAtMs * 1000;
|
|
382
|
+
const observedStartMicros = Math.min(input.observedAtMs * 1000, endMicros);
|
|
383
|
+
const startMicros = timing?.startedAtMs === undefined
|
|
384
|
+
? (startMicrosFromDuration(endMicros, timing?.durationMs) ?? observedStartMicros)
|
|
385
|
+
: Math.min(timing.startedAtMs * 1000, endMicros);
|
|
386
|
+
const session = ensureSession(manager, {
|
|
387
|
+
sessionId: event.sessionId,
|
|
388
|
+
sessionKey: ctx.sessionKey,
|
|
389
|
+
runId: event.runId,
|
|
390
|
+
agentId: ctx.agentId,
|
|
391
|
+
source: 'lazy_session',
|
|
392
|
+
timestamp: startMicros,
|
|
393
|
+
});
|
|
394
|
+
if (!session) {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const request = buildReplayLlmRequest(input, event, manager.config, source);
|
|
398
|
+
const response = buildReplayLlmResponse(event, timing, manager.config);
|
|
399
|
+
const metadata = toJsonRecord({
|
|
400
|
+
source,
|
|
401
|
+
runId: event.runId,
|
|
402
|
+
sessionId: event.sessionId,
|
|
403
|
+
provider: event.provider,
|
|
404
|
+
model: event.model,
|
|
405
|
+
callId: timing?.callId,
|
|
406
|
+
correlation: source === 'openclaw.before_message_write' ? 'fifo_model_call_timing' : undefined,
|
|
407
|
+
});
|
|
408
|
+
manager.emitCapturedUnderSession('llm_output', session, () => {
|
|
409
|
+
const handle = manager.nf.llmCall(event.provider, request, session.rootHandle, null, null, metadata, event.model, startMicros);
|
|
410
|
+
manager.nf.llmCallEnd(handle, response, null, metadata, endMicros);
|
|
411
|
+
manager.state.counters.llmSpansReplayed += 1;
|
|
412
|
+
});
|
|
413
|
+
if (source === 'openclaw.llm_output') {
|
|
414
|
+
incrementHookLlmOutputReplayCount(session, event.runId, manager.config.correlation.maxRecordsPerKey);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
/** Replay assistant message-write records as ordered LLM spans using FIFO timing candidates. */
|
|
418
|
+
function replayAssistantMessageWrites(manager, session, event, ctx) {
|
|
419
|
+
const records = session.assistantMessageWrites ?? [];
|
|
420
|
+
const pending = records.filter((record) => !record.replayed);
|
|
421
|
+
let replayed = 0;
|
|
422
|
+
for (const record of pending) {
|
|
423
|
+
const timing = consumeNextTimingCandidate(manager, session, {
|
|
424
|
+
runId: event.runId ?? ctx.runId,
|
|
425
|
+
provider: record.provider,
|
|
426
|
+
model: record.model,
|
|
427
|
+
});
|
|
428
|
+
if (!timing) {
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
const runId = timing.runId || event.runId || ctx.runId || session.sessionId;
|
|
432
|
+
const usage = mapHookUsage(record.usage);
|
|
433
|
+
replayLlmOutput({
|
|
434
|
+
manager,
|
|
435
|
+
event: {
|
|
436
|
+
runId,
|
|
437
|
+
sessionId: session.sessionId,
|
|
438
|
+
provider: record.provider,
|
|
439
|
+
model: record.model,
|
|
440
|
+
assistantTexts: record.assistantTexts,
|
|
441
|
+
assistantToolCalls: record.assistantToolCalls,
|
|
442
|
+
...(usage === undefined ? {} : { usage }),
|
|
443
|
+
},
|
|
444
|
+
ctx,
|
|
445
|
+
input: {
|
|
446
|
+
sessionKey: session.sessionId,
|
|
447
|
+
sessionId: session.sessionId,
|
|
448
|
+
runId,
|
|
449
|
+
provider: record.provider,
|
|
450
|
+
model: record.model,
|
|
451
|
+
prompt: record.prompt,
|
|
452
|
+
historyMessages: record.historyMessages,
|
|
453
|
+
imagesCount: 0,
|
|
454
|
+
observedAtMs: record.observedAtMs,
|
|
455
|
+
placeholderRequest: true,
|
|
456
|
+
},
|
|
457
|
+
timing,
|
|
458
|
+
source: 'openclaw.before_message_write',
|
|
459
|
+
});
|
|
460
|
+
record.replayed = true;
|
|
461
|
+
replayed += 1;
|
|
462
|
+
}
|
|
463
|
+
session.assistantMessageWrites = [];
|
|
464
|
+
return replayed;
|
|
465
|
+
}
|
|
466
|
+
/** Consume the next unpaired timing record for message-write trajectory replay. */
|
|
467
|
+
function consumeNextTimingCandidate(manager, session, input) {
|
|
468
|
+
const key = modelTimingLlmKey({
|
|
469
|
+
sessionId: session.sessionId,
|
|
470
|
+
runId: input.runId,
|
|
471
|
+
provider: input.provider,
|
|
472
|
+
model: input.model,
|
|
473
|
+
});
|
|
474
|
+
const records = manager.state.modelTimingsByLlmKey.get(key) ?? [];
|
|
475
|
+
const candidate = records.find((record) => record.sessionKey === session.sessionId && !record.consumed);
|
|
476
|
+
if (!candidate) {
|
|
477
|
+
return undefined;
|
|
478
|
+
}
|
|
479
|
+
candidate.consumed = true;
|
|
480
|
+
return candidate;
|
|
481
|
+
}
|
|
482
|
+
/** Consume a timing candidate only when the public hook data makes it unambiguous. */
|
|
483
|
+
function consumeTimingCandidate(manager, session, event) {
|
|
484
|
+
const key = modelTimingLlmKey({
|
|
485
|
+
sessionId: session.sessionId,
|
|
486
|
+
runId: event.runId,
|
|
487
|
+
provider: event.provider,
|
|
488
|
+
model: event.model,
|
|
489
|
+
});
|
|
490
|
+
const candidates = (manager.state.modelTimingsByLlmKey.get(key) ?? []).filter((record) => record.sessionKey === session.sessionId && !record.consumed);
|
|
491
|
+
if (candidates.length === 1) {
|
|
492
|
+
const candidate = candidates[0];
|
|
493
|
+
if (!candidate) {
|
|
494
|
+
return undefined;
|
|
495
|
+
}
|
|
496
|
+
candidate.consumed = true;
|
|
497
|
+
return candidate;
|
|
498
|
+
}
|
|
499
|
+
if (candidates.length > 1) {
|
|
500
|
+
const shouldEmit = candidates.some((candidate) => candidate.ambiguous !== true);
|
|
501
|
+
for (const candidate of candidates) {
|
|
502
|
+
candidate.ambiguous = true;
|
|
503
|
+
}
|
|
504
|
+
if (shouldEmit) {
|
|
505
|
+
emitModelTimingAmbiguousMark(manager, session, event, candidates.length);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return undefined;
|
|
509
|
+
}
|
|
510
|
+
/** Mark a timing match as ambiguous instead of attaching possibly wrong latency. */
|
|
511
|
+
function emitModelTimingAmbiguousMark(manager, session, event, candidateCount) {
|
|
512
|
+
manager.emitCapturedUnderSession('model_call_timing_ambiguous', session, () => {
|
|
513
|
+
emitMark({
|
|
514
|
+
nf: manager.nf,
|
|
515
|
+
state: manager.state,
|
|
516
|
+
session,
|
|
517
|
+
name: 'openclaw.model_call_timing_ambiguous',
|
|
518
|
+
data: toJsonRecord({
|
|
519
|
+
runId: event.runId,
|
|
520
|
+
sessionId: event.sessionId,
|
|
521
|
+
provider: event.provider,
|
|
522
|
+
model: event.model,
|
|
523
|
+
candidateCount,
|
|
524
|
+
}),
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
/** Emit one unpaired timing diagnostic mark. */
|
|
529
|
+
function emitModelTimingMark(manager, session, name, record) {
|
|
530
|
+
manager.emitCapturedUnderSession(name, session, () => {
|
|
531
|
+
emitMark({
|
|
532
|
+
nf: manager.nf,
|
|
533
|
+
state: manager.state,
|
|
534
|
+
session,
|
|
535
|
+
name,
|
|
536
|
+
data: toJsonRecord({
|
|
537
|
+
runId: record.runId,
|
|
538
|
+
callId: record.callId,
|
|
539
|
+
provider: record.provider,
|
|
540
|
+
model: record.model,
|
|
541
|
+
api: record.api,
|
|
542
|
+
transport: record.transport,
|
|
543
|
+
durationMs: record.durationMs,
|
|
544
|
+
outcome: record.outcome,
|
|
545
|
+
errorCategory: record.errorCategory,
|
|
546
|
+
failureKind: record.failureKind,
|
|
547
|
+
requestPayloadBytes: record.requestPayloadBytes,
|
|
548
|
+
responseStreamBytes: record.responseStreamBytes,
|
|
549
|
+
timeToFirstByteMs: record.timeToFirstByteMs,
|
|
550
|
+
upstreamRequestIdHash: record.upstreamRequestIdHash,
|
|
551
|
+
ambiguous: record.ambiguous,
|
|
552
|
+
}),
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
/** Emit a compact summary when multiple timing records cannot be paired safely. */
|
|
557
|
+
function emitModelTimingSummaryMark(manager, session, records) {
|
|
558
|
+
manager.emitCapturedUnderSession('model_call_timing_unmatched', session, () => {
|
|
559
|
+
emitMark({
|
|
560
|
+
nf: manager.nf,
|
|
561
|
+
state: manager.state,
|
|
562
|
+
session,
|
|
563
|
+
name: 'openclaw.model_call_timing_unmatched',
|
|
564
|
+
data: toJsonRecord({
|
|
565
|
+
count: records.length,
|
|
566
|
+
sampleCallIds: records.slice(0, 5).map((record) => record.callId),
|
|
567
|
+
}),
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
/** Convert an OpenClaw llm_input event into the buffered request record. */
|
|
572
|
+
function createInputRecord(session, event) {
|
|
573
|
+
return {
|
|
574
|
+
sessionKey: session.sessionId,
|
|
575
|
+
sessionId: event.sessionId,
|
|
576
|
+
runId: event.runId,
|
|
577
|
+
provider: event.provider,
|
|
578
|
+
model: event.model,
|
|
579
|
+
prompt: event.prompt,
|
|
580
|
+
historyMessages: snapshotMessages(event.historyMessages),
|
|
581
|
+
imagesCount: event.imagesCount,
|
|
582
|
+
observedAtMs: Date.now(),
|
|
583
|
+
...(event.systemPrompt === undefined ? {} : { systemPrompt: event.systemPrompt }),
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
/** Resolve an existing session for before_message_write without creating a fake one. */
|
|
587
|
+
function existingSessionForMessageWrite(manager, event, ctx) {
|
|
588
|
+
const key = resolveSessionKey(manager.state, {
|
|
589
|
+
sessionKey: event.sessionKey ?? ctx.sessionKey,
|
|
590
|
+
});
|
|
591
|
+
return key === undefined ? undefined : manager.state.sessions.get(key);
|
|
592
|
+
}
|
|
593
|
+
/** Build a minimal request placeholder when only an llm_output hook is available. */
|
|
594
|
+
function placeholderInputRecord(record) {
|
|
595
|
+
return {
|
|
596
|
+
sessionKey: record.sessionKey,
|
|
597
|
+
sessionId: record.sessionId,
|
|
598
|
+
runId: record.runId,
|
|
599
|
+
provider: record.provider,
|
|
600
|
+
model: record.model,
|
|
601
|
+
prompt: '',
|
|
602
|
+
historyMessages: [],
|
|
603
|
+
imagesCount: 0,
|
|
604
|
+
observedAtMs: Date.now(),
|
|
605
|
+
placeholderRequest: true,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
/** Append the current user prompt unless the history snapshot already ends with it. */
|
|
609
|
+
function appendPromptIfMissing(historyMessages, prompt) {
|
|
610
|
+
if (!prompt) {
|
|
611
|
+
return historyMessages;
|
|
612
|
+
}
|
|
613
|
+
const last = historyMessages.at(-1);
|
|
614
|
+
if (isRecord(last) && last.role === 'user' && extractTextBlocks(last).join('\n') === prompt) {
|
|
615
|
+
return historyMessages;
|
|
616
|
+
}
|
|
617
|
+
return [...historyMessages, { role: 'user', content: prompt }];
|
|
618
|
+
}
|
|
619
|
+
/** Apply prompt-capture privacy settings to one historical message. */
|
|
620
|
+
function sanitizePromptMessage(message, config) {
|
|
621
|
+
if (!isRecord(message)) {
|
|
622
|
+
return message;
|
|
623
|
+
}
|
|
624
|
+
let sanitized = { ...message };
|
|
625
|
+
if ((sanitized.role === 'tool' || sanitized.role === 'toolResult') && config.capture.stripToolResults) {
|
|
626
|
+
sanitized = { ...sanitized, content: { stripped: true } };
|
|
627
|
+
}
|
|
628
|
+
if (sanitized.role === 'assistant' && config.capture.stripToolArgs) {
|
|
629
|
+
sanitized = stripAssistantToolArgs(sanitized);
|
|
630
|
+
}
|
|
631
|
+
else if (Array.isArray(sanitized.content)) {
|
|
632
|
+
sanitized = { ...sanitized, content: sanitized.content.map(stripLargeAssistantContentFields) };
|
|
633
|
+
}
|
|
634
|
+
return sanitized;
|
|
635
|
+
}
|
|
636
|
+
/** Strip tool call arguments from assistant messages while preserving call names. */
|
|
637
|
+
function stripAssistantToolArgs(message) {
|
|
638
|
+
const stripped = { ...message };
|
|
639
|
+
if (Array.isArray(stripped.toolCalls)) {
|
|
640
|
+
stripped.toolCalls = stripped.toolCalls.map(stripToolCallArgs);
|
|
641
|
+
}
|
|
642
|
+
if (Array.isArray(stripped.tool_calls)) {
|
|
643
|
+
stripped.tool_calls = stripped.tool_calls.map(stripToolCallArgs);
|
|
644
|
+
}
|
|
645
|
+
if (Array.isArray(stripped.content)) {
|
|
646
|
+
stripped.content = stripped.content.map((item) => isToolCallLike(item) ? stripToolCallArgs(item) : stripLargeAssistantContentFields(item));
|
|
647
|
+
}
|
|
648
|
+
return stripped;
|
|
649
|
+
}
|
|
650
|
+
/** Replace large or sensitive tool argument fields with a stripped marker. */
|
|
651
|
+
function stripToolCallArgs(value) {
|
|
652
|
+
if (!isRecord(value)) {
|
|
653
|
+
return value;
|
|
654
|
+
}
|
|
655
|
+
const stripped = { ...value };
|
|
656
|
+
for (const key of ['args', 'arguments', 'input', 'params']) {
|
|
657
|
+
if (stripped[key] !== undefined) {
|
|
658
|
+
stripped[key] = { stripped: true };
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
return stripped;
|
|
662
|
+
}
|
|
663
|
+
/** Strip provider thinking/signature payloads that are noisy in trace UIs. */
|
|
664
|
+
function stripLargeAssistantContentFields(value) {
|
|
665
|
+
if (!isRecord(value)) {
|
|
666
|
+
return value;
|
|
667
|
+
}
|
|
668
|
+
if (value.type === 'thinking') {
|
|
669
|
+
return { type: 'thinking', stripped: true };
|
|
670
|
+
}
|
|
671
|
+
const stripped = { ...value };
|
|
672
|
+
if (stripped.thinking !== undefined) {
|
|
673
|
+
stripped.thinking = { stripped: true };
|
|
674
|
+
}
|
|
675
|
+
if (stripped.thinkingSignature !== undefined) {
|
|
676
|
+
stripped.thinkingSignature = { stripped: true };
|
|
677
|
+
}
|
|
678
|
+
return stripped;
|
|
679
|
+
}
|
|
680
|
+
/** Choose the user-visible LLM output text, falling back to tool-call names. */
|
|
681
|
+
function responseContent(assistantTexts, assistantToolCallNames) {
|
|
682
|
+
const text = assistantTexts.join('\n').trim();
|
|
683
|
+
if (text.length > 0) {
|
|
684
|
+
return text;
|
|
685
|
+
}
|
|
686
|
+
if (assistantToolCallNames.length > 0) {
|
|
687
|
+
return `tool calls: ${assistantToolCallNames.join(', ')}`;
|
|
688
|
+
}
|
|
689
|
+
return undefined;
|
|
690
|
+
}
|
|
691
|
+
/** Return the last assistant text in a message trajectory. */
|
|
692
|
+
function lastAssistantText(messages) {
|
|
693
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
694
|
+
const message = messages[index];
|
|
695
|
+
if (!isRecord(message) || message.role !== 'assistant') {
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
const text = extractTextBlocks(message).join('\n').trim();
|
|
699
|
+
if (text.length > 0) {
|
|
700
|
+
return text;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
return undefined;
|
|
704
|
+
}
|
|
705
|
+
/** Build the final root-span output from agent_end messages without using shutdown reasons. */
|
|
706
|
+
function finalOutputFromAgentEnd(messages, event, runId) {
|
|
707
|
+
const lastText = lastAssistantText(messages);
|
|
708
|
+
if (lastText) {
|
|
709
|
+
return toJsonRecord({
|
|
710
|
+
content: lastText,
|
|
711
|
+
source: 'openclaw.agent_end',
|
|
712
|
+
runId,
|
|
713
|
+
success: event.success,
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
if (event.error) {
|
|
717
|
+
return toJsonRecord({
|
|
718
|
+
source: 'openclaw.agent_end',
|
|
719
|
+
runId,
|
|
720
|
+
success: event.success,
|
|
721
|
+
error: event.error,
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
return undefined;
|
|
725
|
+
}
|
|
726
|
+
/** Extract textual content blocks from OpenClaw/OpenAI/Anthropic-like messages. */
|
|
727
|
+
function extractTextBlocks(message) {
|
|
728
|
+
const content = message.content;
|
|
729
|
+
if (typeof content === 'string' && content.length > 0) {
|
|
730
|
+
return [content];
|
|
731
|
+
}
|
|
732
|
+
if (!Array.isArray(content)) {
|
|
733
|
+
return [];
|
|
734
|
+
}
|
|
735
|
+
const texts = [];
|
|
736
|
+
for (const item of content) {
|
|
737
|
+
if (typeof item === 'string') {
|
|
738
|
+
texts.push(item);
|
|
739
|
+
}
|
|
740
|
+
else if (isRecord(item) && typeof item.text === 'string') {
|
|
741
|
+
texts.push(item.text);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
return texts;
|
|
745
|
+
}
|
|
746
|
+
/** Extract assistant tool calls from common OpenClaw/OpenAI/Anthropic message shapes. */
|
|
747
|
+
function extractToolCalls(message) {
|
|
748
|
+
if (Array.isArray(message.toolCalls)) {
|
|
749
|
+
return message.toolCalls;
|
|
750
|
+
}
|
|
751
|
+
if (Array.isArray(message.tool_calls)) {
|
|
752
|
+
return message.tool_calls;
|
|
753
|
+
}
|
|
754
|
+
const content = message.content;
|
|
755
|
+
if (!Array.isArray(content)) {
|
|
756
|
+
return [];
|
|
757
|
+
}
|
|
758
|
+
return content.filter((item) => isToolCallLike(item));
|
|
759
|
+
}
|
|
760
|
+
/** Identify likely tool-call content blocks across provider-specific shapes. */
|
|
761
|
+
function isToolCallLike(value) {
|
|
762
|
+
return (isRecord(value) &&
|
|
763
|
+
(value.type === 'toolCall' ||
|
|
764
|
+
value.type === 'tool_use' ||
|
|
765
|
+
value.type === 'tool-call' ||
|
|
766
|
+
value.toolName !== undefined ||
|
|
767
|
+
value.name !== undefined));
|
|
768
|
+
}
|
|
769
|
+
/** Return display names for assistant tool calls without exposing arguments. */
|
|
770
|
+
function toolCallNames(toolCalls) {
|
|
771
|
+
if (!Array.isArray(toolCalls)) {
|
|
772
|
+
return [];
|
|
773
|
+
}
|
|
774
|
+
const names = [];
|
|
775
|
+
for (const toolCall of toolCalls) {
|
|
776
|
+
if (!isRecord(toolCall)) {
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
const name = stringField(toolCall, 'name') ?? stringField(toolCall, 'toolName') ?? stringField(toolCall, 'functionName');
|
|
780
|
+
if (name) {
|
|
781
|
+
names.push(name);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
return names;
|
|
785
|
+
}
|
|
786
|
+
/** Convert stored message-write usage back into the llm_output usage contract. */
|
|
787
|
+
function mapHookUsage(usage) {
|
|
788
|
+
const mapped = mapUsage(usage);
|
|
789
|
+
if (!mapped) {
|
|
790
|
+
return undefined;
|
|
791
|
+
}
|
|
792
|
+
const hookUsage = {};
|
|
793
|
+
if (mapped.prompt_tokens !== undefined) {
|
|
794
|
+
hookUsage.input = mapped.prompt_tokens;
|
|
795
|
+
}
|
|
796
|
+
if (mapped.completion_tokens !== undefined) {
|
|
797
|
+
hookUsage.output = mapped.completion_tokens;
|
|
798
|
+
}
|
|
799
|
+
if (mapped.cache_read_tokens !== undefined) {
|
|
800
|
+
hookUsage.cacheRead = mapped.cache_read_tokens;
|
|
801
|
+
}
|
|
802
|
+
if (mapped.cache_write_tokens !== undefined) {
|
|
803
|
+
hookUsage.cacheWrite = mapped.cache_write_tokens;
|
|
804
|
+
}
|
|
805
|
+
if (mapped.total_tokens !== undefined) {
|
|
806
|
+
hookUsage.total = mapped.total_tokens;
|
|
807
|
+
}
|
|
808
|
+
if (mapped.cost_usd !== undefined) {
|
|
809
|
+
hookUsage.cost = { total: mapped.cost_usd };
|
|
810
|
+
}
|
|
811
|
+
return Object.keys(hookUsage).length > 0 ? hookUsage : undefined;
|
|
812
|
+
}
|
|
813
|
+
/** Report whether this run already has a replayed LLM trajectory. */
|
|
814
|
+
function hasTrajectoryReplay(session, runId) {
|
|
815
|
+
return session.trajectoryReplayedRuns?.has(trajectoryRunKey(session, runId)) === true;
|
|
816
|
+
}
|
|
817
|
+
/** Remember the latest llm_input snapshot so message-write replay can include context. */
|
|
818
|
+
function rememberAgentRunInputSnapshot(session, runId, historyMessages, prompt, maxSnapshots) {
|
|
819
|
+
const runKey = trajectoryRunKey(session, runId);
|
|
820
|
+
session.agentRunInputSnapshots ??= new Map();
|
|
821
|
+
if (!session.agentRunInputSnapshots.has(runKey)) {
|
|
822
|
+
session.agentRunInputSnapshots.set(runKey, {
|
|
823
|
+
historyMessageCount: historyMessages.length,
|
|
824
|
+
historyMessages: snapshotMessages(historyMessages),
|
|
825
|
+
observedAtMs: Date.now(),
|
|
826
|
+
prompt,
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
while (session.agentRunInputSnapshots.size > maxSnapshots) {
|
|
830
|
+
const oldest = session.agentRunInputSnapshots.keys().next().value;
|
|
831
|
+
if (oldest === undefined) {
|
|
832
|
+
break;
|
|
833
|
+
}
|
|
834
|
+
session.agentRunInputSnapshots.delete(oldest);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
/** Snapshot messages into JSON-compatible values before storing them for later replay. */
|
|
838
|
+
function snapshotMessages(messages) {
|
|
839
|
+
const snapshot = toJsonValue(messages);
|
|
840
|
+
return Array.isArray(snapshot) ? snapshot : [];
|
|
841
|
+
}
|
|
842
|
+
/** Return the most recent input history snapshot, including the current prompt if needed. */
|
|
843
|
+
function initialHistoryFromLlmInputSnapshot(session) {
|
|
844
|
+
let snapshot;
|
|
845
|
+
for (const current of session.agentRunInputSnapshots?.values() ?? []) {
|
|
846
|
+
if (!snapshot || current.observedAtMs > snapshot.observedAtMs) {
|
|
847
|
+
snapshot = current;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
if (!snapshot) {
|
|
851
|
+
return [];
|
|
852
|
+
}
|
|
853
|
+
return appendPromptIfMissing([...snapshot.historyMessages], snapshot.prompt);
|
|
854
|
+
}
|
|
855
|
+
/** Trim agent_end messages to only the current run's trajectory when a snapshot exists. */
|
|
856
|
+
function currentRunAgentMessages(session, runId, messages) {
|
|
857
|
+
const inputSnapshot = session.agentRunInputSnapshots?.get(trajectoryRunKey(session, runId));
|
|
858
|
+
if (!inputSnapshot || inputSnapshot.historyMessageCount <= 0) {
|
|
859
|
+
return messages;
|
|
860
|
+
}
|
|
861
|
+
if (inputSnapshot.historyMessageCount <= messages.length) {
|
|
862
|
+
return messages.slice(inputSnapshot.historyMessageCount);
|
|
863
|
+
}
|
|
864
|
+
const promptIndex = findCurrentPromptIndex(messages, inputSnapshot.prompt);
|
|
865
|
+
return promptIndex === undefined ? [] : messages.slice(promptIndex);
|
|
866
|
+
}
|
|
867
|
+
/** Find the current prompt in a full message transcript. */
|
|
868
|
+
function findCurrentPromptIndex(messages, prompt) {
|
|
869
|
+
if (!prompt) {
|
|
870
|
+
return undefined;
|
|
871
|
+
}
|
|
872
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
873
|
+
const message = messages[index];
|
|
874
|
+
if (!isRecord(message) || message.role !== 'user') {
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
877
|
+
if (extractTextBlocks(message).join('\n') === prompt) {
|
|
878
|
+
return index;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return undefined;
|
|
882
|
+
}
|
|
883
|
+
/** Drop per-run replay bookkeeping once that run no longer needs correlation. */
|
|
884
|
+
function cleanupAgentRunReplayBookkeeping(session, runKey) {
|
|
885
|
+
session.agentRunInputSnapshots?.delete(runKey);
|
|
886
|
+
session.hookLlmOutputReplayCounts?.delete(runKey);
|
|
887
|
+
}
|
|
888
|
+
/** Mark a run as trajectory-replayed while bounding long-lived session memory. */
|
|
889
|
+
function markTrajectoryReplay(session, runKey, maxRuns) {
|
|
890
|
+
session.trajectoryReplayedRuns ??= new Set();
|
|
891
|
+
session.trajectoryReplayedRuns.delete(runKey);
|
|
892
|
+
session.trajectoryReplayedRuns.add(runKey);
|
|
893
|
+
while (session.trajectoryReplayedRuns.size > maxRuns) {
|
|
894
|
+
const oldest = session.trajectoryReplayedRuns.values().next().value;
|
|
895
|
+
if (oldest === undefined) {
|
|
896
|
+
break;
|
|
897
|
+
}
|
|
898
|
+
session.trajectoryReplayedRuns.delete(oldest);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
/** Return how many llm_output hooks have already replayed for this run. */
|
|
902
|
+
function hookLlmOutputReplayCount(session, runId) {
|
|
903
|
+
return session.hookLlmOutputReplayCounts?.get(trajectoryRunKey(session, runId)) ?? 0;
|
|
904
|
+
}
|
|
905
|
+
/** Increment direct llm_output replay count and keep the count map bounded. */
|
|
906
|
+
function incrementHookLlmOutputReplayCount(session, runId, maxRuns) {
|
|
907
|
+
const runKey = trajectoryRunKey(session, runId);
|
|
908
|
+
session.hookLlmOutputReplayCounts ??= new Map();
|
|
909
|
+
const nextCount = hookLlmOutputReplayCount(session, runId) + 1;
|
|
910
|
+
session.hookLlmOutputReplayCounts.delete(runKey);
|
|
911
|
+
session.hookLlmOutputReplayCounts.set(runKey, nextCount);
|
|
912
|
+
while (session.hookLlmOutputReplayCounts.size > maxRuns) {
|
|
913
|
+
const oldest = session.hookLlmOutputReplayCounts.keys().next().value;
|
|
914
|
+
if (oldest === undefined) {
|
|
915
|
+
break;
|
|
916
|
+
}
|
|
917
|
+
session.hookLlmOutputReplayCounts.delete(oldest);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
/** Build the per-session run key used for trajectory de-duplication. */
|
|
921
|
+
function trajectoryRunKey(session, runId) {
|
|
922
|
+
return runId ?? session.sessionId;
|
|
923
|
+
}
|
|
924
|
+
/** Normalize provider usage into OpenInference-friendly token/cost fields. */
|
|
925
|
+
function mapUsage(usage) {
|
|
926
|
+
if (!isRecord(usage)) {
|
|
927
|
+
return undefined;
|
|
928
|
+
}
|
|
929
|
+
const mapped = {};
|
|
930
|
+
const input = numberField(usage, 'input') ?? numberField(usage, 'prompt_tokens');
|
|
931
|
+
const output = numberField(usage, 'output') ?? numberField(usage, 'completion_tokens');
|
|
932
|
+
const cacheRead = numberField(usage, 'cacheRead') ?? numberField(usage, 'cache_read_tokens');
|
|
933
|
+
const cacheWrite = numberField(usage, 'cacheWrite') ?? numberField(usage, 'cache_write_tokens');
|
|
934
|
+
const total = numberField(usage, 'total') ?? numberField(usage, 'totalTokens') ?? numberField(usage, 'total_tokens');
|
|
935
|
+
const totalCanIncludeCompletion = total === undefined || output === undefined || total >= output;
|
|
936
|
+
const prompt = total !== undefined && output !== undefined && totalCanIncludeCompletion ? total - output : input;
|
|
937
|
+
const totalCanIncludePrompt = total === undefined || prompt === undefined || total >= prompt;
|
|
938
|
+
const normalizedTotal = totalCanIncludeCompletion && totalCanIncludePrompt ? total : undefined;
|
|
939
|
+
const costTotal = isRecord(usage.cost) ? numberField(usage.cost, 'total') : numberField(usage, 'cost_usd');
|
|
940
|
+
if (prompt !== undefined) {
|
|
941
|
+
mapped.prompt_tokens = prompt;
|
|
942
|
+
}
|
|
943
|
+
if (output !== undefined) {
|
|
944
|
+
mapped.completion_tokens = output;
|
|
945
|
+
}
|
|
946
|
+
if (cacheRead !== undefined) {
|
|
947
|
+
mapped.cached_tokens = cacheRead;
|
|
948
|
+
mapped.cache_read_tokens = cacheRead;
|
|
949
|
+
}
|
|
950
|
+
if (cacheWrite !== undefined) {
|
|
951
|
+
mapped.cache_write_tokens = cacheWrite;
|
|
952
|
+
}
|
|
953
|
+
if (normalizedTotal !== undefined) {
|
|
954
|
+
mapped.total_tokens = normalizedTotal;
|
|
955
|
+
}
|
|
956
|
+
else if (total === undefined && (prompt !== undefined || output !== undefined)) {
|
|
957
|
+
mapped.total_tokens = (prompt ?? 0) + (output ?? 0);
|
|
958
|
+
}
|
|
959
|
+
if (costTotal !== undefined) {
|
|
960
|
+
mapped.cost_usd = costTotal;
|
|
961
|
+
}
|
|
962
|
+
return Object.keys(mapped).length > 0 ? mapped : undefined;
|
|
963
|
+
}
|
|
964
|
+
/** Read a non-empty string field from a generic hook record. */
|
|
965
|
+
function stringField(record, key) {
|
|
966
|
+
const value = record[key];
|
|
967
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
968
|
+
}
|
|
969
|
+
/** Read a finite numeric field from a generic hook record. */
|
|
970
|
+
function numberField(record, key) {
|
|
971
|
+
const value = record[key];
|
|
972
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
973
|
+
}
|
|
974
|
+
/** Copy model_call_ended details into a retained timing record. */
|
|
975
|
+
function applyModelCallEnd(record, event, nowMs) {
|
|
976
|
+
record.observedAtMs = nowMs;
|
|
977
|
+
record.endedAtMs = nowMs;
|
|
978
|
+
record.durationMs = event.durationMs;
|
|
979
|
+
record.outcome = event.outcome;
|
|
980
|
+
record.api = event.api;
|
|
981
|
+
record.transport = event.transport;
|
|
982
|
+
record.errorCategory = event.errorCategory;
|
|
983
|
+
record.failureKind = event.failureKind;
|
|
984
|
+
record.requestPayloadBytes = event.requestPayloadBytes;
|
|
985
|
+
record.responseStreamBytes = event.responseStreamBytes;
|
|
986
|
+
record.timeToFirstByteMs = event.timeToFirstByteMs;
|
|
987
|
+
record.upstreamRequestIdHash = event.upstreamRequestIdHash;
|
|
988
|
+
}
|
|
989
|
+
/** Find the newest started-but-not-ended timing record for a session. */
|
|
990
|
+
function latestUnendedRecord(records, session) {
|
|
991
|
+
if (!records) {
|
|
992
|
+
return undefined;
|
|
993
|
+
}
|
|
994
|
+
for (let index = records.length - 1; index >= 0; index -= 1) {
|
|
995
|
+
const record = records[index];
|
|
996
|
+
if (record?.sessionKey === session.sessionId && record.endedAtMs === undefined) {
|
|
997
|
+
return record;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
return undefined;
|
|
1001
|
+
}
|
|
1002
|
+
/** Insert a pending output and clear timers for any evicted records. */
|
|
1003
|
+
function insertPendingOutput(manager, key, record) {
|
|
1004
|
+
const records = manager.state.llmOutputsPendingInput.get(key) ?? [];
|
|
1005
|
+
records.push(record);
|
|
1006
|
+
while (records.length > manager.config.correlation.maxRecordsPerKey) {
|
|
1007
|
+
const evicted = records.shift();
|
|
1008
|
+
if (evicted) {
|
|
1009
|
+
clearPendingTimer(evicted);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
manager.state.llmOutputsPendingInput.set(key, records);
|
|
1013
|
+
}
|
|
1014
|
+
/** Remove and return the first record matching a predicate from a keyed map. */
|
|
1015
|
+
function shiftOldest(map, key, predicate) {
|
|
1016
|
+
const records = map.get(key);
|
|
1017
|
+
if (!records) {
|
|
1018
|
+
return undefined;
|
|
1019
|
+
}
|
|
1020
|
+
const index = records.findIndex(predicate);
|
|
1021
|
+
if (index === -1) {
|
|
1022
|
+
return undefined;
|
|
1023
|
+
}
|
|
1024
|
+
const [record] = records.splice(index, 1);
|
|
1025
|
+
if (records.length === 0) {
|
|
1026
|
+
map.delete(key);
|
|
1027
|
+
}
|
|
1028
|
+
return record;
|
|
1029
|
+
}
|
|
1030
|
+
/** Remove a specific record object from a keyed map. */
|
|
1031
|
+
function removeRecord(map, key, record) {
|
|
1032
|
+
const records = map.get(key);
|
|
1033
|
+
if (!records) {
|
|
1034
|
+
return false;
|
|
1035
|
+
}
|
|
1036
|
+
const index = records.indexOf(record);
|
|
1037
|
+
if (index === -1) {
|
|
1038
|
+
return false;
|
|
1039
|
+
}
|
|
1040
|
+
records.splice(index, 1);
|
|
1041
|
+
if (records.length === 0) {
|
|
1042
|
+
map.delete(key);
|
|
1043
|
+
}
|
|
1044
|
+
return true;
|
|
1045
|
+
}
|
|
1046
|
+
/** Clear the grace timer owned by a pending llm_output record. */
|
|
1047
|
+
function clearPendingTimer(record) {
|
|
1048
|
+
if (record.timer) {
|
|
1049
|
+
clearTimeout(record.timer);
|
|
1050
|
+
record.timer = undefined;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
/** Evict stale replay state before accepting another hook event. */
|
|
1054
|
+
function evictExpiredReplayRecords(manager) {
|
|
1055
|
+
evictExpiredCorrelationRecords(manager.state, Date.now(), manager.config.correlation.recordTtlMs);
|
|
1056
|
+
}
|
|
1057
|
+
/** Narrow unknown values to plain records for payload traversal. */
|
|
1058
|
+
function isRecord(value) {
|
|
1059
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
1060
|
+
}
|