@softprobe/opencode-plugin 0.1.1 → 0.1.6
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 +16 -0
- package/dist/bounded.d.ts +29 -0
- package/dist/bounded.js +62 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +225 -98
- package/dist/session-graph.d.ts +72 -0
- package/dist/session-graph.js +204 -0
- package/dist/softprobe.d.ts +85 -7
- package/dist/softprobe.js +327 -39
- package/dist/types.d.ts +62 -0
- package/package.json +4 -3
package/dist/softprobe.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { SoftprobeClient, recordToolCalls, } from "@softprobe/tracing";
|
|
2
|
+
import { BoundedSet } from "./bounded.js";
|
|
3
|
+
import { SessionGraph, parseTaskCallArgs, } from "./session-graph.js";
|
|
2
4
|
import { inferToolKind } from "./tool-kind.js";
|
|
3
5
|
function asJson(value) {
|
|
4
6
|
if (value === null ||
|
|
@@ -12,6 +14,23 @@ function asJson(value) {
|
|
|
12
14
|
}
|
|
13
15
|
return String(value);
|
|
14
16
|
}
|
|
17
|
+
/** Dedup-set key scoped to a session so per-session cleanup stays precise. */
|
|
18
|
+
function scopedKey(sessionID, id) {
|
|
19
|
+
return `${sessionID}:${id}`;
|
|
20
|
+
}
|
|
21
|
+
/** Cap on remembered ended task spans (attachment targets for late children). */
|
|
22
|
+
const ENDED_TASK_SPAN_CAP = 200;
|
|
23
|
+
/**
|
|
24
|
+
* Cap per dedup set.
|
|
25
|
+
*
|
|
26
|
+
* This bounds memory but does not make eviction free: if an evicted callID
|
|
27
|
+
* later receives a duplicate delivery, its span is recreated — two spans for
|
|
28
|
+
* one tool call, the exact failure the dedup set exists to prevent. The cap
|
|
29
|
+
* raises the threshold rather than removing the failure, so it is set far
|
|
30
|
+
* above what any live session produces; reaching it needs thousands of calls
|
|
31
|
+
* between a span's force-end and a late duplicate for that same call.
|
|
32
|
+
*/
|
|
33
|
+
const TRACED_ID_CAP = 10_000;
|
|
15
34
|
function formatUserParts(parts) {
|
|
16
35
|
const formatted = parts.map((part) => {
|
|
17
36
|
if (part.type === "text") {
|
|
@@ -73,13 +92,32 @@ function getCompletedReasoningTimestamp(part) {
|
|
|
73
92
|
export class SoftprobeSessionTracer {
|
|
74
93
|
client;
|
|
75
94
|
userId;
|
|
95
|
+
sessionLookup;
|
|
96
|
+
/** Sub-agent session relationships (parent links + task-call bindings). */
|
|
97
|
+
sessionGraph = new SessionGraph();
|
|
98
|
+
/** In-flight classification promises, deduped per session. */
|
|
99
|
+
classifying = new Map();
|
|
100
|
+
/**
|
|
101
|
+
* Recently ended task spans, kept so late-starting child sessions can still
|
|
102
|
+
* be parented under them (OTEL allows ended spans as parents).
|
|
103
|
+
*/
|
|
104
|
+
endedTaskSpans = new Map();
|
|
105
|
+
/** messageID → sessionID index enabling per-session state cleanup. */
|
|
106
|
+
messageSession = new Map();
|
|
76
107
|
abortedSessions = new Set();
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
108
|
+
// Dedup keys outlive every scoped finalize (see clearTraceState), so they
|
|
109
|
+
// are capped rather than cleared. Only ids far past their session's end can
|
|
110
|
+
// fall out, and re-tracing one of those is harmless.
|
|
111
|
+
tracedMessageIds = new BoundedSet(TRACED_ID_CAP);
|
|
112
|
+
tracedGenerationIds = new BoundedSet(TRACED_ID_CAP);
|
|
113
|
+
tracedEventIds = new BoundedSet(TRACED_ID_CAP);
|
|
114
|
+
tracedReasoningIds = new BoundedSet(TRACED_ID_CAP);
|
|
115
|
+
tracedToolCallIds = new BoundedSet(TRACED_ID_CAP);
|
|
82
116
|
pendingReasoningByMessageId = new Map();
|
|
117
|
+
// Deliberately unbounded: every SDK part shape declares sessionID, so every
|
|
118
|
+
// entry is indexed in messageSession and cleared with its session. A cap
|
|
119
|
+
// here could only drop parts a pending traceGeneration still needs — losing
|
|
120
|
+
// the turn's and the generation's output — never save memory.
|
|
83
121
|
assistantParts = new Map();
|
|
84
122
|
generationByMessageId = new Map();
|
|
85
123
|
turnByMessageId = new Map();
|
|
@@ -90,23 +128,56 @@ export class SoftprobeSessionTracer {
|
|
|
90
128
|
constructor(client, options = {}) {
|
|
91
129
|
this.client = client;
|
|
92
130
|
this.userId = options.userId;
|
|
131
|
+
this.sessionLookup = options.sessionLookup;
|
|
93
132
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
133
|
+
/**
|
|
134
|
+
* Clear bookkeeping for ended spans. With a sessionID, only that session's
|
|
135
|
+
* state is cleared (a sub-agent session going idle must not disturb the
|
|
136
|
+
* parent session's in-flight spans).
|
|
137
|
+
*
|
|
138
|
+
* Only payload caches are cleared, in both the scoped and the global path.
|
|
139
|
+
* Dedup sets are never cleared here — they are capped instead (see
|
|
140
|
+
* {@link BoundedSet}) — so a late duplicate delivery can never recreate a
|
|
141
|
+
* span that was already force-ended, on scoped and legacy hosts alike.
|
|
142
|
+
*/
|
|
143
|
+
clearTraceState(sessionID) {
|
|
144
|
+
if (!sessionID) {
|
|
145
|
+
this.assistantParts.clear();
|
|
146
|
+
this.abortedSessions.clear();
|
|
147
|
+
this.pendingReasoningByMessageId.clear();
|
|
148
|
+
this.generationByMessageId.clear();
|
|
149
|
+
this.generationParents.clear();
|
|
150
|
+
this.turnByMessageId.clear();
|
|
151
|
+
this.latestTurnBySession.clear();
|
|
152
|
+
this.messageSession.clear();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
this.abortedSessions.delete(sessionID);
|
|
156
|
+
this.generationParents.delete(sessionID);
|
|
157
|
+
this.latestTurnBySession.delete(sessionID);
|
|
158
|
+
for (const [messageID, turn] of this.turnByMessageId) {
|
|
159
|
+
if (turn.sessionID === sessionID)
|
|
160
|
+
this.turnByMessageId.delete(messageID);
|
|
161
|
+
}
|
|
162
|
+
for (const [messageID, owner] of this.messageSession) {
|
|
163
|
+
if (owner !== sessionID)
|
|
164
|
+
continue;
|
|
165
|
+
this.messageSession.delete(messageID);
|
|
166
|
+
this.assistantParts.delete(messageID);
|
|
167
|
+
this.pendingReasoningByMessageId.delete(messageID);
|
|
168
|
+
this.generationByMessageId.delete(messageID);
|
|
169
|
+
}
|
|
105
170
|
}
|
|
106
171
|
endActiveToolObservations(sessionID, error) {
|
|
107
172
|
for (const [callID, active] of this.activeTools) {
|
|
108
173
|
if (sessionID && active.sessionID !== sessionID)
|
|
109
174
|
continue;
|
|
175
|
+
if (active.tool === "task") {
|
|
176
|
+
const child = this.sessionGraph.childForTaskCall(callID);
|
|
177
|
+
if (child) {
|
|
178
|
+
active.observation.setAttributes({ "sp.child.session.id": child });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
110
181
|
if (error && error.name !== "MessageAbortedError") {
|
|
111
182
|
active.observation.recordException(new Error(getSessionErrorMessage(error)));
|
|
112
183
|
active.observation.end({
|
|
@@ -122,6 +193,11 @@ export class SoftprobeSessionTracer {
|
|
|
122
193
|
active.observation.end();
|
|
123
194
|
}
|
|
124
195
|
this.activeTools.delete(callID);
|
|
196
|
+
// Mark as traced so a late duplicate tool end (part.completed arriving
|
|
197
|
+
// after this forced end) does not recreate the span.
|
|
198
|
+
this.tracedToolCallIds.add(scopedKey(active.sessionID, callID));
|
|
199
|
+
if (active.tool === "task")
|
|
200
|
+
this.archiveTaskSpan(callID, active);
|
|
125
201
|
}
|
|
126
202
|
}
|
|
127
203
|
endActiveGenerationSteps(sessionID, error) {
|
|
@@ -139,18 +215,165 @@ export class SoftprobeSessionTracer {
|
|
|
139
215
|
this.generationParents.delete(activeSessionID);
|
|
140
216
|
}
|
|
141
217
|
}
|
|
142
|
-
endActiveTurnObservations() {
|
|
218
|
+
endActiveTurnObservations(sessionID) {
|
|
143
219
|
for (const turn of new Set(this.latestTurnBySession.values())) {
|
|
220
|
+
if (sessionID && turn.sessionID !== sessionID)
|
|
221
|
+
continue;
|
|
144
222
|
turn.observation.end();
|
|
145
223
|
}
|
|
146
|
-
|
|
147
|
-
|
|
224
|
+
if (!sessionID) {
|
|
225
|
+
this.turnByMessageId.clear();
|
|
226
|
+
this.latestTurnBySession.clear();
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
for (const [messageID, turn] of this.turnByMessageId) {
|
|
230
|
+
if (turn.sessionID === sessionID)
|
|
231
|
+
this.turnByMessageId.delete(messageID);
|
|
232
|
+
}
|
|
233
|
+
this.latestTurnBySession.delete(sessionID);
|
|
148
234
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
235
|
+
/**
|
|
236
|
+
* End open observations and flush bookkeeping. With a sessionID, only that
|
|
237
|
+
* session is finalized; without one (dispose/shutdown, or hosts whose idle
|
|
238
|
+
* event carries no sessionID) everything is finalized.
|
|
239
|
+
*/
|
|
240
|
+
finalizeSessionTracing(sessionID) {
|
|
241
|
+
this.endActiveToolObservations(sessionID);
|
|
242
|
+
this.endActiveGenerationSteps(sessionID);
|
|
243
|
+
this.endActiveTurnObservations(sessionID);
|
|
244
|
+
this.clearTraceState(sessionID);
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Register session genealogy reported by OpenCode (`session.created` /
|
|
248
|
+
* `session.updated` carry `info.parentID` for task-tool child sessions).
|
|
249
|
+
*/
|
|
250
|
+
registerSessionInfo(sessionID, parentID) {
|
|
251
|
+
if (parentID) {
|
|
252
|
+
this.sessionGraph.registerParent(sessionID, parentID);
|
|
253
|
+
this.tryBindTask(sessionID, parentID, {});
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
this.sessionGraph.markRoot(sessionID);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/** Drop relationship data for a deleted session (`session.deleted`). */
|
|
260
|
+
evictSession(sessionID) {
|
|
261
|
+
this.sessionGraph.evictSession(sessionID);
|
|
262
|
+
for (const [callID, ended] of this.endedTaskSpans) {
|
|
263
|
+
if (ended.sessionID === sessionID)
|
|
264
|
+
this.endedTaskSpans.delete(callID);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Ensure a session is classified (root vs sub-agent child) before its first
|
|
269
|
+
* spans are created. Resolves the parent from prior events or, failing
|
|
270
|
+
* that, the OpenCode client API, then tries to bind the session to the
|
|
271
|
+
* dispatching task call. Idempotent and deduplicated while in flight.
|
|
272
|
+
*/
|
|
273
|
+
async ensureSessionClassified(sessionID, hints = {}) {
|
|
274
|
+
if (this.sessionGraph.bindingFor(sessionID) ||
|
|
275
|
+
this.sessionGraph.isRoot(sessionID)) {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const pending = this.classifying.get(sessionID);
|
|
279
|
+
if (pending)
|
|
280
|
+
return pending;
|
|
281
|
+
const task = this.classifySession(sessionID, hints).finally(() => {
|
|
282
|
+
this.classifying.delete(sessionID);
|
|
283
|
+
});
|
|
284
|
+
this.classifying.set(sessionID, task);
|
|
285
|
+
return task;
|
|
286
|
+
}
|
|
287
|
+
async classifySession(sessionID, hints) {
|
|
288
|
+
let parentID = this.sessionGraph.parentOf(sessionID);
|
|
289
|
+
if (!parentID && this.sessionLookup) {
|
|
290
|
+
try {
|
|
291
|
+
parentID = (await this.sessionLookup(sessionID)) ?? undefined;
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
parentID = undefined;
|
|
295
|
+
}
|
|
296
|
+
if (parentID)
|
|
297
|
+
this.sessionGraph.registerParent(sessionID, parentID);
|
|
298
|
+
}
|
|
299
|
+
if (!parentID) {
|
|
300
|
+
// No evidence of a parent. Treat as root; a late session.updated or
|
|
301
|
+
// task-part metadata event can still reclassify it.
|
|
302
|
+
this.sessionGraph.markRoot(sessionID);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
this.tryBindTask(sessionID, parentID, hints);
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Bind a child session to the task call that dispatched it, when the call
|
|
309
|
+
* can be identified unambiguously. Bindings from task-part metadata are
|
|
310
|
+
* authoritative and are never replaced by inference.
|
|
311
|
+
*/
|
|
312
|
+
tryBindTask(childSessionID, parentSessionID, hints) {
|
|
313
|
+
if (this.sessionGraph.bindingFor(childSessionID)?.source === "metadata") {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const taskCallID = this.sessionGraph.resolveTaskCall(childSessionID, parentSessionID, { agent: hints.agent, prompt: hints.promptText });
|
|
317
|
+
if (taskCallID) {
|
|
318
|
+
this.sessionGraph.bind({
|
|
319
|
+
childSessionID,
|
|
320
|
+
parentSessionID,
|
|
321
|
+
taskCallID,
|
|
322
|
+
source: "inferred",
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Capture the authoritative child-session link published on the task tool
|
|
328
|
+
* part (`state.metadata.sessionId`, present while the part is running,
|
|
329
|
+
* before the child session starts).
|
|
330
|
+
*/
|
|
331
|
+
captureTaskBinding(part) {
|
|
332
|
+
const metadata = part.state?.metadata;
|
|
333
|
+
const childSessionID = metadata?.["sessionId"];
|
|
334
|
+
if (typeof childSessionID !== "string" || !part.sessionID)
|
|
335
|
+
return;
|
|
336
|
+
if (childSessionID === part.sessionID)
|
|
337
|
+
return;
|
|
338
|
+
const callID = part.callID ?? part.id;
|
|
339
|
+
if (!callID)
|
|
340
|
+
return;
|
|
341
|
+
const parentSessionId = metadata?.["parentSessionId"];
|
|
342
|
+
this.sessionGraph.bind({
|
|
343
|
+
childSessionID,
|
|
344
|
+
parentSessionID: typeof parentSessionId === "string" ? parentSessionId : part.sessionID,
|
|
345
|
+
taskCallID: callID,
|
|
346
|
+
source: "metadata",
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
/** Move an ended task span into the archive used for late child attach. */
|
|
350
|
+
archiveTaskSpan(callID, active) {
|
|
351
|
+
this.sessionGraph.endTaskCall(callID);
|
|
352
|
+
this.endedTaskSpans.set(callID, {
|
|
353
|
+
observation: active.observation,
|
|
354
|
+
sessionID: active.sessionID,
|
|
355
|
+
});
|
|
356
|
+
if (this.endedTaskSpans.size > ENDED_TASK_SPAN_CAP) {
|
|
357
|
+
const oldest = this.endedTaskSpans.keys().next().value;
|
|
358
|
+
if (oldest !== undefined)
|
|
359
|
+
this.endedTaskSpans.delete(oldest);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Locate the span of the task call that dispatched a child session.
|
|
364
|
+
*
|
|
365
|
+
* Direct lookup only. A previous version fell back to "the unique active
|
|
366
|
+
* task span in the parent session" when the bound call had no span, which
|
|
367
|
+
* could parent a child under an unrelated sibling task. Its justification —
|
|
368
|
+
* a path keying the task span by part.id while the binding carries a
|
|
369
|
+
* different callID — does not exist: `ToolPart.callID` is required in the
|
|
370
|
+
* SDK, and traceToolPart and captureTaskBinding derive the id from the same
|
|
371
|
+
* expression, so the two can never diverge. Callers fall back to the parent
|
|
372
|
+
* session's turn when this returns undefined.
|
|
373
|
+
*/
|
|
374
|
+
taskSpanFor(binding) {
|
|
375
|
+
return (this.activeTools.get(binding.taskCallID)?.observation ??
|
|
376
|
+
this.endedTaskSpans.get(binding.taskCallID)?.observation);
|
|
154
377
|
}
|
|
155
378
|
async forceFlush() {
|
|
156
379
|
await this.client.forceFlush();
|
|
@@ -174,13 +397,15 @@ export class SoftprobeSessionTracer {
|
|
|
174
397
|
};
|
|
175
398
|
}
|
|
176
399
|
traceUserMessage(input) {
|
|
177
|
-
if (input.messageID &&
|
|
400
|
+
if (input.messageID &&
|
|
401
|
+
this.tracedMessageIds.has(scopedKey(input.sessionID, input.messageID))) {
|
|
178
402
|
return;
|
|
179
403
|
}
|
|
180
404
|
this.abortedSessions.delete(input.sessionID);
|
|
181
405
|
const formattedInput = formatUserParts(input.parts);
|
|
182
406
|
if (input.messageID) {
|
|
183
|
-
this.tracedMessageIds.add(input.messageID);
|
|
407
|
+
this.tracedMessageIds.add(scopedKey(input.sessionID, input.messageID));
|
|
408
|
+
this.messageSession.set(input.messageID, input.sessionID);
|
|
184
409
|
}
|
|
185
410
|
const previous = this.latestTurnBySession.get(input.sessionID);
|
|
186
411
|
if (previous) {
|
|
@@ -188,15 +413,33 @@ export class SoftprobeSessionTracer {
|
|
|
188
413
|
this.latestTurnBySession.delete(input.sessionID);
|
|
189
414
|
}
|
|
190
415
|
this.generationParents.delete(input.sessionID);
|
|
416
|
+
// Sub-agent child sessions nest under the dispatching task span (or, when
|
|
417
|
+
// the span is gone/ambiguous, the parent session's current turn). Root
|
|
418
|
+
// sessions explicitly detach from any ambient host span.
|
|
419
|
+
const binding = this.sessionGraph.bindingFor(input.sessionID);
|
|
420
|
+
const parentSessionID = binding?.parentSessionID ?? this.sessionGraph.parentOf(input.sessionID);
|
|
421
|
+
const parentObservation = (binding ? this.taskSpanFor(binding) : undefined) ??
|
|
422
|
+
(parentSessionID
|
|
423
|
+
? this.getTurn(parentSessionID)?.observation
|
|
424
|
+
: undefined);
|
|
191
425
|
const turn = this.client.startAgent({
|
|
192
426
|
name: "opencode.turn",
|
|
193
427
|
...this.sessionAttrs(input.sessionID),
|
|
194
428
|
input: formattedInput,
|
|
429
|
+
...(parentObservation
|
|
430
|
+
? { parent: parentObservation }
|
|
431
|
+
: { root: true }),
|
|
195
432
|
metadata: {
|
|
196
433
|
messageID: input.messageID ?? null,
|
|
197
434
|
agent: input.agent ?? null,
|
|
198
435
|
providerID: input.model?.providerID ?? null,
|
|
199
436
|
modelID: input.model?.modelID ?? null,
|
|
437
|
+
...(parentSessionID
|
|
438
|
+
? { "opencode.parentSessionID": parentSessionID }
|
|
439
|
+
: {}),
|
|
440
|
+
...(binding
|
|
441
|
+
? { "opencode.parentTaskCallID": binding.taskCallID }
|
|
442
|
+
: {}),
|
|
200
443
|
},
|
|
201
444
|
});
|
|
202
445
|
const observation = {
|
|
@@ -281,6 +524,8 @@ export class SoftprobeSessionTracer {
|
|
|
281
524
|
rememberAssistantPart(part) {
|
|
282
525
|
if (!part.id || !part.messageID)
|
|
283
526
|
return;
|
|
527
|
+
if (part.sessionID)
|
|
528
|
+
this.messageSession.set(part.messageID, part.sessionID);
|
|
284
529
|
const parts = this.assistantParts.get(part.messageID) ?? new Map();
|
|
285
530
|
parts.set(part.id, part);
|
|
286
531
|
this.assistantParts.set(part.messageID, parts);
|
|
@@ -324,9 +569,11 @@ export class SoftprobeSessionTracer {
|
|
|
324
569
|
traceGeneration(input) {
|
|
325
570
|
if (this.abortedSessions.has(input.sessionID))
|
|
326
571
|
return;
|
|
327
|
-
if (this.tracedGenerationIds.has(input.messageID))
|
|
572
|
+
if (this.tracedGenerationIds.has(scopedKey(input.sessionID, input.messageID))) {
|
|
328
573
|
return;
|
|
329
|
-
|
|
574
|
+
}
|
|
575
|
+
this.tracedGenerationIds.add(scopedKey(input.sessionID, input.messageID));
|
|
576
|
+
this.messageSession.set(input.messageID, input.sessionID);
|
|
330
577
|
const text = this.getAssistantText(input.messageID);
|
|
331
578
|
const output = text ? { text } : undefined;
|
|
332
579
|
const turn = this.getTurn(input.sessionID, input.parentID);
|
|
@@ -417,9 +664,10 @@ export class SoftprobeSessionTracer {
|
|
|
417
664
|
generation.end({ endTime: input.completed });
|
|
418
665
|
}
|
|
419
666
|
traceFailedGenerationStep(input) {
|
|
420
|
-
if (this.tracedGenerationIds.has(input.id))
|
|
667
|
+
if (this.tracedGenerationIds.has(scopedKey(input.sessionID, input.id))) {
|
|
421
668
|
return;
|
|
422
|
-
|
|
669
|
+
}
|
|
670
|
+
this.tracedGenerationIds.add(scopedKey(input.sessionID, input.id));
|
|
423
671
|
const step = this.activeGenerations.get(input.sessionID);
|
|
424
672
|
if (step) {
|
|
425
673
|
step.observation.recordException(new Error(input.error.message));
|
|
@@ -448,9 +696,10 @@ export class SoftprobeSessionTracer {
|
|
|
448
696
|
generation.end({ endTime: input.completed });
|
|
449
697
|
}
|
|
450
698
|
traceEvent(input) {
|
|
451
|
-
|
|
699
|
+
const eventKey = scopedKey(input.sessionID, input.id);
|
|
700
|
+
if (this.tracedEventIds.has(eventKey))
|
|
452
701
|
return;
|
|
453
|
-
this.tracedEventIds.add(
|
|
702
|
+
this.tracedEventIds.add(eventKey);
|
|
454
703
|
const parent = input.parent ??
|
|
455
704
|
this.generationParent(input.sessionID) ??
|
|
456
705
|
this.getTurn(input.sessionID)?.observation;
|
|
@@ -548,12 +797,16 @@ export class SoftprobeSessionTracer {
|
|
|
548
797
|
return this.generationParent(sessionID);
|
|
549
798
|
}
|
|
550
799
|
/**
|
|
551
|
-
* Capture tool observations from message.part.updated
|
|
552
|
-
*
|
|
800
|
+
* Capture tool observations from message.part.updated.
|
|
801
|
+
* Also the canonical end path for MCP tools: tool.execute.after often
|
|
802
|
+
* receives raw CallToolResult without `output`, so we leave the span
|
|
803
|
+
* open until this completed/error part update arrives.
|
|
553
804
|
*/
|
|
554
805
|
traceToolPart(part) {
|
|
555
806
|
if (part.type !== "tool" || !part.tool || !part.sessionID)
|
|
556
807
|
return;
|
|
808
|
+
if (part.tool === "task")
|
|
809
|
+
this.captureTaskBinding(part);
|
|
557
810
|
const callID = part.callID ?? part.id;
|
|
558
811
|
if (!callID)
|
|
559
812
|
return;
|
|
@@ -587,8 +840,25 @@ export class SoftprobeSessionTracer {
|
|
|
587
840
|
});
|
|
588
841
|
}
|
|
589
842
|
traceToolStart(input) {
|
|
590
|
-
if (this.tracedToolCallIds.has(input.callID))
|
|
843
|
+
if (this.tracedToolCallIds.has(scopedKey(input.sessionID, input.callID))) {
|
|
591
844
|
return;
|
|
845
|
+
}
|
|
846
|
+
if (input.tool === "task") {
|
|
847
|
+
const taskArgs = parseTaskCallArgs(input.args);
|
|
848
|
+
this.sessionGraph.registerTaskCall(input.callID, input.sessionID, taskArgs);
|
|
849
|
+
if (taskArgs.task_id) {
|
|
850
|
+
// task_id names the resumed child session explicitly — same
|
|
851
|
+
// authority as part metadata, and always precedes the child's next
|
|
852
|
+
// message, so stale bindings from an earlier dispatch are replaced
|
|
853
|
+
// before they can be consumed.
|
|
854
|
+
this.sessionGraph.bind({
|
|
855
|
+
childSessionID: taskArgs.task_id,
|
|
856
|
+
parentSessionID: input.sessionID,
|
|
857
|
+
taskCallID: input.callID,
|
|
858
|
+
source: "metadata",
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
}
|
|
592
862
|
const existing = this.activeTools.get(input.callID);
|
|
593
863
|
if (existing) {
|
|
594
864
|
// Same call already open (e.g. message.part.updated running before
|
|
@@ -627,7 +897,8 @@ export class SoftprobeSessionTracer {
|
|
|
627
897
|
});
|
|
628
898
|
}
|
|
629
899
|
traceToolEnd(input) {
|
|
630
|
-
|
|
900
|
+
const traceKey = scopedKey(input.sessionID, input.callID);
|
|
901
|
+
if (this.tracedToolCallIds.has(traceKey)) {
|
|
631
902
|
this.activeTools.delete(input.callID);
|
|
632
903
|
return;
|
|
633
904
|
}
|
|
@@ -644,10 +915,21 @@ export class SoftprobeSessionTracer {
|
|
|
644
915
|
if (!active)
|
|
645
916
|
return;
|
|
646
917
|
const toolStatus = input.status ?? "ok";
|
|
918
|
+
// Best-effort: the link is read once, here at end, and the ended span is
|
|
919
|
+
// never revisited. A task that finishes before OpenCode publishes its
|
|
920
|
+
// child session ships without this attribute. The child turn carries the
|
|
921
|
+
// reverse edge (sp.metadata.opencode.parentTaskCallID) in most cases but
|
|
922
|
+
// not all — an ambiguous dispatch leaves the turn with only
|
|
923
|
+
// sp.metadata.opencode.parentSessionID, which links the sessions but not
|
|
924
|
+
// the specific task call.
|
|
925
|
+
const childSessionID = input.tool === "task"
|
|
926
|
+
? this.sessionGraph.childForTaskCall(input.callID)
|
|
927
|
+
: undefined;
|
|
647
928
|
active.observation.setAttributes({
|
|
648
929
|
"sp.metadata.callID": input.callID,
|
|
649
930
|
"sp.metadata.tool": input.tool,
|
|
650
931
|
"sp.tool.status": toolStatus,
|
|
932
|
+
...(childSessionID ? { "sp.child.session.id": childSessionID } : {}),
|
|
651
933
|
});
|
|
652
934
|
active.observation.end({
|
|
653
935
|
output: { title: input.title, output: input.output },
|
|
@@ -657,7 +939,9 @@ export class SoftprobeSessionTracer {
|
|
|
657
939
|
: {}),
|
|
658
940
|
});
|
|
659
941
|
this.activeTools.delete(input.callID);
|
|
660
|
-
this.tracedToolCallIds.add(
|
|
942
|
+
this.tracedToolCallIds.add(traceKey);
|
|
943
|
+
if (input.tool === "task")
|
|
944
|
+
this.archiveTaskSpan(input.callID, active);
|
|
661
945
|
}
|
|
662
946
|
traceSessionError(input) {
|
|
663
947
|
this.endActiveToolObservations(input.sessionID, input.error);
|
|
@@ -686,8 +970,9 @@ export class SoftprobeSessionTracer {
|
|
|
686
970
|
}
|
|
687
971
|
}
|
|
688
972
|
export function createSoftprobeSessionTracer(options) {
|
|
973
|
+
const { sessionLookup, ...clientOptions } = options;
|
|
689
974
|
const client = new SoftprobeClient({
|
|
690
|
-
...
|
|
975
|
+
...clientOptions,
|
|
691
976
|
serviceName: options.serviceName ?? "opencode",
|
|
692
977
|
// OpenCode already owns experimental OTEL; keep Softprobe spans on a
|
|
693
978
|
// private provider so we never replace or disable the host tracer.
|
|
@@ -703,5 +988,8 @@ export function createSoftprobeSessionTracer(options) {
|
|
|
703
988
|
...(options.headers ?? {}),
|
|
704
989
|
},
|
|
705
990
|
});
|
|
706
|
-
return new SoftprobeSessionTracer(client, {
|
|
991
|
+
return new SoftprobeSessionTracer(client, {
|
|
992
|
+
userId: options.userId,
|
|
993
|
+
sessionLookup,
|
|
994
|
+
});
|
|
707
995
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -79,6 +79,12 @@ export type MessagePart = {
|
|
|
79
79
|
error?: string;
|
|
80
80
|
input?: unknown;
|
|
81
81
|
output?: string;
|
|
82
|
+
/**
|
|
83
|
+
* Tool-state metadata. OpenCode's task tool publishes
|
|
84
|
+
* `{ sessionId, parentSessionId }` here while the part is still running —
|
|
85
|
+
* the authoritative link between a task call and its child session.
|
|
86
|
+
*/
|
|
87
|
+
metadata?: Record<string, unknown>;
|
|
82
88
|
time?: {
|
|
83
89
|
start?: number;
|
|
84
90
|
end?: number;
|
|
@@ -96,6 +102,62 @@ export type SessionErrorInfo = {
|
|
|
96
102
|
message?: string;
|
|
97
103
|
};
|
|
98
104
|
};
|
|
105
|
+
/** Session record as it appears on `session.created` / `session.updated`. */
|
|
106
|
+
export type SessionInfo = {
|
|
107
|
+
id?: string;
|
|
108
|
+
/** Set on task-tool child sessions; absent or null on roots. */
|
|
109
|
+
parentID?: string | null;
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* Session lifecycle events the plugin consumes.
|
|
113
|
+
*
|
|
114
|
+
* Declared locally for the same reason as {@link SessionNextEvent}: the host's
|
|
115
|
+
* `Event` union resolves to `any` unless `@opencode-ai/sdk` is installed, so
|
|
116
|
+
* inline casts at each call site are unchecked *and* invisible to a rename.
|
|
117
|
+
* Keeping the shapes here at least makes them one searchable definition.
|
|
118
|
+
*/
|
|
119
|
+
export type SessionLifecycleEvent = {
|
|
120
|
+
type: "session.idle";
|
|
121
|
+
properties?: {
|
|
122
|
+
sessionID?: string;
|
|
123
|
+
};
|
|
124
|
+
} | {
|
|
125
|
+
type: "session.status";
|
|
126
|
+
properties?: {
|
|
127
|
+
sessionID?: string;
|
|
128
|
+
status?: {
|
|
129
|
+
type?: string;
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
} | {
|
|
133
|
+
type: "session.created";
|
|
134
|
+
properties?: {
|
|
135
|
+
info?: SessionInfo;
|
|
136
|
+
};
|
|
137
|
+
} | {
|
|
138
|
+
type: "session.updated";
|
|
139
|
+
properties?: {
|
|
140
|
+
info?: SessionInfo;
|
|
141
|
+
};
|
|
142
|
+
} | {
|
|
143
|
+
type: "session.deleted";
|
|
144
|
+
properties?: {
|
|
145
|
+
info?: {
|
|
146
|
+
id?: string;
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
} | {
|
|
150
|
+
type: "session.error";
|
|
151
|
+
properties?: {
|
|
152
|
+
sessionID?: string;
|
|
153
|
+
error?: SessionErrorInfo;
|
|
154
|
+
};
|
|
155
|
+
};
|
|
156
|
+
/**
|
|
157
|
+
* Resolves a session's parent sessionID via the OpenCode client API.
|
|
158
|
+
* Returns undefined when the session is a root, unknown, or the lookup fails.
|
|
159
|
+
*/
|
|
160
|
+
export type SessionLookup = (sessionID: string) => Promise<string | undefined>;
|
|
99
161
|
export type UserMessageInput = {
|
|
100
162
|
sessionID: string;
|
|
101
163
|
messageID?: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softprobe/opencode-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "OpenCode plugin that sends session telemetry to Softprobe",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"lint": "tsc -p tsconfig.json --noEmit"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@softprobe/tracing": "0.1.
|
|
28
|
+
"@softprobe/tracing": "0.1.6"
|
|
29
29
|
},
|
|
30
30
|
"peerDependencies": {
|
|
31
31
|
"@opencode-ai/plugin": ">=1.15.0",
|
|
@@ -45,7 +45,8 @@
|
|
|
45
45
|
"vitest": "^3.0.8"
|
|
46
46
|
},
|
|
47
47
|
"publishConfig": {
|
|
48
|
-
"access": "public"
|
|
48
|
+
"access": "public",
|
|
49
|
+
"provenance": true
|
|
49
50
|
},
|
|
50
51
|
"keywords": [
|
|
51
52
|
"softprobe",
|