@softprobe/opencode-plugin 0.1.0 → 0.1.5
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
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session relationship data for OpenCode sub-agent nesting.
|
|
3
|
+
*
|
|
4
|
+
* Pure bookkeeping — no spans, no I/O, no timers. The tracer consults this
|
|
5
|
+
* graph to decide whether a session is a sub-agent child and, if so, which
|
|
6
|
+
* task tool call in the parent session dispatched it.
|
|
7
|
+
*
|
|
8
|
+
* Binding sources, in order of authority:
|
|
9
|
+
* 1. `bind(source: "metadata")` from the task tool part's
|
|
10
|
+
* `state.metadata.sessionId`. OpenCode publishes this while the part is
|
|
11
|
+
* still running, before the child session starts — exact and safe under
|
|
12
|
+
* concurrent task calls.
|
|
13
|
+
* 2. Task args `task_id` (session resume) — the child session is named
|
|
14
|
+
* explicitly, matched in {@link resolveTaskCall}.
|
|
15
|
+
* 3. Heuristic inference from parentID + task call candidates (unique
|
|
16
|
+
* candidate → agent name → prompt text), for OpenCode versions that do
|
|
17
|
+
* not publish part metadata. Never resolves on ambiguity: a wrong
|
|
18
|
+
* nesting is silent data corruption, a missing one is only a missing
|
|
19
|
+
* edge.
|
|
20
|
+
*/
|
|
21
|
+
/** Window in which an ended task call stays a valid inference candidate. */
|
|
22
|
+
const RECENTLY_ENDED_TASK_MS = 15 * 60 * 1000;
|
|
23
|
+
/** Ended task calls are pruned after a day; active ones are never pruned. */
|
|
24
|
+
const ENDED_TASK_CALL_TTL_MS = 24 * 60 * 60 * 1000;
|
|
25
|
+
/** Safety cap on remembered task calls (active + recently ended). */
|
|
26
|
+
const MAX_TASK_CALLS = 500;
|
|
27
|
+
/** Extract the task-tool args relevant for child-session binding. */
|
|
28
|
+
export function parseTaskCallArgs(args) {
|
|
29
|
+
if (!args || typeof args !== "object")
|
|
30
|
+
return {};
|
|
31
|
+
const record = args;
|
|
32
|
+
const out = {};
|
|
33
|
+
if (typeof record.prompt === "string")
|
|
34
|
+
out.prompt = record.prompt;
|
|
35
|
+
if (typeof record.subagent_type === "string") {
|
|
36
|
+
out.subagent_type = record.subagent_type;
|
|
37
|
+
}
|
|
38
|
+
if (typeof record.task_id === "string")
|
|
39
|
+
out.task_id = record.task_id;
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
export class SessionGraph {
|
|
43
|
+
/** child sessionID → parent sessionID (from session events or API lookup). */
|
|
44
|
+
parents = new Map();
|
|
45
|
+
/** Sessions known to have no parent. */
|
|
46
|
+
roots = new Set();
|
|
47
|
+
/** child sessionID → dispatching task call. */
|
|
48
|
+
bindings = new Map();
|
|
49
|
+
/** task callID → child sessionID (reverse of bindings). */
|
|
50
|
+
childByTaskCall = new Map();
|
|
51
|
+
/** task callID → call record (args for inference, times for recency). */
|
|
52
|
+
taskCalls = new Map();
|
|
53
|
+
registerParent(childSessionID, parentSessionID) {
|
|
54
|
+
if (childSessionID === parentSessionID)
|
|
55
|
+
return;
|
|
56
|
+
this.roots.delete(childSessionID);
|
|
57
|
+
this.parents.set(childSessionID, parentSessionID);
|
|
58
|
+
}
|
|
59
|
+
markRoot(sessionID) {
|
|
60
|
+
if (this.parents.has(sessionID) || this.bindings.has(sessionID))
|
|
61
|
+
return;
|
|
62
|
+
this.roots.add(sessionID);
|
|
63
|
+
}
|
|
64
|
+
isRoot(sessionID) {
|
|
65
|
+
return this.roots.has(sessionID);
|
|
66
|
+
}
|
|
67
|
+
parentOf(sessionID) {
|
|
68
|
+
return this.parents.get(sessionID);
|
|
69
|
+
}
|
|
70
|
+
registerTaskCall(callID, sessionID, args) {
|
|
71
|
+
const existing = this.taskCalls.get(callID);
|
|
72
|
+
if (existing) {
|
|
73
|
+
existing.args = { ...existing.args, ...parseTaskCallArgs(args) };
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
this.taskCalls.set(callID, {
|
|
77
|
+
callID,
|
|
78
|
+
sessionID,
|
|
79
|
+
args: parseTaskCallArgs(args),
|
|
80
|
+
startedAt: Date.now(),
|
|
81
|
+
});
|
|
82
|
+
this.pruneTaskCalls();
|
|
83
|
+
}
|
|
84
|
+
endTaskCall(callID) {
|
|
85
|
+
const task = this.taskCalls.get(callID);
|
|
86
|
+
if (task && task.endedAt === undefined)
|
|
87
|
+
task.endedAt = Date.now();
|
|
88
|
+
}
|
|
89
|
+
bind(binding) {
|
|
90
|
+
if (binding.childSessionID === binding.parentSessionID)
|
|
91
|
+
return;
|
|
92
|
+
const existing = this.bindings.get(binding.childSessionID);
|
|
93
|
+
if (existing?.source === "metadata" && binding.source === "inferred") {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (existing && existing.taskCallID !== binding.taskCallID) {
|
|
97
|
+
this.childByTaskCall.delete(existing.taskCallID);
|
|
98
|
+
}
|
|
99
|
+
this.roots.delete(binding.childSessionID);
|
|
100
|
+
this.parents.set(binding.childSessionID, binding.parentSessionID);
|
|
101
|
+
this.bindings.set(binding.childSessionID, binding);
|
|
102
|
+
this.childByTaskCall.set(binding.taskCallID, binding.childSessionID);
|
|
103
|
+
}
|
|
104
|
+
bindingFor(childSessionID) {
|
|
105
|
+
return this.bindings.get(childSessionID);
|
|
106
|
+
}
|
|
107
|
+
childForTaskCall(callID) {
|
|
108
|
+
return this.childByTaskCall.get(callID);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Infer which task call in `parentSessionID` dispatched `childSessionID`.
|
|
112
|
+
* Returns undefined on any ambiguity — never guesses.
|
|
113
|
+
*/
|
|
114
|
+
resolveTaskCall(childSessionID, parentSessionID, hints = {}) {
|
|
115
|
+
const now = Date.now();
|
|
116
|
+
const candidates = [...this.taskCalls.values()].filter((task) => task.sessionID === parentSessionID && this.isCandidate(task, now));
|
|
117
|
+
if (candidates.length === 0)
|
|
118
|
+
return undefined;
|
|
119
|
+
// task_id resume names the child session explicitly — unambiguous even
|
|
120
|
+
// with several candidates.
|
|
121
|
+
const resumed = candidates.filter((task) => task.args.task_id === childSessionID);
|
|
122
|
+
if (resumed.length > 0) {
|
|
123
|
+
return resumed.reduce((a, b) => (a.startedAt >= b.startedAt ? a : b))
|
|
124
|
+
.callID;
|
|
125
|
+
}
|
|
126
|
+
if (candidates.length === 1) {
|
|
127
|
+
const only = candidates[0];
|
|
128
|
+
// A lone candidate that contradicts the hints is not a match — the
|
|
129
|
+
// child was likely dispatched by a call we never saw.
|
|
130
|
+
if (hints.agent &&
|
|
131
|
+
only.args.subagent_type &&
|
|
132
|
+
only.args.subagent_type !== hints.agent) {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
if (hints.prompt && only.args.prompt && only.args.prompt !== hints.prompt) {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
return only.callID;
|
|
139
|
+
}
|
|
140
|
+
if (hints.agent) {
|
|
141
|
+
const byAgent = candidates.filter((task) => task.args.subagent_type === hints.agent);
|
|
142
|
+
if (byAgent.length === 1)
|
|
143
|
+
return byAgent[0].callID;
|
|
144
|
+
}
|
|
145
|
+
if (hints.prompt) {
|
|
146
|
+
const byPrompt = candidates.filter((task) => task.args.prompt === hints.prompt);
|
|
147
|
+
if (byPrompt.length === 1)
|
|
148
|
+
return byPrompt[0].callID;
|
|
149
|
+
}
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Drop relationship data owned by a deleted session: its own parent link,
|
|
154
|
+
* root mark, task calls, and bindings dispatched by it (its children lose
|
|
155
|
+
* their dispatcher).
|
|
156
|
+
*/
|
|
157
|
+
evictSession(sessionID) {
|
|
158
|
+
this.parents.delete(sessionID);
|
|
159
|
+
this.roots.delete(sessionID);
|
|
160
|
+
this.dropBinding(sessionID);
|
|
161
|
+
// Children of the deleted session lose their dispatcher and parent link.
|
|
162
|
+
for (const [child, parent] of this.parents) {
|
|
163
|
+
if (parent === sessionID)
|
|
164
|
+
this.parents.delete(child);
|
|
165
|
+
}
|
|
166
|
+
for (const binding of [...this.bindings.values()]) {
|
|
167
|
+
if (binding.parentSessionID === sessionID) {
|
|
168
|
+
this.dropBinding(binding.childSessionID);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
for (const [callID, task] of this.taskCalls) {
|
|
172
|
+
if (task.sessionID === sessionID)
|
|
173
|
+
this.taskCalls.delete(callID);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
dropBinding(childSessionID) {
|
|
177
|
+
const binding = this.bindings.get(childSessionID);
|
|
178
|
+
if (!binding)
|
|
179
|
+
return;
|
|
180
|
+
this.bindings.delete(childSessionID);
|
|
181
|
+
this.childByTaskCall.delete(binding.taskCallID);
|
|
182
|
+
}
|
|
183
|
+
isCandidate(task, now) {
|
|
184
|
+
return (task.endedAt === undefined || now - task.endedAt <= RECENTLY_ENDED_TASK_MS);
|
|
185
|
+
}
|
|
186
|
+
pruneTaskCalls() {
|
|
187
|
+
const now = Date.now();
|
|
188
|
+
for (const [callID, task] of this.taskCalls) {
|
|
189
|
+
if (task.endedAt !== undefined && now - task.endedAt > ENDED_TASK_CALL_TTL_MS) {
|
|
190
|
+
this.taskCalls.delete(callID);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (this.taskCalls.size <= MAX_TASK_CALLS)
|
|
194
|
+
return;
|
|
195
|
+
const ended = [...this.taskCalls.values()]
|
|
196
|
+
.filter((task) => task.endedAt !== undefined)
|
|
197
|
+
.sort((a, b) => a.endedAt - b.endedAt);
|
|
198
|
+
for (const task of ended) {
|
|
199
|
+
if (this.taskCalls.size <= MAX_TASK_CALLS)
|
|
200
|
+
break;
|
|
201
|
+
this.taskCalls.delete(task.callID);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
package/dist/softprobe.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { SoftprobeClient, type Generation, type JsonValue, type Observation, type SoftprobeClientOptions } from "@softprobe/tracing";
|
|
2
|
-
import type { AssistantGenerationInput, MessagePart, SessionErrorInfo, UserMessageInput } from "./types.js";
|
|
3
|
-
export type SoftprobeSessionTracerOptions = SoftprobeClientOptions
|
|
2
|
+
import type { AssistantGenerationInput, MessagePart, SessionErrorInfo, SessionLookup, UserMessageInput } from "./types.js";
|
|
3
|
+
export type SoftprobeSessionTracerOptions = SoftprobeClientOptions & {
|
|
4
|
+
/** OpenCode client API lookup used to classify unknown sessions lazily. */
|
|
5
|
+
sessionLookup?: SessionLookup;
|
|
6
|
+
};
|
|
4
7
|
/**
|
|
5
8
|
* Maps OpenCode session hooks to Softprobe Part A observations.
|
|
6
9
|
* Construct with an injected SoftprobeClient (tests) or via {@link createSoftprobeSessionTracer}.
|
|
@@ -8,6 +11,18 @@ export type SoftprobeSessionTracerOptions = SoftprobeClientOptions;
|
|
|
8
11
|
export declare class SoftprobeSessionTracer {
|
|
9
12
|
readonly client: SoftprobeClient;
|
|
10
13
|
private readonly userId?;
|
|
14
|
+
private readonly sessionLookup?;
|
|
15
|
+
/** Sub-agent session relationships (parent links + task-call bindings). */
|
|
16
|
+
private readonly sessionGraph;
|
|
17
|
+
/** In-flight classification promises, deduped per session. */
|
|
18
|
+
private readonly classifying;
|
|
19
|
+
/**
|
|
20
|
+
* Recently ended task spans, kept so late-starting child sessions can still
|
|
21
|
+
* be parented under them (OTEL allows ended spans as parents).
|
|
22
|
+
*/
|
|
23
|
+
private readonly endedTaskSpans;
|
|
24
|
+
/** messageID → sessionID index enabling per-session state cleanup. */
|
|
25
|
+
private readonly messageSession;
|
|
11
26
|
private readonly abortedSessions;
|
|
12
27
|
private readonly tracedMessageIds;
|
|
13
28
|
private readonly tracedGenerationIds;
|
|
@@ -24,12 +39,73 @@ export declare class SoftprobeSessionTracer {
|
|
|
24
39
|
private readonly generationParents;
|
|
25
40
|
constructor(client: SoftprobeClient, options?: {
|
|
26
41
|
userId?: string;
|
|
42
|
+
sessionLookup?: SessionLookup;
|
|
27
43
|
});
|
|
28
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Clear bookkeeping for ended spans. With a sessionID, only that session's
|
|
46
|
+
* state is cleared (a sub-agent session going idle must not disturb the
|
|
47
|
+
* parent session's in-flight spans).
|
|
48
|
+
*
|
|
49
|
+
* Only payload caches are cleared, in both the scoped and the global path.
|
|
50
|
+
* Dedup sets are never cleared here — they are capped instead (see
|
|
51
|
+
* {@link BoundedSet}) — so a late duplicate delivery can never recreate a
|
|
52
|
+
* span that was already force-ended, on scoped and legacy hosts alike.
|
|
53
|
+
*/
|
|
54
|
+
clearTraceState(sessionID?: string): void;
|
|
29
55
|
endActiveToolObservations(sessionID?: string, error?: SessionErrorInfo): void;
|
|
30
56
|
endActiveGenerationSteps(sessionID?: string, error?: SessionErrorInfo): void;
|
|
31
|
-
endActiveTurnObservations(): void;
|
|
32
|
-
|
|
57
|
+
endActiveTurnObservations(sessionID?: string): void;
|
|
58
|
+
/**
|
|
59
|
+
* End open observations and flush bookkeeping. With a sessionID, only that
|
|
60
|
+
* session is finalized; without one (dispose/shutdown, or hosts whose idle
|
|
61
|
+
* event carries no sessionID) everything is finalized.
|
|
62
|
+
*/
|
|
63
|
+
finalizeSessionTracing(sessionID?: string): void;
|
|
64
|
+
/**
|
|
65
|
+
* Register session genealogy reported by OpenCode (`session.created` /
|
|
66
|
+
* `session.updated` carry `info.parentID` for task-tool child sessions).
|
|
67
|
+
*/
|
|
68
|
+
registerSessionInfo(sessionID: string, parentID?: string | null): void;
|
|
69
|
+
/** Drop relationship data for a deleted session (`session.deleted`). */
|
|
70
|
+
evictSession(sessionID: string): void;
|
|
71
|
+
/**
|
|
72
|
+
* Ensure a session is classified (root vs sub-agent child) before its first
|
|
73
|
+
* spans are created. Resolves the parent from prior events or, failing
|
|
74
|
+
* that, the OpenCode client API, then tries to bind the session to the
|
|
75
|
+
* dispatching task call. Idempotent and deduplicated while in flight.
|
|
76
|
+
*/
|
|
77
|
+
ensureSessionClassified(sessionID: string, hints?: {
|
|
78
|
+
agent?: string;
|
|
79
|
+
promptText?: string;
|
|
80
|
+
}): Promise<void>;
|
|
81
|
+
private classifySession;
|
|
82
|
+
/**
|
|
83
|
+
* Bind a child session to the task call that dispatched it, when the call
|
|
84
|
+
* can be identified unambiguously. Bindings from task-part metadata are
|
|
85
|
+
* authoritative and are never replaced by inference.
|
|
86
|
+
*/
|
|
87
|
+
private tryBindTask;
|
|
88
|
+
/**
|
|
89
|
+
* Capture the authoritative child-session link published on the task tool
|
|
90
|
+
* part (`state.metadata.sessionId`, present while the part is running,
|
|
91
|
+
* before the child session starts).
|
|
92
|
+
*/
|
|
93
|
+
private captureTaskBinding;
|
|
94
|
+
/** Move an ended task span into the archive used for late child attach. */
|
|
95
|
+
private archiveTaskSpan;
|
|
96
|
+
/**
|
|
97
|
+
* Locate the span of the task call that dispatched a child session.
|
|
98
|
+
*
|
|
99
|
+
* Direct lookup only. A previous version fell back to "the unique active
|
|
100
|
+
* task span in the parent session" when the bound call had no span, which
|
|
101
|
+
* could parent a child under an unrelated sibling task. Its justification —
|
|
102
|
+
* a path keying the task span by part.id while the binding carries a
|
|
103
|
+
* different callID — does not exist: `ToolPart.callID` is required in the
|
|
104
|
+
* SDK, and traceToolPart and captureTaskBinding derive the id from the same
|
|
105
|
+
* expression, so the two can never diverge. Callers fall back to the parent
|
|
106
|
+
* session's turn when this returns undefined.
|
|
107
|
+
*/
|
|
108
|
+
private taskSpanFor;
|
|
33
109
|
forceFlush(): Promise<void>;
|
|
34
110
|
shutdown(): Promise<void>;
|
|
35
111
|
private getTurn;
|
|
@@ -89,8 +165,10 @@ export declare class SoftprobeSessionTracer {
|
|
|
89
165
|
*/
|
|
90
166
|
private toolParent;
|
|
91
167
|
/**
|
|
92
|
-
* Capture tool observations from message.part.updated
|
|
93
|
-
*
|
|
168
|
+
* Capture tool observations from message.part.updated.
|
|
169
|
+
* Also the canonical end path for MCP tools: tool.execute.after often
|
|
170
|
+
* receives raw CallToolResult without `output`, so we leave the span
|
|
171
|
+
* open until this completed/error part update arrives.
|
|
94
172
|
*/
|
|
95
173
|
traceToolPart(part: MessagePart): void;
|
|
96
174
|
traceToolStart(input: {
|