@senpi/runtime 3.0.6-dev.runtime-phase-2.20260630134327 → 3.0.6-dev.runtime-phase-2.20260630134410

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.
Files changed (102) hide show
  1. package/dist/actions/llm-decision/anthropic-llm-decision.d.ts +5 -9
  2. package/dist/actions/llm-decision/anthropic-llm-decision.d.ts.map +1 -1
  3. package/dist/actions/llm-decision/anthropic-llm-decision.js +6 -86
  4. package/dist/actions/llm-decision/anthropic-llm-decision.js.map +1 -1
  5. package/dist/actions/llm-decision/factory.d.ts +0 -6
  6. package/dist/actions/llm-decision/factory.d.ts.map +1 -1
  7. package/dist/actions/llm-decision/factory.js +0 -2
  8. package/dist/actions/llm-decision/factory.js.map +1 -1
  9. package/dist/actions/llm-decision/openclaw-llm-decision.d.ts.map +1 -1
  10. package/dist/actions/llm-decision/openclaw-llm-decision.js +20 -0
  11. package/dist/actions/llm-decision/openclaw-llm-decision.js.map +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +23 -11
  14. package/dist/index.js.map +1 -1
  15. package/dist/recording/wiring/emitter-factory.d.ts +0 -5
  16. package/dist/recording/wiring/emitter-factory.d.ts.map +1 -1
  17. package/dist/recording/wiring/emitter-factory.js +0 -18
  18. package/dist/recording/wiring/emitter-factory.js.map +1 -1
  19. package/dist/recording/wiring/index.d.ts +1 -1
  20. package/dist/recording/wiring/index.d.ts.map +1 -1
  21. package/dist/recording/wiring/index.js +1 -1
  22. package/dist/recording/wiring/index.js.map +1 -1
  23. package/dist/runtime/runtime-schema.d.ts +4 -53
  24. package/dist/runtime/runtime-schema.d.ts.map +1 -1
  25. package/dist/runtime/runtime-schema.js +4 -4
  26. package/dist/runtime/runtime-schema.js.map +1 -1
  27. package/dist/runtime/runtime.d.ts.map +1 -1
  28. package/dist/runtime/runtime.js +9 -15
  29. package/dist/runtime/runtime.js.map +1 -1
  30. package/dist/senpi/client.d.ts +10 -0
  31. package/dist/senpi/client.d.ts.map +1 -1
  32. package/dist/senpi/client.js +69 -49
  33. package/dist/senpi/client.js.map +1 -1
  34. package/dist/telemetry/metrics.d.ts +47 -0
  35. package/dist/telemetry/metrics.d.ts.map +1 -0
  36. package/dist/telemetry/metrics.js +154 -0
  37. package/dist/telemetry/metrics.js.map +1 -0
  38. package/dist/telemetry/otel-sdk.d.ts +21 -33
  39. package/dist/telemetry/otel-sdk.d.ts.map +1 -1
  40. package/dist/telemetry/otel-sdk.js +77 -50
  41. package/dist/telemetry/otel-sdk.js.map +1 -1
  42. package/dist/telemetry/tracing.d.ts +2 -1
  43. package/dist/telemetry/tracing.d.ts.map +1 -1
  44. package/dist/telemetry/tracing.js +1 -1
  45. package/dist/telemetry/tracing.js.map +1 -1
  46. package/dist/tracking/spans/constants.d.ts +4 -11
  47. package/dist/tracking/spans/constants.d.ts.map +1 -1
  48. package/dist/tracking/spans/constants.js +4 -23
  49. package/dist/tracking/spans/constants.js.map +1 -1
  50. package/dist/tracking/spans/gen-ai.d.ts +70 -0
  51. package/dist/tracking/spans/gen-ai.d.ts.map +1 -0
  52. package/dist/tracking/spans/gen-ai.js +330 -0
  53. package/dist/tracking/spans/gen-ai.js.map +1 -0
  54. package/dist/tracking/spans/index.d.ts +6 -8
  55. package/dist/tracking/spans/index.d.ts.map +1 -1
  56. package/dist/tracking/spans/index.js +6 -11
  57. package/dist/tracking/spans/index.js.map +1 -1
  58. package/dist/tracking/spans/redact-pii.d.ts +18 -20
  59. package/dist/tracking/spans/redact-pii.d.ts.map +1 -1
  60. package/dist/tracking/spans/redact-pii.js +30 -29
  61. package/dist/tracking/spans/redact-pii.js.map +1 -1
  62. package/dist/tracking/spans/span-hooks.d.ts +0 -17
  63. package/dist/tracking/spans/span-hooks.d.ts.map +1 -1
  64. package/dist/tracking/spans/span-hooks.js +407 -207
  65. package/dist/tracking/spans/span-hooks.js.map +1 -1
  66. package/package.json +1 -1
  67. package/dist/tracking/spans/fan-out-span-transport.d.ts +0 -22
  68. package/dist/tracking/spans/fan-out-span-transport.d.ts.map +0 -1
  69. package/dist/tracking/spans/fan-out-span-transport.js +0 -49
  70. package/dist/tracking/spans/fan-out-span-transport.js.map +0 -1
  71. package/dist/tracking/spans/otel/fit-row-budget.d.ts +0 -22
  72. package/dist/tracking/spans/otel/fit-row-budget.d.ts.map +0 -1
  73. package/dist/tracking/spans/otel/fit-row-budget.js +0 -91
  74. package/dist/tracking/spans/otel/fit-row-budget.js.map +0 -1
  75. package/dist/tracking/spans/otel/map-span-to-trace-row.d.ts +0 -16
  76. package/dist/tracking/spans/otel/map-span-to-trace-row.d.ts.map +0 -1
  77. package/dist/tracking/spans/otel/map-span-to-trace-row.js +0 -227
  78. package/dist/tracking/spans/otel/map-span-to-trace-row.js.map +0 -1
  79. package/dist/tracking/spans/otel/recording-span-transport.d.ts +0 -43
  80. package/dist/tracking/spans/otel/recording-span-transport.d.ts.map +0 -1
  81. package/dist/tracking/spans/otel/recording-span-transport.js +0 -67
  82. package/dist/tracking/spans/otel/recording-span-transport.js.map +0 -1
  83. package/dist/tracking/spans/otel/span-ids.d.ts +0 -22
  84. package/dist/tracking/spans/otel/span-ids.d.ts.map +0 -1
  85. package/dist/tracking/spans/otel/span-ids.js +0 -32
  86. package/dist/tracking/spans/otel/span-ids.js.map +0 -1
  87. package/dist/tracking/spans/plugin-span-tracker.d.ts +0 -9
  88. package/dist/tracking/spans/plugin-span-tracker.d.ts.map +0 -1
  89. package/dist/tracking/spans/plugin-span-tracker.js +0 -115
  90. package/dist/tracking/spans/plugin-span-tracker.js.map +0 -1
  91. package/dist/tracking/spans/span-tracker.d.ts +0 -133
  92. package/dist/tracking/spans/span-tracker.d.ts.map +0 -1
  93. package/dist/tracking/spans/span-tracker.js +0 -721
  94. package/dist/tracking/spans/span-tracker.js.map +0 -1
  95. package/dist/tracking/spans/span-transport.d.ts +0 -71
  96. package/dist/tracking/spans/span-transport.d.ts.map +0 -1
  97. package/dist/tracking/spans/span-transport.js +0 -240
  98. package/dist/tracking/spans/span-transport.js.map +0 -1
  99. package/dist/tracking/spans/types.d.ts +0 -101
  100. package/dist/tracking/spans/types.d.ts.map +0 -1
  101. package/dist/tracking/spans/types.js +0 -6
  102. package/dist/tracking/spans/types.js.map +0 -1
@@ -1,13 +1,42 @@
1
+ /**
2
+ * registerSpanHooks: wires the nine OpenClaw plugin hooks (llm_input/llm_output,
3
+ * before/after_tool_call, the four subagent_* hooks, agent_end) to real OTel gen_ai
4
+ * spans via the shared `getTracer()`:
5
+ * - llm_input → llm_output → `chat` (CLIENT)
6
+ * - tool_call → `execute_tool` (INTERNAL), parented to the model span
7
+ * - subagent_* lifecycle → `invoke_agent` (INTERNAL), parented to the model span
8
+ * - agent_end → ends any still-open spans for the run
9
+ *
10
+ * Open and finalize are separate callbacks, so we can't use `withSpan`; we `startSpan`
11
+ * and hold the handle in a session-keyed registry until the finalize hook, recording
12
+ * errors directly on the held span (it's never the active span, so `logger.error` won't).
13
+ *
14
+ * Identity rides as `senpi.*` attributes from the `X-Senpi-*` headers (ALS doesn't cross
15
+ * the loopback fetch). The trading trace is joined by the `traceparent` header when present
16
+ * (nesting under `signal.process`), else by a `senpi.correlation_id` attribute. Handlers
17
+ * never throw.
18
+ */
19
+ import { SpanKind, context as otelContext, propagation, trace, } from "@opentelemetry/api";
20
+ import { getTracer } from "../../telemetry/tracing.js";
1
21
  import { HEADER_ADDRESS, HEADER_CORRELATION_ID, HEADER_RECIPE_NAME, HEADER_RUNTIME_ID, HEADER_RUNTIME_VERSION, } from "./constants.js";
22
+ import { GEN_AI, errorTypeOf, firstNumber, normalizeGenAiMessages, recordSpanError, setDefinedAttributes, setGenAiContentAttribute, toFinishReasons, } from "./gen-ai.js";
23
+ import { elapsedSeconds, recordExecuteToolDuration, recordInvokeAgentDuration, recordOperationDuration, recordTokenUsage, } from "../../telemetry/metrics.js";
2
24
  import { createLogger } from "../../utils/logger.js";
3
- import { sanitizePayloadValue } from "./sanitize-payload.js";
4
25
  const logger = createLogger("[senpi-core][span-hooks]");
5
26
  export function registerSpanHooks(options) {
6
- const { api, tracker } = options;
7
- // Tool hooks often arrive with a ctx that lacks sessionKey/runId — OpenClaw
8
- // doesn't always thread the host agent's identity through to tool calls.
9
- // Mirror opik's fallback strategy: remember (agentId → sessionKey/runId)
10
- // from llm_input, and the last seen pair, so tool hooks can resolve them.
27
+ const { api } = options;
28
+ const tracer = getTracer();
29
+ // Held model spans, keyed by (sessionKey, runId). The tool/subagent spans parent
30
+ // off the model span for the same session/run.
31
+ const modelSpans = new Map();
32
+ // Held tool spans, keyed by (sessionKey, runId, toolCallId|toolName seq).
33
+ const toolSpans = new Map();
34
+ // Held subagent spans, keyed by childSessionKey (sessionKey/runId = the host).
35
+ const subagentSpans = new Map();
36
+ let toolSeq = 0;
37
+ // Tool hooks often arrive with a ctx that lacks sessionKey/runId. Remember
38
+ // (agentId → sessionKey/runId) from llm_input, plus the last-seen pair, so tool
39
+ // hooks can resolve their host session.
11
40
  const sessionByAgentId = new Map();
12
41
  let lastActive = null;
13
42
  const rememberSession = (agentCtx, sessionKey, runId) => {
@@ -32,10 +61,15 @@ export function registerSpanHooks(options) {
32
61
  }
33
62
  return lastActive;
34
63
  };
35
- // ─── LLM ───────────────────────────────────────────────────────────────
64
+ /** Build a parent Context for tool/subagent spans from the host model span (if any). */
65
+ const parentContextForModel = (sessionKey, runId) => {
66
+ const model = modelSpans.get(sessionLlmKey(sessionKey, runId));
67
+ return model ? trace.setSpan(otelContext.active(), model.span) : otelContext.active();
68
+ };
69
+ // ─── LLM (`chat` span) ───────────────────────────────────────────────────
36
70
  api.on("llm_input", (event, agentCtx) => {
37
71
  try {
38
- const ctx = readContext(agentCtx);
72
+ const identity = readIdentity(agentCtx);
39
73
  const sessionKey = readSessionKey(agentCtx, event);
40
74
  const runId = readRunId(agentCtx, event);
41
75
  if (!sessionKey || !runId) {
@@ -43,13 +77,43 @@ export function registerSpanHooks(options) {
43
77
  return;
44
78
  }
45
79
  rememberSession(agentCtx, sessionKey, runId);
46
- tracker.openLlmSpan({
80
+ const key = sessionLlmKey(sessionKey, runId);
81
+ // A duplicate llm_input for the same (session, run) means a new turn opened
82
+ // without the previous one closing — end the prior span before replacing it.
83
+ const prior = modelSpans.get(key);
84
+ if (prior)
85
+ endSpan(prior.span);
86
+ const ev = asRecord(event) ?? {};
87
+ const requestModel = nonEmptyString(ev.model);
88
+ const provider = nonEmptyString(ev.provider);
89
+ const span = tracer.startSpan(`chat ${requestModel ?? "unknown"}`, { kind: SpanKind.CLIENT },
90
+ // Parent off the trading trace when the traceparent header threaded through.
91
+ parentContextFromHeaders(agentCtx));
92
+ const held = {
93
+ span,
47
94
  sessionKey,
48
95
  runId,
49
- emissionSource: "hook",
50
- input: extractLlmInput(event),
51
- ...ctx,
96
+ startMs: Date.now(),
97
+ provider,
98
+ model: requestModel,
99
+ };
100
+ modelSpans.set(key, held);
101
+ setDefinedAttributes(span, {
102
+ [GEN_AI.OPERATION_NAME]: "chat",
103
+ [GEN_AI.PROVIDER_NAME]: provider,
104
+ [GEN_AI.REQUEST_MODEL]: requestModel,
105
+ [GEN_AI.REQUEST_TEMPERATURE]: firstNumber(ev.temperature),
106
+ [GEN_AI.REQUEST_MAX_TOKENS]: firstNumber(ev.maxTokens),
107
+ ...identityAttributes(identity),
52
108
  });
109
+ // Content (redacted + capped). Stamping is async and the span ends in llm_output,
110
+ // so track the promise for the finalize to await — else a fast round-trip could end
111
+ // the span before the input content lands.
112
+ held.pending = Promise.allSettled([
113
+ setGenAiContentAttribute(span, GEN_AI.SYSTEM_INSTRUCTIONS, buildSystemInstructions(ev)),
114
+ setGenAiContentAttribute(span, GEN_AI.INPUT_MESSAGES, buildInputMessages(ev)),
115
+ setGenAiContentAttribute(span, GEN_AI.TOOL_DEFINITIONS, ev.tools),
116
+ ]);
53
117
  }
54
118
  catch (err) {
55
119
  logger.warn("llm_input handler failed", { err: errMsg(err) });
@@ -57,7 +121,6 @@ export function registerSpanHooks(options) {
57
121
  });
58
122
  api.on("llm_output", (event, agentCtx) => {
59
123
  try {
60
- const ctx = readContext(agentCtx);
61
124
  const sessionKey = readSessionKey(agentCtx, event);
62
125
  const runId = readRunId(agentCtx, event);
63
126
  if (!sessionKey || !runId) {
@@ -65,47 +128,88 @@ export function registerSpanHooks(options) {
65
128
  return;
66
129
  }
67
130
  rememberSession(agentCtx, sessionKey, runId);
68
- const out = extractLlmOutput(event);
69
- tracker.finalizeLlmSpan({
70
- sessionKey,
71
- runId,
72
- output: out.output,
73
- model: out.model,
74
- tokensIn: out.tokensIn,
75
- tokensOut: out.tokensOut,
76
- ...ctx,
131
+ const held = modelSpans.get(sessionLlmKey(sessionKey, runId));
132
+ if (!held) {
133
+ logger.debug("llm_output: no open chat span", { sessionKey, runId });
134
+ return;
135
+ }
136
+ const span = held.span;
137
+ modelSpans.delete(sessionLlmKey(sessionKey, runId));
138
+ const ev = asRecord(event) ?? {};
139
+ const usage = asRecord(ev.usage);
140
+ // Token field naming varies by provider/wrapper; try every known spelling.
141
+ const inputTokens = firstNumber(usage?.input_tokens, usage?.prompt_tokens, usage?.promptTokens, usage?.input);
142
+ const outputTokens = firstNumber(usage?.output_tokens, usage?.completion_tokens, usage?.completionTokens, usage?.output);
143
+ setDefinedAttributes(span, {
144
+ [GEN_AI.RESPONSE_MODEL]: nonEmptyString(ev.model),
145
+ [GEN_AI.RESPONSE_FINISH_REASONS]: toFinishReasons(ev.finishReason ?? ev.stopReason),
146
+ [GEN_AI.USAGE_INPUT_TOKENS]: inputTokens,
147
+ [GEN_AI.USAGE_OUTPUT_TOKENS]: outputTokens,
77
148
  });
149
+ const error = ev.error;
150
+ const errored = error !== undefined && error !== null;
151
+ const dims = { provider: held.provider, model: held.model };
152
+ recordTokenUsage(dims, { input: inputTokens, output: outputTokens });
153
+ recordOperationDuration(elapsedSeconds(held.startMs), {
154
+ ...dims,
155
+ errorType: errored ? errorTypeOf(error) : undefined,
156
+ });
157
+ const outputMessages = buildOutputMessages(ev);
158
+ void (async () => {
159
+ try {
160
+ if (held.pending)
161
+ await held.pending;
162
+ await setGenAiContentAttribute(span, GEN_AI.OUTPUT_MESSAGES, outputMessages);
163
+ if (errored)
164
+ recordSpanError(span, error);
165
+ }
166
+ finally {
167
+ try {
168
+ span.end();
169
+ }
170
+ catch {
171
+ /* best effort */
172
+ }
173
+ }
174
+ })();
78
175
  }
79
176
  catch (err) {
80
177
  logger.warn("llm_output handler failed", { err: errMsg(err) });
81
178
  }
82
179
  });
83
- // ─── Tool ──────────────────────────────────────────────────────────────
84
- // OpenClaw's tool-hook context often omits sessionKey/runId. Fall back to
85
- // (agentId → session/run) learned from the most recent llm_input, then to
86
- // the last-active pair, matching opik's strategy.
180
+ // ─── Tool (`execute_tool` span) ────────────────────────────────────────────
87
181
  api.on("before_tool_call", (event, toolCtx) => {
88
182
  try {
89
- const ctx = readContext(toolCtx);
183
+ const identity = readIdentity(toolCtx);
90
184
  const resolved = resolveToolSession(toolCtx);
91
185
  if (!resolved) {
92
- logger.debug("before_tool_call sessionKey/runId unresolved (no llm_input seen yet); skipping");
186
+ logger.debug("before_tool_call session unresolved (no llm_input seen yet); skipping");
93
187
  return;
94
188
  }
95
- const evObj = asRecord(event);
96
- const toolName = nonEmptyString(evObj?.toolName) ?? nonEmptyString(evObj?.name) ?? "unknown";
97
- const toolCallId = nonEmptyString(evObj?.toolCallId) ??
189
+ const ev = asRecord(event);
190
+ const toolName = nonEmptyString(ev?.toolName) ?? nonEmptyString(ev?.name) ?? "unknown";
191
+ const toolCallId = nonEmptyString(ev?.toolCallId) ??
98
192
  nonEmptyString(asRecord(toolCtx)?.toolCallId) ??
99
193
  null;
100
- tracker.openToolSpan({
194
+ const key = toolKey(resolved.sessionKey, resolved.runId, toolCallId, toolName, ++toolSeq);
195
+ const span = tracer.startSpan(`execute_tool ${toolName}`, { kind: SpanKind.INTERNAL }, parentContextForModel(resolved.sessionKey, resolved.runId));
196
+ const held = {
197
+ span,
101
198
  sessionKey: resolved.sessionKey,
102
199
  runId: resolved.runId,
103
- emissionSource: "hook",
104
- toolCallId,
200
+ startMs: Date.now(),
105
201
  toolName,
106
- toolInput: asRecord(evObj?.params) ?? asRecord(evObj?.input),
107
- ...ctx,
202
+ };
203
+ toolSpans.set(key, held);
204
+ setDefinedAttributes(span, {
205
+ [GEN_AI.OPERATION_NAME]: "execute_tool",
206
+ [GEN_AI.TOOL_NAME]: toolName,
207
+ [GEN_AI.TOOL_CALL_ID]: toolCallId ?? undefined,
208
+ ...identityAttributes(identity),
108
209
  });
210
+ // Args stamping is async; the finalize (after_tool_call) awaits this so a fast
211
+ // tool doesn't end the span before the args land.
212
+ held.pending = setGenAiContentAttribute(span, GEN_AI.TOOL_CALL_ARGUMENTS, ev?.params ?? ev?.input);
109
213
  }
110
214
  catch (err) {
111
215
  logger.warn("before_tool_call handler failed", { err: errMsg(err) });
@@ -113,69 +217,93 @@ export function registerSpanHooks(options) {
113
217
  });
114
218
  api.on("after_tool_call", (event, toolCtx) => {
115
219
  try {
116
- const ctx = readContext(toolCtx);
117
220
  const resolved = resolveToolSession(toolCtx);
118
221
  if (!resolved) {
119
- logger.debug("after_tool_call sessionKey/runId unresolved (no llm_input seen yet); skipping");
222
+ logger.debug("after_tool_call session unresolved; skipping");
120
223
  return;
121
224
  }
122
- const evObj = asRecord(event);
123
- const toolName = nonEmptyString(evObj?.toolName) ?? nonEmptyString(evObj?.name) ?? "unknown";
124
- const toolCallId = nonEmptyString(evObj?.toolCallId) ??
225
+ const ev = asRecord(event);
226
+ const toolName = nonEmptyString(ev?.toolName) ?? nonEmptyString(ev?.name) ?? "unknown";
227
+ const toolCallId = nonEmptyString(ev?.toolCallId) ??
125
228
  nonEmptyString(asRecord(toolCtx)?.toolCallId) ??
126
229
  null;
127
- const error = evObj?.error;
128
- const result = evObj?.result;
129
- const durationMs = typeof evObj?.durationMs === "number" ? evObj.durationMs : undefined;
130
- tracker.finalizeToolSpan({
131
- sessionKey: resolved.sessionKey,
132
- runId: resolved.runId,
133
- toolCallId,
134
- toolName,
135
- output: error !== undefined ? undefined : result,
136
- error: error !== undefined ? error : undefined,
137
- durationMs,
138
- ...ctx,
230
+ const foundKey = findToolKey(toolSpans, resolved.sessionKey, resolved.runId, toolCallId, toolName);
231
+ if (!foundKey) {
232
+ logger.debug("after_tool_call: no open tool span", { toolName, toolCallId });
233
+ return;
234
+ }
235
+ const held = toolSpans.get(foundKey);
236
+ const span = held.span;
237
+ toolSpans.delete(foundKey);
238
+ const error = ev?.error;
239
+ const errored = error !== undefined && error !== null;
240
+ const result = ev?.result;
241
+ recordExecuteToolDuration(elapsedSeconds(held.startMs), {
242
+ toolName: held.toolName,
243
+ errorType: errored ? errorTypeOf(error) : undefined,
139
244
  });
245
+ void (async () => {
246
+ try {
247
+ if (held.pending)
248
+ await held.pending;
249
+ if (errored) {
250
+ recordSpanError(span, error);
251
+ }
252
+ else {
253
+ await setGenAiContentAttribute(span, GEN_AI.TOOL_CALL_RESULT, result);
254
+ }
255
+ }
256
+ finally {
257
+ try {
258
+ span.end();
259
+ }
260
+ catch {
261
+ /* best effort */
262
+ }
263
+ }
264
+ })();
140
265
  }
141
266
  catch (err) {
142
267
  logger.warn("after_tool_call handler failed", { err: errMsg(err) });
143
268
  }
144
269
  });
145
- // ─── Subagent (four hooks) ─────────────────────────────────────────────
270
+ // ─── Subagent (`invoke_agent` span, four-hook lifecycle) ───────────────────
146
271
  api.on("subagent_spawning", (event, subagentCtx) => {
147
272
  try {
148
- const ctx = readContext(subagentCtx);
149
- const evObj = asRecord(event);
273
+ const identity = readIdentity(subagentCtx);
274
+ const ev = asRecord(event);
150
275
  const ctxObj = asRecord(subagentCtx);
151
276
  const hostSessionKey = nonEmptyString(ctxObj?.requesterSessionKey) ??
152
277
  nonEmptyString(ctxObj?.sessionKey) ??
153
278
  "";
154
- const hostRunId = nonEmptyString(ctxObj?.runId) ??
155
- nonEmptyString(ctxObj?.requesterRunId) ??
156
- "";
157
- const childSessionKey = nonEmptyString(evObj?.childSessionKey) ??
158
- nonEmptyString(ctxObj?.childSessionKey);
279
+ const hostRunId = nonEmptyString(ctxObj?.runId) ?? nonEmptyString(ctxObj?.requesterRunId) ?? "";
280
+ const childSessionKey = nonEmptyString(ev?.childSessionKey) ?? nonEmptyString(ctxObj?.childSessionKey);
159
281
  if (!childSessionKey) {
160
282
  logger.warn("subagent_spawning missing childSessionKey; skipping span");
161
283
  return;
162
284
  }
163
- const parentSpanId = hostSessionKey && hostRunId
164
- ? tracker.getActiveLlmSpanId(hostSessionKey, hostRunId)
165
- : null;
166
- tracker.openSubagentSpan({
167
- hostSessionKey,
168
- hostRunId,
169
- childSessionKey,
170
- parentSpanId,
171
- input: {
172
- agentId: evObj?.agentId,
173
- label: evObj?.label,
174
- mode: evObj?.mode,
175
- requester: evObj?.requester,
176
- threadRequested: evObj?.threadRequested,
177
- },
178
- ...ctx,
285
+ const prior = subagentSpans.get(childSessionKey);
286
+ if (prior)
287
+ endSpan(prior.span);
288
+ const agentName = nonEmptyString(ev?.label) ?? nonEmptyString(ev?.agentId) ?? "unknown";
289
+ const span = tracer.startSpan(`invoke_agent ${agentName}`, { kind: SpanKind.INTERNAL }, hostSessionKey && hostRunId
290
+ ? parentContextForModel(hostSessionKey, hostRunId)
291
+ : otelContext.active());
292
+ // sessionKey/runId carry the HOST run so the agent_end sweep can reach this span.
293
+ subagentSpans.set(childSessionKey, {
294
+ span,
295
+ sessionKey: hostSessionKey,
296
+ runId: hostRunId,
297
+ startMs: Date.now(),
298
+ agentName,
299
+ });
300
+ setDefinedAttributes(span, {
301
+ [GEN_AI.OPERATION_NAME]: "invoke_agent",
302
+ [GEN_AI.AGENT_NAME]: agentName,
303
+ "senpi.subagent.mode": nonEmptyString(ev?.mode),
304
+ "senpi.subagent.requester": nonEmptyString(ev?.requester),
305
+ "senpi.subagent.thread_requested": typeof ev?.threadRequested === "boolean" ? ev.threadRequested : undefined,
306
+ ...identityAttributes(identity),
179
307
  });
180
308
  }
181
309
  catch (err) {
@@ -184,23 +312,20 @@ export function registerSpanHooks(options) {
184
312
  });
185
313
  api.on("subagent_spawned", (event, subagentCtx) => {
186
314
  try {
187
- const ctx = readContext(subagentCtx);
188
- const evObj = asRecord(event);
315
+ const ev = asRecord(event);
189
316
  const ctxObj = asRecord(subagentCtx);
190
- const childSessionKey = nonEmptyString(evObj?.childSessionKey) ??
191
- nonEmptyString(ctxObj?.childSessionKey);
317
+ const childSessionKey = nonEmptyString(ev?.childSessionKey) ?? nonEmptyString(ctxObj?.childSessionKey);
192
318
  if (!childSessionKey)
193
319
  return;
194
- tracker.mergeSubagentSpan({
195
- childSessionKey,
196
- status: "spawned",
197
- patch: {
198
- runId: nonEmptyString(evObj?.runId) ?? nonEmptyString(ctxObj?.runId),
199
- agentId: evObj?.agentId,
200
- mode: evObj?.mode,
201
- threadRequested: evObj?.threadRequested,
202
- },
203
- }, ctx);
320
+ const held = subagentSpans.get(childSessionKey);
321
+ if (!held)
322
+ return;
323
+ const span = held.span;
324
+ setDefinedAttributes(span, {
325
+ "senpi.subagent.run_id": nonEmptyString(ev?.runId) ?? nonEmptyString(ctxObj?.runId),
326
+ "senpi.subagent.mode": nonEmptyString(ev?.mode),
327
+ "senpi.subagent.thread_requested": typeof ev?.threadRequested === "boolean" ? ev.threadRequested : undefined,
328
+ });
204
329
  }
205
330
  catch (err) {
206
331
  logger.warn("subagent_spawned handler failed", { err: errMsg(err) });
@@ -208,31 +333,23 @@ export function registerSpanHooks(options) {
208
333
  });
209
334
  api.on("subagent_delivery_target", (event, subagentCtx) => {
210
335
  try {
211
- const ctx = readContext(subagentCtx);
212
- const evObj = asRecord(event);
336
+ const ev = asRecord(event);
213
337
  const ctxObj = asRecord(subagentCtx);
214
- const childSessionKey = nonEmptyString(evObj?.childSessionKey) ??
215
- nonEmptyString(ctxObj?.childSessionKey);
338
+ const childSessionKey = nonEmptyString(ev?.childSessionKey) ?? nonEmptyString(ctxObj?.childSessionKey);
216
339
  if (!childSessionKey)
217
340
  return;
218
- const requesterOrigin = asRecord(evObj?.requesterOrigin) ?? {};
219
- tracker.mergeSubagentSpan({
220
- childSessionKey,
221
- status: "delivery_target",
222
- patch: {
223
- delivery: {
224
- channel: nonEmptyString(requesterOrigin.channel),
225
- accountId: nonEmptyString(requesterOrigin.accountId),
226
- to: nonEmptyString(requesterOrigin.to),
227
- threadId: requesterOrigin.threadId,
228
- childRunId: nonEmptyString(evObj?.childRunId),
229
- spawnMode: nonEmptyString(evObj?.spawnMode),
230
- expectsCompletionMessage: typeof evObj?.expectsCompletionMessage === "boolean"
231
- ? evObj.expectsCompletionMessage
232
- : undefined,
233
- },
234
- },
235
- }, ctx);
341
+ const held = subagentSpans.get(childSessionKey);
342
+ if (!held)
343
+ return;
344
+ const span = held.span;
345
+ const origin = asRecord(ev?.requesterOrigin) ?? {};
346
+ setDefinedAttributes(span, {
347
+ "senpi.subagent.delivery.channel": nonEmptyString(origin.channel),
348
+ "senpi.subagent.delivery.account_id": nonEmptyString(origin.accountId),
349
+ "senpi.subagent.delivery.to": nonEmptyString(origin.to),
350
+ "senpi.subagent.delivery.child_run_id": nonEmptyString(ev?.childRunId),
351
+ "senpi.subagent.delivery.spawn_mode": nonEmptyString(ev?.spawnMode),
352
+ });
236
353
  }
237
354
  catch (err) {
238
355
  logger.warn("subagent_delivery_target handler failed", { err: errMsg(err) });
@@ -240,79 +357,192 @@ export function registerSpanHooks(options) {
240
357
  });
241
358
  api.on("subagent_ended", (event, subagentCtx) => {
242
359
  try {
243
- const ctx = readContext(subagentCtx);
244
- const evObj = asRecord(event);
360
+ const ev = asRecord(event);
245
361
  const ctxObj = asRecord(subagentCtx);
246
- const childSessionKey = nonEmptyString(evObj?.targetSessionKey) ??
362
+ const childSessionKey = nonEmptyString(ev?.targetSessionKey) ??
247
363
  nonEmptyString(ctxObj?.childSessionKey) ??
248
- nonEmptyString(evObj?.childSessionKey);
364
+ nonEmptyString(ev?.childSessionKey);
249
365
  if (!childSessionKey)
250
366
  return;
251
- const error = evObj?.error;
252
- tracker.finalizeSubagentSpan({
253
- childSessionKey,
254
- status: "ended",
255
- output: error === undefined
256
- ? {
257
- outcome: evObj?.outcome,
258
- reason: evObj?.reason,
259
- }
260
- : undefined,
261
- error: error !== undefined ? error : undefined,
262
- }, ctx);
367
+ const held = subagentSpans.get(childSessionKey);
368
+ if (!held)
369
+ return;
370
+ const span = held.span;
371
+ subagentSpans.delete(childSessionKey);
372
+ const error = ev?.error;
373
+ const errored = error !== undefined && error !== null;
374
+ recordInvokeAgentDuration(elapsedSeconds(held.startMs), {
375
+ agentName: held.agentName,
376
+ errorType: errored ? errorTypeOf(error) : undefined,
377
+ });
378
+ if (errored) {
379
+ recordSpanError(span, error);
380
+ }
381
+ else {
382
+ setDefinedAttributes(span, {
383
+ "senpi.subagent.outcome": nonEmptyString(ev?.outcome),
384
+ "senpi.subagent.reason": nonEmptyString(ev?.reason),
385
+ });
386
+ }
387
+ try {
388
+ span.end();
389
+ }
390
+ catch {
391
+ /* best effort */
392
+ }
263
393
  }
264
394
  catch (err) {
265
395
  logger.warn("subagent_ended handler failed", { err: errMsg(err) });
266
396
  }
267
397
  });
268
- // ─── Agent end (sweep orphans + emit marker) ───────────────────────────
398
+ // ─── Agent end (orphan sweep) ──────────────────────────────────────────────
269
399
  api.on("agent_end", (event, agentCtx) => {
270
400
  try {
271
- const ctx = readContext(agentCtx);
272
401
  const sessionKey = readSessionKey(agentCtx, event);
273
402
  const runId = readRunId(agentCtx, event);
274
403
  if (!sessionKey || !runId) {
275
- logger.warn("agent_end missing sessionKey/runId; skipping marker");
404
+ logger.warn("agent_end missing sessionKey/runId; skipping sweep");
276
405
  return;
277
406
  }
278
- const evObj = asRecord(event);
279
- tracker.captureAgentEnd({
280
- sessionKey,
281
- runId,
282
- outcome: evObj?.outcome,
283
- error: evObj?.error,
284
- ...ctx,
285
- });
407
+ const ev = asRecord(event);
408
+ const outcomeError = ev?.error;
409
+ // End any span still open under this (session, run): the model span, its tool
410
+ // children, and any subagents it hosted. Matching is by the stored host
411
+ // session/run, so nothing leaks past the run's end (incl. subagents, which have
412
+ // no other guaranteed terminator).
413
+ const llmKey = sessionLlmKey(sessionKey, runId);
414
+ const model = modelSpans.get(llmKey);
415
+ if (model) {
416
+ modelSpans.delete(llmKey);
417
+ const errored = outcomeError !== undefined && outcomeError !== null;
418
+ recordOperationDuration(elapsedSeconds(model.startMs), {
419
+ provider: model.provider,
420
+ model: model.model,
421
+ errorType: errored ? errorTypeOf(outcomeError) : undefined,
422
+ });
423
+ if (errored)
424
+ recordSpanError(model.span, outcomeError);
425
+ endSpan(model.span);
426
+ }
427
+ for (const [k, held] of toolSpans) {
428
+ if (held.sessionKey === sessionKey && held.runId === runId) {
429
+ toolSpans.delete(k);
430
+ endSpan(held.span);
431
+ }
432
+ }
433
+ for (const [k, held] of subagentSpans) {
434
+ if (held.sessionKey === sessionKey && held.runId === runId) {
435
+ subagentSpans.delete(k);
436
+ endSpan(held.span);
437
+ }
438
+ }
286
439
  }
287
440
  catch (err) {
288
441
  logger.warn("agent_end handler failed", { err: errMsg(err) });
289
442
  }
290
443
  });
291
444
  }
292
- // ─── Helpers ──────────────────────────────────────────────────────────────
293
- function asRecord(v) {
294
- if (v && typeof v === "object" && !Array.isArray(v)) {
295
- return v;
445
+ // ─── Span key helpers ───────────────────────────────────────────────────────
446
+ const SEP = " ";
447
+ function sessionLlmKey(sessionKey, runId) {
448
+ return `${sessionKey}${SEP}${runId}${SEP}llm`;
449
+ }
450
+ function toolKey(sessionKey, runId, toolCallId, toolName, seq) {
451
+ return toolCallId
452
+ ? `${sessionKey}${SEP}${runId}${SEP}toolcall${SEP}${toolCallId}`
453
+ : `${sessionKey}${SEP}${runId}${SEP}tool${SEP}${toolName}${SEP}${seq}`;
454
+ }
455
+ /** Find the open tool span key within one (session, run): by toolCallId first, else by tool name. */
456
+ function findToolKey(toolSpans, sessionKey, runId, toolCallId, toolName) {
457
+ const prefix = `${sessionKey}${SEP}${runId}${SEP}`;
458
+ if (toolCallId) {
459
+ const suffix = `${SEP}toolcall${SEP}${toolCallId}`;
460
+ for (const key of toolSpans.keys()) {
461
+ if (key.startsWith(prefix) && key.endsWith(suffix))
462
+ return key;
463
+ }
464
+ }
465
+ const namePart = `${SEP}tool${SEP}${toolName}${SEP}`;
466
+ for (const key of toolSpans.keys()) {
467
+ if (key.startsWith(prefix) && key.includes(namePart))
468
+ return key;
296
469
  }
297
470
  return undefined;
298
471
  }
299
- function nonEmptyString(v) {
300
- return typeof v === "string" && v.length > 0 ? v : undefined;
472
+ function endSpan(span) {
473
+ try {
474
+ span.end();
475
+ }
476
+ catch {
477
+ /* best effort */
478
+ }
479
+ }
480
+ // ─── Identity + trace correlation ─────────────────────────────────────────────
481
+ const ADDRESS_RE = /^0x[a-f0-9]{40}$/i;
482
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
483
+ /** Cap on a free-form header value stamped as an attribute (defence vs oversized header). */
484
+ const MAX_HEADER_ATTR_CHARS = 256;
485
+ function clampHeader(v) {
486
+ return v.length > MAX_HEADER_ATTR_CHARS ? v.slice(0, MAX_HEADER_ATTR_CHARS) : v;
301
487
  }
302
488
  /**
303
- * Pull senpi-context headers out of agentCtx. Several common locations are
304
- * checked because the exact shape depends on OpenClaw's runtime extension SDK.
489
+ * Map the senpi identity headers to `senpi.*` span attributes (keys match the on-site
490
+ * identity bag; the headers carry the runtime id as `senpi.runtime.id`, vs on-site
491
+ * `senpi.runtime`). Headers are caller-influenceable, so join keys are validated — a
492
+ * malformed address / correlation id is dropped, not stamped as garbage.
305
493
  */
306
- function readContext(agentCtx) {
494
+ function identityAttributes(identity) {
495
+ const attrs = {};
496
+ if (identity.address) {
497
+ const lowered = identity.address.toLowerCase();
498
+ if (ADDRESS_RE.test(lowered))
499
+ attrs["senpi.strategy.address"] = lowered;
500
+ }
501
+ if (identity.recipeName)
502
+ attrs["senpi.recipe"] = clampHeader(identity.recipeName);
503
+ if (identity.runtimeVersion)
504
+ attrs["senpi.recipe.version"] = clampHeader(identity.runtimeVersion);
505
+ if (identity.runtimeId)
506
+ attrs["senpi.runtime.id"] = clampHeader(identity.runtimeId);
507
+ // Fallback join key to the trading trace when traceparent didn't thread through.
508
+ if (identity.correlationId && UUID_RE.test(identity.correlationId)) {
509
+ attrs["senpi.correlation_id"] = identity.correlationId;
510
+ }
511
+ return attrs;
512
+ }
513
+ function readIdentity(agentCtx) {
307
514
  const headers = readHeaders(agentCtx);
308
515
  return {
309
- correlationId: headers[HEADER_CORRELATION_ID] ?? null,
310
- address: headers[HEADER_ADDRESS] ?? null,
311
- recipeName: headers[HEADER_RECIPE_NAME] ?? null,
312
- runtimeId: headers[HEADER_RUNTIME_ID] ?? null,
313
- runtimeVersion: headers[HEADER_RUNTIME_VERSION] ?? null,
516
+ correlationId: headers[HEADER_CORRELATION_ID],
517
+ address: headers[HEADER_ADDRESS],
518
+ recipeName: headers[HEADER_RECIPE_NAME],
519
+ runtimeId: headers[HEADER_RUNTIME_ID],
520
+ runtimeVersion: headers[HEADER_RUNTIME_VERSION],
314
521
  };
315
522
  }
523
+ /** Extract a W3C traceparent from the request headers → a parent Context (else active). */
524
+ function parentContextFromHeaders(agentCtx) {
525
+ try {
526
+ const headers = readHeaders(agentCtx);
527
+ if (headers.traceparent === undefined)
528
+ return otelContext.active();
529
+ return propagation.extract(otelContext.active(), headers);
530
+ }
531
+ catch {
532
+ return otelContext.active();
533
+ }
534
+ }
535
+ // ─── Field readers ────────────────────────────────────────────────────────────
536
+ function asRecord(v) {
537
+ if (v && typeof v === "object" && !Array.isArray(v)) {
538
+ return v;
539
+ }
540
+ return undefined;
541
+ }
542
+ function nonEmptyString(v) {
543
+ return typeof v === "string" && v.length > 0 ? v : undefined;
544
+ }
545
+ /** Pull request headers out of agentCtx; several common locations are checked. */
316
546
  function readHeaders(agentCtx) {
317
547
  const ctxObj = asRecord(agentCtx);
318
548
  if (!ctxObj)
@@ -328,7 +558,6 @@ function readHeaders(agentCtx) {
328
558
  if (!candidate)
329
559
  continue;
330
560
  for (const [k, v] of Object.entries(candidate)) {
331
- // HTTP headers should be case-insensitive; normalize to lowercase.
332
561
  const lk = k.toLowerCase();
333
562
  if (typeof v === "string" && v.length > 0 && out[lk] === undefined) {
334
563
  out[lk] = v;
@@ -347,61 +576,32 @@ function readSessionKey(ctx, event) {
347
576
  nonEmptyString(asRecord(event)?.sessionId));
348
577
  }
349
578
  function readRunId(ctx, event) {
350
- return (nonEmptyString(asRecord(ctx)?.runId) ??
351
- nonEmptyString(asRecord(event)?.runId));
579
+ return nonEmptyString(asRecord(ctx)?.runId) ?? nonEmptyString(asRecord(event)?.runId);
352
580
  }
353
- function extractLlmInput(event) {
354
- const ev = asRecord(event);
355
- if (!ev)
356
- return {};
357
- // Strip OpenClaw framework metadata blocks (untrusted-context, sender info,
358
- // reply_to markers, media refs) before stamping the payload onto the span.
359
- // Without this, every llm_input on a chat session balloons past 1MB on the
360
- // back of injected context and the real user prompt gets buried.
361
- return {
362
- system: sanitizePayloadValue(ev.systemPrompt ?? ev.system),
363
- prompt: sanitizePayloadValue(ev.prompt),
364
- messages: sanitizePayloadValue(ev.historyMessages ?? ev.messages),
365
- model: ev.model,
366
- provider: ev.provider,
367
- imagesCount: ev.imagesCount,
368
- temperature: ev.temperature,
369
- maxTokens: ev.maxTokens,
370
- tools: ev.tools,
371
- };
581
+ // ─── gen_ai content builders ──────────────────────────────────────────────────
582
+ /** System instructions as `[{type:"text", content}]` per the gen_ai shape. */
583
+ function buildSystemInstructions(ev) {
584
+ const system = ev.systemPrompt ?? ev.system;
585
+ if (typeof system !== "string" || system.length === 0)
586
+ return undefined;
587
+ return [{ type: "text", content: system }];
372
588
  }
373
- function extractLlmOutput(event) {
374
- const ev = asRecord(event);
375
- if (!ev)
376
- return { model: null, tokensIn: null, tokensOut: null };
377
- const usage = asRecord(ev.usage);
378
- return {
379
- output: {
380
- assistantTexts: sanitizePayloadValue(ev.assistantTexts),
381
- lastAssistant: sanitizePayloadValue(ev.lastAssistant),
382
- content: sanitizePayloadValue(ev.content),
383
- finishReason: ev.finishReason ?? ev.stopReason,
384
- },
385
- model: nonEmptyString(ev.model) ?? null,
386
- // Token field naming varies by provider and by how OpenClaw shapes the
387
- // usage object. OpenClaw normalizes most hook-path traffic to `input` /
388
- // `output` (see normalizeUsage in session-cost-usage-*.js), but raw
389
- // SDK shapes leak through on edge paths and direct-SDK callers. We try
390
- // every known spelling so the count is captured regardless:
391
- // Anthropic SDK → input_tokens / output_tokens
392
- // OpenAI SDK → prompt_tokens / completion_tokens (snake_case is canonical)
393
- // Wrapper layers → promptTokens / completionTokens (some abstractions)
394
- // OpenClaw / Gemini → input / output (normalized)
395
- tokensIn: firstNumber(usage?.input_tokens, usage?.prompt_tokens, usage?.promptTokens, usage?.input),
396
- tokensOut: firstNumber(usage?.output_tokens, usage?.completion_tokens, usage?.completionTokens, usage?.output),
397
- };
398
- }
399
- function firstNumber(...values) {
400
- for (const v of values) {
401
- if (typeof v === "number" && Number.isFinite(v))
402
- return v;
589
+ /** Input messages: the history (or messages), with any standalone `prompt` as a user turn. */
590
+ function buildInputMessages(ev) {
591
+ const history = ev.historyMessages ?? ev.messages;
592
+ const messages = Array.isArray(history) ? [...history] : history ? [history] : [];
593
+ if (typeof ev.prompt === "string" && ev.prompt.length > 0) {
594
+ messages.push({ role: "user", content: ev.prompt });
403
595
  }
404
- return null;
596
+ return normalizeGenAiMessages(messages, "user");
597
+ }
598
+ /** Output messages: the assistant text(s) / content, with the finish reason on the message. */
599
+ function buildOutputMessages(ev) {
600
+ const content = ev.assistantTexts ?? ev.lastAssistant ?? ev.content;
601
+ if (content === undefined || content === null)
602
+ return undefined;
603
+ const finish = ev.finishReason ?? ev.stopReason;
604
+ return normalizeGenAiMessages([{ role: "assistant", content, ...(typeof finish === "string" ? { finish_reason: finish } : {}) }], "assistant");
405
605
  }
406
606
  function errMsg(err) {
407
607
  return err instanceof Error ? err.message : String(err);