1717// - `pino` logger — opencode plugins log via `client.app.log`; the plugin passes
1818// in a logger callback.
1919
20- import { type Context , type Span , trace } from "@opentelemetry/api"
20+ import { type Context , type Span , SpanStatusCode , trace } from "@opentelemetry/api"
2121import {
2222 BatchSpanProcessor ,
2323 type ReadableSpan ,
@@ -39,6 +39,7 @@ import { otelSpanIdToUUID, type StringUUID } from "./utils"
3939
4040const SDK_VERSION = "bcode-laminar-0.1"
4141const SPAWNING_TOOL_NAMES = [ "task" ]
42+ const TOOL_ERROR = "bcode.tool.error"
4243type LogFn = ( level : "debug" | "info" | "warn" | "error" , message : string ) => void
4344
4445export class OpenCodeLaminarSpanProcessor implements SpanProcessor {
@@ -153,6 +154,21 @@ export class OpenCodeLaminarSpanProcessor implements SpanProcessor {
153154 }
154155
155156 onEnd ( span : ReadableSpan ) : void {
157+ // A tool that throws is normal agent flow, not a failed run: the model
158+ // reads the `tool-error` result and adapts. The AI SDK still stamps ERROR
159+ // on the `ai.toolCall` span (`recordErrorOnSpan`), and Laminar reds a
160+ // whole trace if ANY of its spans is ERROR — so every run where the model
161+ // wrote one buggy `browser_execute` snippet was reported as a failure.
162+ // Demote to UNSET, keeping the message as an attribute and the recorded
163+ // `exception` event untouched. Only the `turn` span decides run outcome.
164+ //
165+ // Mutated in place because `setStatus`/`setAttribute` are no-ops once the
166+ // span has ended, which it has by the time onEnd runs.
167+ if ( span . attributes [ "ai.toolCall.id" ] && span . status . code === SpanStatusCode . ERROR ) {
168+ Object . assign ( span . attributes , { [ TOOL_ERROR ] : span . status . message ?? "" } )
169+ Object . assign ( span , { status : { code : SpanStatusCode . UNSET } } )
170+ }
171+
156172 const spanId = span . spanContext ( ) . spanId
157173 this . spanIdLists . delete ( spanId )
158174 this . spanIdToPath . delete ( spanId )
0 commit comments