Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 22 additions & 8 deletions contracts/fixtures/expected-nested-spans.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@
"sp.observation.type": "agent",
"sp.session.id": "sess-contract-1",
"sp.user.id": "user-contract-1",
"sp.tags": ["contract", "nested"],
"sp.input": "{\"goal\":\"answer question\"}"
"sp.tags": [
"contract",
"nested"
],
"sp.input": "{\"goal\":\"answer question\"}",
"gen_ai.conversation.id": "sess-contract-1"
},
"events": [],
"status_code": "OK",
Expand All @@ -20,7 +24,8 @@
"parent_name": "agent.run",
"attributes": {
"sp.observation.type": "chain",
"sp.session.id": "sess-contract-1"
"sp.session.id": "sess-contract-1",
"gen_ai.conversation.id": "sess-contract-1"
},
"events": [],
"status_code": "OK",
Expand Down Expand Up @@ -67,7 +72,9 @@
"gen_ai.request.temperature": 0.2,
"gen_ai.request.max_tokens": 128,
"gen_ai.response.id": "chatcmpl-contract-1",
"gen_ai.response.finish_reasons": ["tool_calls"],
"gen_ai.response.finish_reasons": [
"tool_calls"
],
"gen_ai.usage.input_tokens": 100,
"gen_ai.usage.output_tokens": 25,
"gen_ai.usage.total_tokens": 125,
Expand All @@ -76,13 +83,20 @@
"sp.cost.total": 0.0003,
"sp.prompt.name": "answer",
"sp.prompt.version": 1,
"sp.tool.available_names": ["lookup"],
"sp.tool.available_names": [
"lookup"
],
"sp.tool.available_count": 1,
"sp.tool.call_names": ["lookup"],
"sp.tool.call_names": [
"lookup"
],
"sp.tool.call_count": 1,
"sp.tool.call_ids": ["call_lookup_1"],
"sp.tool.call_ids": [
"call_lookup_1"
],
"sp.input": "{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}",
"sp.output": "{\"content\":null,\"tool_calls\":[{\"id\":\"call_lookup_1\",\"name\":\"lookup\",\"arguments\":\"{\\\"name\\\":\\\"lookup\\\"}\"}]}"
"sp.output": "{\"content\":null,\"tool_calls\":[{\"id\":\"call_lookup_1\",\"name\":\"lookup\",\"arguments\":\"{\\\"name\\\":\\\"lookup\\\"}\"}]}",
"gen_ai.conversation.id": "sess-contract-1"
},
"events": [
{
Expand Down
21 changes: 13 additions & 8 deletions packages/opencode-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ and used to improve agents.

## Install

Full guide: [Agent QA · OpenCode](https://docs.softprobe.ai/en/agent-qa/opencode)
(Explorer can also give you a short pasteable OpenCode chat prompt).

Enable OpenTelemetry and add the plugin in `opencode.json` / `opencode.jsonc`:

```json
Expand Down Expand Up @@ -69,16 +72,18 @@ credentials when `SPCODE_MODE` is on (see softprobe-code

### Sub-agent sessions

OpenCode runs sub-agents (the `task` tool) in child sessions. The plugin nests
each child session's turn under the dispatching task span, so a whole agent
run — root session plus any sub-agents, recursively — forms a single trace
instead of several disconnected ones. The link comes from the task part's
OpenCode runs sub-agents (the `task` tool) in child OpenCode sessions. The
plugin nests each child turn under the dispatching task span (`parent_span_id`)
and stamps the **root** OpenCode session id as `sp.session.id` /
`gen_ai.conversation.id` on every nested span — one product Session for the
whole chat. Child `ses_*` ids stay internal to the plugin graph and are never
the product session key. Linkage comes from the task part's
`state.metadata.sessionId` (authoritative), with `task_id` resume and
parentID + task-call inference as fallbacks; ambiguous dispatches (e.g.
parallel identical task calls) are left unnested rather than guessed. Each
span keeps its own `sp.session.id`; the parent side is recorded as
`sp.metadata.opencode.parentSessionID` / `sp.metadata.opencode.parentTaskCallID`
on the child turn and `sp.child.session.id` on the task span.
parallel identical task calls) are left unnested rather than guessed. Nested
turns also carry `sp.metadata.opencode.parentSessionID` /
`sp.metadata.opencode.parentTaskCallID`, and the task span records
`sp.child.session.id` for the OpenCode child session id.

Session lifecycle is isolated per session: a sub-agent session going idle
finalizes only its own spans, never the parent session's in-flight work.
Expand Down
13 changes: 13 additions & 0 deletions packages/opencode-plugin/src/session-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,19 @@ export class SessionGraph {
return this.parents.get(sessionID);
}

/** Walk parent links to the product (root) session id. */
rootSessionId(sessionID: string): string {
let current = sessionID;
const seen = new Set<string>();
while (true) {
if (seen.has(current)) return current;
seen.add(current);
const parent = this.parents.get(current);
if (!parent) return current;
current = parent;
}
}

registerTaskCall(callID: string, sessionID: string, args: TaskCallArgs): void {
const existing = this.taskCalls.get(callID);
if (existing) {
Expand Down
5 changes: 4 additions & 1 deletion packages/opencode-plugin/src/softprobe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,8 +481,11 @@ export class SoftprobeSessionTracer {
}

private sessionAttrs(sessionID: string) {
// Product session is always the root OpenCode session; child ses_* scopes
// keep their own bookkeeping keys but stamp the root on every span.
const productSessionId = this.sessionGraph.rootSessionId(sessionID);
return {
sessionId: sessionID,
sessionId: productSessionId,
...(this.userId ? { userId: this.userId } : {}),
};
}
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode-plugin/tests/session-graph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ describe("SessionGraph genealogy", () => {
graph.markRoot("root");

expect(graph.parentOf("child")).toBe("parent");
expect(graph.rootSessionId("child")).toBe("parent");
expect(graph.rootSessionId("parent")).toBe("parent");
expect(graph.isRoot("root")).toBe(true);
expect(graph.isRoot("child")).toBe(false);

Expand Down
64 changes: 46 additions & 18 deletions packages/opencode-plugin/tests/subagent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,31 @@ function attr(
| undefined;
}

function turnSpan(spans: ReadableSpan[], sessionID: string) {
function turnSpan(spans: ReadableSpan[], productSessionID: string) {
return spans.find(
(s) => s.name === "opencode.turn" && attr(s, "sp.session.id") === sessionID,
(s) =>
s.name === "opencode.turn" &&
attr(s, "sp.session.id") === productSessionID &&
!attr(s, "sp.metadata.opencode.parentSessionID"),
);
}

/** Nested sub-agent turn under the product session (stamps root sp.session.id). */
function childTurnSpan(
spans: ReadableSpan[],
productSessionID: string,
parentTaskCallID?: string,
openCodeParentSessionID: string = productSessionID,
) {
return spans.find(
(s) =>
s.name === "opencode.turn" &&
attr(s, "sp.session.id") === productSessionID &&
attr(s, "sp.metadata.opencode.parentSessionID") ===
openCodeParentSessionID &&
(parentTaskCallID
? attr(s, "sp.metadata.opencode.parentTaskCallID") === parentTaskCallID
: true),
);
}

Expand Down Expand Up @@ -119,14 +141,15 @@ describe("sub-agent nesting", () => {
const raw = exporter.getFinishedSpans();
const parentTurn = turnSpan(raw, PARENT)!;
const task = taskSpan(raw, "call-t1")!;
const childTurn = turnSpan(raw, CHILD)!;
const childTurn = childTurnSpan(raw, PARENT, "call-t1")!;

expect(childTurn.parentSpanId).toBe(task.spanContext().spanId);
expect(childTurn.spanContext().traceId).toBe(
parentTurn.spanContext().traceId,
);
// Sessions keep their own ids; the link is expressed via metadata.
expect(attr(childTurn, "sp.session.id")).toBe(CHILD);
// Product session is the root; nesting is parent_span_id under the task.
expect(attr(childTurn, "sp.session.id")).toBe(PARENT);
expect(attr(childTurn, "gen_ai.conversation.id")).toBe(PARENT);
expect(attr(childTurn, "sp.metadata.opencode.parentSessionID")).toBe(
PARENT,
);
Expand Down Expand Up @@ -261,7 +284,8 @@ describe("sub-agent nesting", () => {
const resumed = raw.find(
(s) =>
s.name === "opencode.turn" &&
attr(s, "sp.session.id") === CHILD &&
attr(s, "sp.session.id") === PARENT &&
attr(s, "sp.metadata.opencode.parentSessionID") === PARENT &&
String(attr(s, "sp.input")).includes("keep going"),
)!;
const second = taskSpan(raw, "call-t2")!;
Expand Down Expand Up @@ -292,7 +316,7 @@ describe("sub-agent nesting", () => {
await client.forceFlush();

const raw = exporter.getFinishedSpans();
expect(turnSpan(raw, CHILD)!.parentSpanId).toBe(
expect(childTurnSpan(raw, PARENT)!.parentSpanId).toBe(
taskSpan(raw, "call-t1")!.spanContext().spanId,
);

Expand All @@ -318,7 +342,7 @@ describe("sub-agent nesting", () => {
await client.forceFlush();

const raw = exporter.getFinishedSpans();
expect(turnSpan(raw, CHILD)!.parentSpanId).toBe(
expect(childTurnSpan(raw, PARENT)!.parentSpanId).toBe(
taskSpan(raw, "call-t1")!.spanContext().spanId,
);

Expand Down Expand Up @@ -348,7 +372,7 @@ describe("sub-agent nesting", () => {
await client.forceFlush();

const raw = exporter.getFinishedSpans();
const childTurn = turnSpan(raw, CHILD)!;
const childTurn = childTurnSpan(raw, PARENT)!;
// Ambiguous: falls back to the parent turn, never to a guessed task span.
expect(childTurn.parentSpanId).toBe(
turnSpan(raw, PARENT)!.spanContext().spanId,
Expand Down Expand Up @@ -388,7 +412,7 @@ describe("sub-agent nesting", () => {
await client.forceFlush();

const raw = exporter.getFinishedSpans();
expect(turnSpan(raw, CHILD)!.parentSpanId).toBe(
expect(childTurnSpan(raw, PARENT)!.parentSpanId).toBe(
taskSpan(raw, "call-t1")!.spanContext().spanId,
);

Expand Down Expand Up @@ -418,10 +442,11 @@ describe("sub-agent nesting", () => {
await client.forceFlush();

const raw = exporter.getFinishedSpans();
const grandchildTurn = turnSpan(raw, GRANDCHILD)!;
const grandchildTurn = childTurnSpan(raw, PARENT, "call-t2", CHILD)!;
expect(grandchildTurn.parentSpanId).toBe(
taskSpan(raw, "call-t2")!.spanContext().spanId,
);
expect(attr(grandchildTurn, "sp.session.id")).toBe(PARENT);
expect(grandchildTurn.spanContext().traceId).toBe(
turnSpan(raw, PARENT)!.spanContext().traceId,
);
Expand Down Expand Up @@ -489,7 +514,7 @@ describe("sub-agent nesting via OpenCode hooks", () => {

await client.forceFlush();
const raw = exporter.getFinishedSpans();
expect(turnSpan(raw, CHILD)!.parentSpanId).toBe(
expect(childTurnSpan(raw, PARENT)!.parentSpanId).toBe(
taskSpan(raw, "call-t1")!.spanContext().spanId,
);
expect(attr(taskSpan(raw, "call-t1"), "sp.output")).toContain("verified");
Expand Down Expand Up @@ -524,7 +549,7 @@ describe("binding and lifecycle edge cases", () => {
await client.forceFlush();

const raw = exporter.getFinishedSpans();
const childTurn = turnSpan(raw, CHILD)!;
const childTurn = childTurnSpan(raw, PARENT)!;
// Task A's span does not exist; task B is somebody else's sub-agent.
// Fall back to the parent turn rather than mis-parenting under B.
expect(childTurn.parentSpanId).not.toBe(
Expand Down Expand Up @@ -602,7 +627,7 @@ describe("binding and lifecycle edge cases", () => {
const raw = exporter.getFinishedSpans();
expect(attr(taskSpan(raw, "call-t1"), "sp.child.session.id")).toBeUndefined();
// The reverse edge still records the relationship.
expect(attr(turnSpan(raw, CHILD), "sp.metadata.opencode.parentTaskCallID")).toBe(
expect(attr(childTurnSpan(raw, PARENT), "sp.metadata.opencode.parentTaskCallID")).toBe(
"call-t1",
);

Expand Down Expand Up @@ -649,7 +674,8 @@ describe("binding and lifecycle edge cases", () => {
const resumed = raw.find(
(s) =>
s.name === "opencode.turn" &&
attr(s, "sp.session.id") === CHILD &&
attr(s, "sp.session.id") === PARENT &&
attr(s, "sp.metadata.opencode.parentSessionID") === PARENT &&
String(attr(s, "sp.input")).includes("keep going"),
)!;
expect(resumed.parentSpanId).toBe(
Expand Down Expand Up @@ -725,7 +751,7 @@ describe("binding and lifecycle edge cases", () => {
await client.forceFlush();

const raw = exporter.getFinishedSpans();
expect(turnSpan(raw, CHILD)!.parentSpanId).toBe(
expect(childTurnSpan(raw, PARENT)!.parentSpanId).toBe(
taskSpan(raw, "call-t1")!.spanContext().spanId,
);

Expand All @@ -745,8 +771,10 @@ describe("binding and lifecycle edge cases", () => {
await client.forceFlush();

const raw = exporter.getFinishedSpans();
const childTurn = turnSpan(raw, CHILD)!;
expect(childTurn.parentSpanId).toBeUndefined();
// Lookup failed → treated as its own product root (no parent metadata).
const orphanTurn = turnSpan(raw, CHILD)!;
expect(orphanTurn.parentSpanId).toBeUndefined();
expect(attr(orphanTurn, "sp.session.id")).toBe(CHILD);

await client.shutdown();
});
Expand Down
5 changes: 4 additions & 1 deletion packages/tracing/src/attributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,10 @@ export function buildObservationAttributes(
const metadata =
("metadata" in options ? options.metadata : undefined) ?? defaults.metadata;

if (sessionId) attrs["sp.session.id"] = sessionId;
if (sessionId) {
attrs["sp.session.id"] = sessionId;
attrs["gen_ai.conversation.id"] = sessionId;
}
if (userId) attrs["sp.user.id"] = userId;
if (release) attrs["sp.release"] = release;
if (tags) attrs["sp.tags"] = tags;
Expand Down
Loading