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
97 changes: 51 additions & 46 deletions src/handlers/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,55 +42,61 @@ export function handleToolInputStarted(e: EventOf<"session.tool.input.started">,
})
}

/** Attaches tool input to the span and retains the command for terminal commit detection. */
/** Starts timing and tracing an observed tool call, and retains the command for commit detection. */
export function handleToolCalled(e: EventOf<"session.tool.called">, ctx: HandlerContext) {
const d = e.data
const meta = ctx.tracing.toolMeta.get(d.id)
const isSubagent = meta?.tool === "subagent"
const runs = d.executed
const { agentName, agentType } = getSessionAgentMeta(d.sessionID, ctx)
const inputJson = safeJson(d.input)
if (meta && (runs || isSubagent)) {
setBoundedMap(ctx.tracing.toolMeta, d.id, {
...meta,
startMs: e.created,
executionStarted: runs,
...(isSubagent && typeof d.input["agent"] === "string" ? { agent: d.input["agent"] } : {}),
})
}
if (!meta) return
const isSubagent = meta.tool === "subagent"
setBoundedMap(ctx.tracing.toolMeta, d.id, {
...meta,
startMs: e.created,
executionStarted: true,
...(isSubagent && typeof d.input["agent"] === "string" ? { agent: d.input["agent"] } : {}),
...(typeof d.input["command"] === "string" ? { command: d.input["command"] } : {}),
})

const span = runs && meta && isTraceEnabled("tool", ctx)
? ctx.tracer.startSpan(
`${ctx.tracePrefix}tool.${meta.tool}`,
{
startTime: e.created,
kind: SpanKind.INTERNAL,
attributes: {
[OPENINFERENCE_SPAN_KIND]: OpenInferenceSpanKind.TOOL,
[SESSION_ID]: d.sessionID,
[TOOL_ID]: d.id,
[TOOL_NAME]: meta.tool,
...ctx.commonAttrs,
},
if (!isTraceEnabled("tool", ctx)) return
if (isSubagent) startSubagentSpan(d.id, ctx)
else {
const span = ctx.tracer.startSpan(
`${ctx.tracePrefix}tool.${meta.tool}`,
{
startTime: e.created,
kind: SpanKind.INTERNAL,
attributes: {
[OPENINFERENCE_SPAN_KIND]: OpenInferenceSpanKind.TOOL,
[SESSION_ID]: d.sessionID,
[TOOL_ID]: d.id,
[TOOL_NAME]: meta.tool,
...ctx.commonAttrs,
},
resolveStepContext(d.sessionID, d.assistantMessageID, ctx),
)
: undefined
if (span) {
},
resolveStepContext(d.sessionID, d.assistantMessageID, ctx),
)
setBoundedMap(ctx.tracing.toolSpans, d.id, span)
setBoundedMap(ctx.tracing.toolSpanContexts, d.id, span.spanContext())
span.setAttributes({
[TOOL_PARAMETERS]: inputJson,
[INPUT_VALUE]: inputJson,
[INPUT_MIME_TYPE]: MimeType.JSON,
[AGENT_NAME]: agentName,
"agent.type": agentType,
})
}

if (meta && typeof d.input["command"] === "string") {
setBoundedMap(ctx.tracing.toolMeta, d.id, { ...ctx.tracing.toolMeta.get(d.id)!, command: d.input["command"] })
}
const { agentName, agentType } = getSessionAgentMeta(d.sessionID, ctx)
const inputJson = safeJson(d.input)
ctx.tracing.toolSpans.get(d.id)?.setAttributes({
[TOOL_PARAMETERS]: inputJson,
[INPUT_VALUE]: inputJson,
[INPUT_MIME_TYPE]: MimeType.JSON,
[AGENT_NAME]: agentName,
"agent.type": agentType,
"tool.provider_executed": isProviderExecuted(d),
})
}

type ProviderExecution = {
readonly executed?: boolean
readonly provider?: { readonly executed?: boolean }
}

function isProviderExecuted(data: ProviderExecution): boolean {
return data.provider?.executed ?? data.executed ?? false
}

function startSubagentSpan(callID: string, ctx: HandlerContext) {
Expand Down Expand Up @@ -140,36 +146,35 @@ export function handleToolProgress(e: EventOf<"session.tool.progress">, ctx: Han
export function handleToolSuccess(e: EventOf<"session.tool.success">, ctx: HandlerContext) {
linkSubagent(e.data.id, e.data.metadata?.["sessionID"], ctx)
const output = contentText(e.data.content)
finishTool(e.data.id, e.data.sessionID, e.created, true, e.data.executed, output, undefined, ctx)
finishTool(e.data.id, e.data.sessionID, e.created, true, output, undefined, ctx)
}

/** Ends a failed tool call: records duration, sets error attributes, and emits `tool_result`. */
export function handleToolFailed(e: EventOf<"session.tool.failed">, ctx: HandlerContext) {
linkSubagent(e.data.id, e.data.metadata?.["sessionID"], ctx)
const output = contentText(e.data.content)
finishTool(e.data.id, e.data.sessionID, e.created, false, e.data.executed, output, errorSummary(e.data.error), ctx)
finishTool(e.data.id, e.data.sessionID, e.created, false, output, errorSummary(e.data.error), ctx)
}

function finishTool(
callID: string,
sessionID: string,
endMs: number,
success: boolean,
executed: boolean,
output: string,
error: string | undefined,
ctx: HandlerContext,
) {
const meta = ctx.tracing.toolMeta.get(callID)
ctx.tracing.toolMeta.delete(callID)
const tool = meta?.tool ?? "unknown"
const observedExecution = executed || (tool === "subagent" && meta?.executionStarted === true)
const start = observedExecution && meta?.executionStarted ? meta.startMs : endMs
const observedExecution = meta?.executionStarted === true
const start = observedExecution ? meta.startMs : endMs
const durationMs = Math.max(0, endMs - start)
const { agentName, agentType } = getSessionAgentMeta(sessionID, ctx)
const sizeBytes = output ? Buffer.byteLength(output, "utf8") : 0

if (success && executed && meta?.command && SHELL_TOOL_RE.test(tool) && GIT_COMMIT_RE.test(meta.command)) {
if (success && observedExecution && meta.command && SHELL_TOOL_RE.test(tool) && GIT_COMMIT_RE.test(meta.command)) {
if (isMetricEnabled("commit.count", ctx)) {
ctx.instruments.commitCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID })
}
Expand Down
23 changes: 19 additions & 4 deletions tests/handlers/disabled.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, test, expect } from "bun:test"
import { handleExecutionStarted, handleSessionCreated, handleSessionStatus, handleUsageUpdated, finalizeSession } from "../../src/handlers/session.ts"
import { handleStepEnded, handleStepStarted } from "../../src/handlers/step.ts"
import { handleToolInputStarted } from "../../src/handlers/tool.ts"
import { handleToolCalled, handleToolInputStarted, handleToolSuccess } from "../../src/handlers/tool.ts"
import { makeCtx, evt, tokens } from "../helpers.ts"

describe("disabled metrics", () => {
Expand Down Expand Up @@ -49,11 +49,26 @@ describe("disabled traces", () => {
expect(tracer.spans.map((s) => s.name)).toEqual(["opencode.session"])
})

test("tool spans can be disabled", () => {
const { ctx, tracer } = makeCtx("proj_test", [], ["tool"])
test("tool spans can be disabled without disabling tool metrics or commit detection", () => {
const { ctx, tracer, counters, histograms, logger } = makeCtx("proj_test", [], ["tool"])
handleSessionCreated(evt("session.created", { sessionID: "ses_1" }), ctx)
handleExecutionStarted(evt("session.execution.started", { sessionID: "ses_1" }), ctx)
handleToolInputStarted(evt("session.tool.input.started", { sessionID: "ses_1", assistantMessageID: "m", id: "c", name: "read" }), ctx)
const call = { sessionID: "ses_1", assistantMessageID: "m", id: "c", executed: false }
handleToolInputStarted(evt("session.tool.input.started", { ...call, name: "shell" }, 1000), ctx)
handleToolCalled(evt("session.tool.called", { ...call, input: { command: "git commit -m 'test'" } }, 2000), ctx)
expect(tracer.spans.map((s) => s.name)).toEqual(["opencode.session"])
expect(counters.commit.calls).toHaveLength(0)
expect(logger.records.filter((record) => record.body === "commit")).toHaveLength(0)
handleToolSuccess(evt("session.tool.success", { ...call, content: [{ type: "text", text: "committed" }] }, 2250), ctx)
expect(tracer.spans.map((s) => s.name)).toEqual(["opencode.session"])
expect(histograms.tool.calls).toHaveLength(1)
expect(histograms.tool.calls[0]!.value).toBe(250)
expect(histograms.tool.calls[0]!.attrs).toMatchObject({ tool_name: "shell", success: true })
const toolResults = logger.records.filter((record) => record.body === "tool_result")
expect(toolResults).toHaveLength(1)
expect(toolResults[0]!.attributes?.["duration_ms"]).toBe(250)
expect(counters.commit.calls).toHaveLength(1)
expect(counters.commit.calls[0]!.value).toBe(1)
expect(logger.records.filter((record) => record.body === "commit")).toHaveLength(1)
})
})
2 changes: 1 addition & 1 deletion tests/handlers/spans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ describe("trace nesting", () => {
ctx,
)
handleToolInputStarted(evt("session.tool.input.started", { sessionID: "ses_1", assistantMessageID: "msg_1", id: "call_1", name: "read" }), ctx)
handleToolCalled(evt("session.tool.called", { sessionID: "ses_1", assistantMessageID: "msg_1", id: "call_1", input: {}, executed: true }), ctx)
handleToolCalled(evt("session.tool.called", { sessionID: "ses_1", assistantMessageID: "msg_1", id: "call_1", input: {}, executed: false }), ctx)

const run = tracer.spans.find((s) => s.name === "opencode.session")!
const step = tracer.spans.find((s) => s.name === "opencode.llm")!
Expand Down
Loading
Loading