Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 00fb739ad6 | |||
| 9307833c5c | |||
| 2ba0817402 | |||
| f4e661f874 | |||
| aacec8ea06 | |||
| 18da3deee7 | |||
| 0b9f1a47c6 | |||
| a2e640aef9 | |||
| 88173fecd4 | |||
| a18e5de4af | |||
| 0271872c09 |
@@ -307,6 +307,7 @@ function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) {
|
||||
provider: ProviderID.make(info.providerID),
|
||||
providerMetadataKey: optionKey,
|
||||
protocol: "ai-sdk",
|
||||
operation: "chat",
|
||||
endpoint: Endpoint.path("/", { baseURL: "https://ai-sdk.local" }),
|
||||
auth: Auth.none,
|
||||
transport: {
|
||||
|
||||
@@ -2,16 +2,23 @@ export * as Observability from "./observability"
|
||||
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { Effect, Layer, Logger, References } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { Cause, Effect, Layer, Logger, References } from "effect"
|
||||
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
|
||||
import { OtlpSerialization } from "effect/unstable/observability"
|
||||
import { Logging } from "./observability/logging"
|
||||
import { Otlp } from "./observability/otlp"
|
||||
|
||||
const references = Layer.mergeAll(
|
||||
Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel()),
|
||||
Layer.succeed(References.TracerEnabled, false),
|
||||
Layer.succeed(HttpClient.TracerDisabledWhen, () => true),
|
||||
Layer.succeed(HttpClient.TracerPropagationEnabled, false),
|
||||
)
|
||||
|
||||
const local = Logger.layer(Logging.loggers(), { mergeWithExisting: false }).pipe(
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
Layer.orDie,
|
||||
Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())),
|
||||
Layer.merge(references),
|
||||
)
|
||||
|
||||
export const layer = Layer.unwrap(
|
||||
@@ -21,10 +28,10 @@ export const layer = Layer.unwrap(
|
||||
Layer.provide(OtlpSerialization.layerJson),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.orDie,
|
||||
Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())),
|
||||
Layer.merge(references),
|
||||
)
|
||||
return Layer.merge(logs, yield* Effect.promise(Otlp.tracingLayer))
|
||||
return Layer.merge(logs, yield* Otlp.tracingLayer)
|
||||
}),
|
||||
).pipe(Layer.catchCause(() => local))
|
||||
).pipe(Layer.catchCause((cause) => (Cause.hasInterrupts(cause) ? Layer.effectDiscard(Effect.failCause(cause)) : local)))
|
||||
|
||||
export const node = LayerNode.make({ name: "observability", layer, deps: [] })
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
export * as AgentTelemetry from "./agent"
|
||||
|
||||
import type { LLMEvent, Usage } from "@opencode-ai/llm"
|
||||
import { Cause, Clock, Context, Effect, Exit, Option } from "effect"
|
||||
import { ParentSpan, type Span } from "effect/Tracer"
|
||||
import {
|
||||
ATTR_ERROR_TYPE,
|
||||
ATTR_GEN_AI_AGENT_NAME,
|
||||
ATTR_GEN_AI_CONVERSATION_COMPACTED,
|
||||
ATTR_GEN_AI_CONVERSATION_ID,
|
||||
ATTR_GEN_AI_OPERATION_NAME,
|
||||
ATTR_GEN_AI_TOOL_CALL_ID,
|
||||
ATTR_GEN_AI_TOOL_NAME,
|
||||
ATTR_GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS,
|
||||
ATTR_GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS,
|
||||
ATTR_GEN_AI_USAGE_INPUT_TOKENS,
|
||||
ATTR_GEN_AI_USAGE_OUTPUT_TOKENS,
|
||||
ATTR_GEN_AI_USAGE_REASONING_OUTPUT_TOKENS,
|
||||
ATTR_OPENCODE_AGENT_STEP_INDEX,
|
||||
ATTR_OPENCODE_AGENT_STEP_TRIGGER,
|
||||
ATTR_OPENCODE_COMPACTION_REASON,
|
||||
ATTR_OPENCODE_ERROR_SOURCE,
|
||||
ATTR_OPENCODE_ERROR_STAGE,
|
||||
ATTR_OPENCODE_LINK_TYPE,
|
||||
ATTR_OPENCODE_RETRY_ATTEMPT,
|
||||
ATTR_OPENCODE_RETRY_DELAY_MS,
|
||||
ATTR_OPENCODE_RETRY_DELAY_SOURCE,
|
||||
ATTR_OPENCODE_RETRY_DECISION,
|
||||
ATTR_OPENCODE_RETRY_MAX_ATTEMPTS,
|
||||
ATTR_OPENCODE_SESSION_INPUT_COUNT,
|
||||
ATTR_OPENCODE_SESSION_INPUT_DELIVERY,
|
||||
ATTR_OPENCODE_SESSION_PARENT_ID,
|
||||
ATTR_OPENCODE_TOOL_OUTCOME,
|
||||
EVENT_OPENCODE_COMPACTION_COMPLETED,
|
||||
EVENT_OPENCODE_COMPACTION_FAILED,
|
||||
EVENT_OPENCODE_COMPACTION_STARTED,
|
||||
EVENT_OPENCODE_PROVIDER_TOOL_CALLED,
|
||||
EVENT_OPENCODE_PROVIDER_TOOL_COMPLETED,
|
||||
EVENT_OPENCODE_RETRY_SCHEDULED,
|
||||
EVENT_OPENCODE_RETRY_STOPPED,
|
||||
EVENT_OPENCODE_SESSION_INPUT_PROMOTED,
|
||||
GEN_AI_OPERATION_NAME_VALUE_INVOKE_AGENT,
|
||||
} from "./semconv"
|
||||
import { SessionTelemetry } from "./session"
|
||||
|
||||
export type ModelCallTrigger = "input" | "tool_result" | "retry" | "compaction" | "resume"
|
||||
export type RetryDecision = "exhausted" | "non_retryable" | "output_started" | "step_limit"
|
||||
|
||||
const FailureState = Context.Reference<{ stage?: string } | undefined>("@opencode/AgentTelemetry/FailureState", {
|
||||
defaultValue: () => undefined,
|
||||
})
|
||||
|
||||
export const currentSpan = Effect.option(Effect.currentSpan).pipe(
|
||||
Effect.map(Option.getOrUndefined),
|
||||
Effect.map(findAgentSpan),
|
||||
)
|
||||
|
||||
const observe = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
Effect.catchCauseIf(
|
||||
effect,
|
||||
(cause) => !Cause.hasInterrupts(cause),
|
||||
() => Effect.void,
|
||||
)
|
||||
|
||||
export const invoke = <A, E, R>(
|
||||
input: { readonly sessionID: string; readonly agent: string; readonly errorType: (cause: unknown) => string },
|
||||
effect: Effect.Effect<A, E, R>,
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
const traceParent = yield* SessionTelemetry.TraceParent
|
||||
const traceLinks = yield* SessionTelemetry.TraceLinks
|
||||
const turnLinks = yield* SessionTelemetry.TurnLinks
|
||||
const previousTurn = turnLinks?.previous()
|
||||
const failureState: { stage?: string } = {}
|
||||
return yield* Effect.acquireUseRelease(
|
||||
Effect.makeSpan(`invoke_agent ${input.agent}`, {
|
||||
kind: "internal",
|
||||
parent: traceParent ?? undefined,
|
||||
root: traceParent === null,
|
||||
links: previousTurn
|
||||
? [...traceLinks, { span: previousTurn, attributes: { [ATTR_OPENCODE_LINK_TYPE]: "previous_turn" } }]
|
||||
: traceLinks,
|
||||
attributes: {
|
||||
[ATTR_GEN_AI_OPERATION_NAME]: GEN_AI_OPERATION_NAME_VALUE_INVOKE_AGENT,
|
||||
[ATTR_GEN_AI_CONVERSATION_ID]: input.sessionID,
|
||||
[ATTR_GEN_AI_AGENT_NAME]: input.agent,
|
||||
},
|
||||
}).pipe(
|
||||
Effect.withTracerEnabled(true),
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterrupts(cause),
|
||||
() => Effect.succeed(undefined),
|
||||
),
|
||||
),
|
||||
(span) =>
|
||||
span
|
||||
? Effect.sync(() => turnLinks?.set(span)).pipe(
|
||||
Effect.andThen(
|
||||
effect.pipe(
|
||||
Effect.withParentSpan(span, { captureStackTrace: false }),
|
||||
Effect.provideService(FailureState, failureState),
|
||||
Effect.withTracerEnabled(false),
|
||||
),
|
||||
),
|
||||
)
|
||||
: effect,
|
||||
(span, exit) => span ? finishSpan(span, exit, failureState, input.errorType) : Effect.void,
|
||||
)
|
||||
})
|
||||
|
||||
function finishSpan<E>(
|
||||
span: Span,
|
||||
exit: Exit.Exit<unknown, E>,
|
||||
failureState: { stage?: string },
|
||||
errorType: (cause: unknown) => string,
|
||||
) {
|
||||
return observe(
|
||||
Effect.gen(function* () {
|
||||
if (Exit.isSuccess(exit)) {
|
||||
span.end(yield* Clock.currentTimeNanos, exit)
|
||||
return
|
||||
}
|
||||
const canceled = Cause.hasInterruptsOnly(exit.cause)
|
||||
const type = canceled ? "canceled" : yield* classify(() => errorType(Cause.squash(exit.cause)))
|
||||
const source = canceled
|
||||
? "cancellation"
|
||||
: type.startsWith("provider.")
|
||||
? "provider"
|
||||
: type.startsWith("permission.")
|
||||
? "permission"
|
||||
: type.startsWith("tool.")
|
||||
? "tool"
|
||||
: "session"
|
||||
span.attribute(ATTR_ERROR_TYPE, type)
|
||||
span.attribute(ATTR_OPENCODE_ERROR_SOURCE, source)
|
||||
if (failureState.stage) span.attribute(ATTR_OPENCODE_ERROR_STAGE, failureState.stage)
|
||||
span.end(yield* Clock.currentTimeNanos, Exit.fail(new Error(type)))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const identify = (input: { readonly agent: string; readonly parentSessionID?: string }) =>
|
||||
withCurrent((span) => {
|
||||
span.attribute(ATTR_GEN_AI_AGENT_NAME, input.agent)
|
||||
if (input.parentSessionID) span.attribute(ATTR_OPENCODE_SESSION_PARENT_ID, input.parentSessionID)
|
||||
})
|
||||
|
||||
export const stage = <A, E, R>(name: string, effect: Effect.Effect<A, E, R>) =>
|
||||
Effect.gen(function* () {
|
||||
const state = yield* FailureState
|
||||
return yield* effect.pipe(
|
||||
Effect.onExit((exit) =>
|
||||
Effect.sync(() => {
|
||||
if (Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause) && state && !state.stage) state.stage = name
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
export const resetStage = Effect.gen(function* () {
|
||||
const state = yield* FailureState
|
||||
if (state) state.stage = undefined
|
||||
})
|
||||
|
||||
export const inputPromoted = (input: { readonly delivery: "steer" | "queue"; readonly count: number }) =>
|
||||
event(EVENT_OPENCODE_SESSION_INPUT_PROMOTED, {
|
||||
[ATTR_OPENCODE_SESSION_INPUT_DELIVERY]: input.delivery,
|
||||
[ATTR_OPENCODE_SESSION_INPUT_COUNT]: input.count,
|
||||
})
|
||||
|
||||
export const compactionStarted = (reason: "manual" | "automatic") =>
|
||||
event(EVENT_OPENCODE_COMPACTION_STARTED, { [ATTR_OPENCODE_COMPACTION_REASON]: reason })
|
||||
|
||||
export const compactionCompleted = (reason: "manual" | "automatic") =>
|
||||
event(EVENT_OPENCODE_COMPACTION_COMPLETED, { [ATTR_OPENCODE_COMPACTION_REASON]: reason })
|
||||
|
||||
export const compactionFailed = (reason: "manual" | "automatic") =>
|
||||
event(EVENT_OPENCODE_COMPACTION_FAILED, { [ATTR_OPENCODE_COMPACTION_REASON]: reason })
|
||||
|
||||
export const modelCall = (input: {
|
||||
readonly sessionID: string
|
||||
readonly agent: string
|
||||
readonly step: number
|
||||
readonly trigger: ModelCallTrigger
|
||||
readonly retryAttempt?: number
|
||||
readonly delivery?: "steer" | "queue"
|
||||
readonly compacted: boolean
|
||||
}) => {
|
||||
let usage: Usage | undefined
|
||||
const observeEvent = (value: LLMEvent) =>
|
||||
Effect.gen(function* () {
|
||||
if (value.type === "tool-call" && value.providerExecuted)
|
||||
yield* event(EVENT_OPENCODE_PROVIDER_TOOL_CALLED, {
|
||||
[ATTR_GEN_AI_TOOL_CALL_ID]: value.id,
|
||||
[ATTR_GEN_AI_TOOL_NAME]: value.name,
|
||||
})
|
||||
if (value.type === "tool-result" && value.providerExecuted)
|
||||
yield* event(EVENT_OPENCODE_PROVIDER_TOOL_COMPLETED, {
|
||||
[ATTR_GEN_AI_TOOL_CALL_ID]: value.id,
|
||||
[ATTR_GEN_AI_TOOL_NAME]: value.name,
|
||||
[ATTR_OPENCODE_TOOL_OUTCOME]: value.result.type === "error" ? "error" : "completed",
|
||||
})
|
||||
if ("usage" in value && value.usage !== undefined) usage = value.usage
|
||||
if (value.type === "finish" && usage) yield* recordUsage(usage)
|
||||
})
|
||||
const run = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
Effect.gen(function* () {
|
||||
const agentSpan = yield* currentSpan
|
||||
const parentSessionID = agentSpan?.attributes.get(ATTR_OPENCODE_SESSION_PARENT_ID)
|
||||
const observed = effect.pipe(
|
||||
Effect.annotateSpans({
|
||||
[ATTR_GEN_AI_AGENT_NAME]: input.agent,
|
||||
[ATTR_GEN_AI_CONVERSATION_ID]: input.sessionID,
|
||||
[ATTR_OPENCODE_AGENT_STEP_INDEX]: input.step,
|
||||
[ATTR_OPENCODE_AGENT_STEP_TRIGGER]: input.trigger,
|
||||
...(input.retryAttempt === undefined ? {} : { [ATTR_OPENCODE_RETRY_ATTEMPT]: input.retryAttempt }),
|
||||
...(input.delivery === undefined ? {} : { [ATTR_OPENCODE_SESSION_INPUT_DELIVERY]: input.delivery }),
|
||||
...(input.compacted ? { [ATTR_GEN_AI_CONVERSATION_COMPACTED]: true } : {}),
|
||||
...(typeof parentSessionID === "string" ? { [ATTR_OPENCODE_SESSION_PARENT_ID]: parentSessionID } : {}),
|
||||
}),
|
||||
)
|
||||
if (!agentSpan) return yield* observed
|
||||
return yield* observed.pipe(Effect.withParentSpan(agentSpan, { captureStackTrace: false }))
|
||||
})
|
||||
return { observe: observeEvent, run }
|
||||
}
|
||||
|
||||
export const retryScheduled = (input: {
|
||||
readonly attempt: number
|
||||
readonly maxAttempts: number
|
||||
readonly delayMs: number
|
||||
readonly retryAfterMs?: number
|
||||
readonly errorType: string
|
||||
}) =>
|
||||
event(EVENT_OPENCODE_RETRY_SCHEDULED, {
|
||||
[ATTR_OPENCODE_RETRY_ATTEMPT]: input.attempt,
|
||||
[ATTR_OPENCODE_RETRY_MAX_ATTEMPTS]: input.maxAttempts,
|
||||
[ATTR_OPENCODE_RETRY_DELAY_MS]: input.delayMs,
|
||||
[ATTR_OPENCODE_RETRY_DELAY_SOURCE]: input.retryAfterMs === undefined ? "backoff" : "max(backoff,retry_after)",
|
||||
[ATTR_OPENCODE_RETRY_DECISION]: "scheduled",
|
||||
[ATTR_ERROR_TYPE]: input.errorType,
|
||||
})
|
||||
|
||||
export const retryStopped = (input: {
|
||||
readonly decision: RetryDecision
|
||||
readonly attempt: number
|
||||
readonly maxAttempts: number
|
||||
readonly errorType: string
|
||||
}) =>
|
||||
event(EVENT_OPENCODE_RETRY_STOPPED, {
|
||||
[ATTR_OPENCODE_RETRY_DECISION]: input.decision,
|
||||
[ATTR_OPENCODE_RETRY_ATTEMPT]: input.attempt,
|
||||
[ATTR_OPENCODE_RETRY_MAX_ATTEMPTS]: input.maxAttempts,
|
||||
[ATTR_ERROR_TYPE]: input.errorType,
|
||||
})
|
||||
|
||||
function recordUsage(usage: Usage) {
|
||||
return withCurrent((span) => {
|
||||
const add = (key: string, value: number | undefined) => {
|
||||
if (value === undefined) return
|
||||
const current = span.attributes.get(key)
|
||||
span.attribute(key, (typeof current === "number" ? current : 0) + value)
|
||||
}
|
||||
add(ATTR_GEN_AI_USAGE_INPUT_TOKENS, usage.inputTokens)
|
||||
add(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, usage.outputTokens)
|
||||
add(ATTR_GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS, usage.cacheReadInputTokens)
|
||||
add(ATTR_GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS, usage.cacheWriteInputTokens)
|
||||
add(ATTR_GEN_AI_USAGE_REASONING_OUTPUT_TOKENS, usage.reasoningTokens)
|
||||
})
|
||||
}
|
||||
|
||||
function event(name: string, attributes: Record<string, unknown>) {
|
||||
return Effect.gen(function* () {
|
||||
const span = yield* currentSpan
|
||||
if (!span) return
|
||||
const time = yield* Clock.currentTimeNanos
|
||||
yield* observe(Effect.sync(() => span.event(name, time, attributes)))
|
||||
})
|
||||
}
|
||||
|
||||
function withCurrent(f: (span: Span) => void) {
|
||||
return Effect.gen(function* () {
|
||||
const span = yield* currentSpan
|
||||
if (span) yield* observe(Effect.sync(() => f(span)))
|
||||
})
|
||||
}
|
||||
|
||||
function findAgentSpan(span: Span | undefined): Span | undefined {
|
||||
if (!span) return
|
||||
if (span.attributes.get(ATTR_GEN_AI_OPERATION_NAME) === GEN_AI_OPERATION_NAME_VALUE_INVOKE_AGENT) return span
|
||||
const parent = Option.getOrUndefined(span.parent)
|
||||
return findAgentSpan(parent?._tag === "Span" ? parent : undefined)
|
||||
}
|
||||
|
||||
function classify(f: () => string) {
|
||||
return Effect.sync(f).pipe(Effect.catchCause(() => Effect.succeed("unknown")))
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
export * as HttpTelemetry from "./http"
|
||||
|
||||
import { Cause, Clock, Effect, Exit, Option } from "effect"
|
||||
import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import {
|
||||
ATTR_ERROR_TYPE,
|
||||
ATTR_HTTP_REQUEST_METHOD,
|
||||
ATTR_HTTP_RESPONSE_STATUS_CODE,
|
||||
ATTR_SERVER_ADDRESS,
|
||||
ATTR_SERVER_PORT,
|
||||
ATTR_URL_FULL,
|
||||
ATTR_URL_PATH,
|
||||
ATTR_URL_SCHEME,
|
||||
ATTR_OPENCODE_ERROR_SOURCE,
|
||||
ATTR_OPENCODE_ERROR_STAGE,
|
||||
} from "./semconv"
|
||||
import { ToolTelemetry } from "./tool"
|
||||
import { AgentTelemetry } from "./agent"
|
||||
|
||||
export const use = <A, E, R>(
|
||||
http: HttpClient.HttpClient,
|
||||
request: HttpClientRequest.HttpClientRequest,
|
||||
consume: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect<A, E, R>,
|
||||
validate?: (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
) => Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError>,
|
||||
): Effect.Effect<A, E | HttpClientError.HttpClientError, R> =>
|
||||
Effect.gen(function* () {
|
||||
const observe = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
Effect.catchCauseIf(
|
||||
effect,
|
||||
(cause) => !Cause.hasInterrupts(cause),
|
||||
() => Effect.void,
|
||||
)
|
||||
const state: { responseStatus?: number } = {}
|
||||
const execute = http.execute(request).pipe(
|
||||
Effect.flatMap(validate ?? Effect.succeed),
|
||||
Effect.tap((response) => Effect.sync(() => (state.responseStatus = response.status))),
|
||||
Effect.flatMap(consume),
|
||||
)
|
||||
const parent =
|
||||
(yield* ToolTelemetry.currentSpan) ??
|
||||
(yield* AgentTelemetry.currentSpan) ??
|
||||
Option.getOrUndefined(yield* Effect.option(Effect.currentSpan))
|
||||
if (!parent) return yield* execute
|
||||
const url = URL.canParse(request.url) ? new URL(request.url) : undefined
|
||||
const port = url?.port
|
||||
? Number(url.port)
|
||||
: url?.protocol === "https:"
|
||||
? 443
|
||||
: url?.protocol === "http:"
|
||||
? 80
|
||||
: undefined
|
||||
const span = yield* Effect.makeSpan(request.method, {
|
||||
kind: "client",
|
||||
parent,
|
||||
attributes: {
|
||||
[ATTR_HTTP_REQUEST_METHOD]: request.method,
|
||||
...(url
|
||||
? {
|
||||
[ATTR_SERVER_ADDRESS]: url.hostname,
|
||||
...(port === undefined ? {} : { [ATTR_SERVER_PORT]: port }),
|
||||
[ATTR_URL_FULL]: safeUrl(url),
|
||||
[ATTR_URL_PATH]: url.pathname,
|
||||
[ATTR_URL_SCHEME]: url.protocol.slice(0, -1),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}).pipe(
|
||||
Effect.withTracerEnabled(true),
|
||||
Effect.catchCause(() => Effect.succeed(undefined)),
|
||||
)
|
||||
if (!span) return yield* execute
|
||||
return yield* execute.pipe(
|
||||
Effect.provideService(HttpClient.TracerPropagationEnabled, false),
|
||||
Effect.onExit((exit) =>
|
||||
observe(
|
||||
Effect.gen(function* () {
|
||||
if (state.responseStatus !== undefined)
|
||||
span.attribute(ATTR_HTTP_RESPONSE_STATUS_CODE, state.responseStatus)
|
||||
if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) {
|
||||
span.end(yield* Clock.currentTimeNanos, exit)
|
||||
return
|
||||
}
|
||||
const error = Option.getOrUndefined(Exit.findErrorOption(exit))
|
||||
const type = HttpClientError.isHttpClientError(error)
|
||||
? error.reason._tag
|
||||
: error instanceof Error
|
||||
? error.name
|
||||
: "unknown"
|
||||
const status =
|
||||
HttpClientError.isHttpClientError(error) && "response" in error.reason
|
||||
? error.reason.response.status
|
||||
: undefined
|
||||
if (status !== undefined) span.attribute(ATTR_HTTP_RESPONSE_STATUS_CODE, status)
|
||||
span.attribute(ATTR_ERROR_TYPE, type)
|
||||
span.attribute(ATTR_OPENCODE_ERROR_SOURCE, "transport")
|
||||
span.attribute(
|
||||
ATTR_OPENCODE_ERROR_STAGE,
|
||||
status !== undefined ? "response" : state.responseStatus !== undefined ? "response_stream" : "request",
|
||||
)
|
||||
span.end(yield* Clock.currentTimeNanos, Exit.fail(new Error(type)))
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.withParentSpan(span, { captureStackTrace: false }),
|
||||
Effect.withTracerEnabled(false),
|
||||
)
|
||||
})
|
||||
|
||||
function safeUrl(url: URL) {
|
||||
const safe = new URL(url)
|
||||
safe.username = ""
|
||||
safe.password = ""
|
||||
safe.search = ""
|
||||
safe.hash = ""
|
||||
return safe.toString()
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
import { Layer } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { OtlpLogger } from "effect/unstable/observability"
|
||||
import {
|
||||
ATTR_DEPLOYMENT_ENVIRONMENT_NAME,
|
||||
ATTR_OPENCODE_CLIENT,
|
||||
ATTR_OPENCODE_RUN,
|
||||
ATTR_SERVICE_INSTANCE_ID,
|
||||
} from "./semconv"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { InstallationChannel, InstallationVersion } from "../installation/version"
|
||||
import { runID } from "./shared"
|
||||
|
||||
const endpoint = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
|
||||
let installedContextManager: { readonly manager: { disable(): unknown }; references: number } | undefined
|
||||
|
||||
const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
|
||||
? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
|
||||
@@ -34,15 +41,16 @@ function resourceAttributes() {
|
||||
}
|
||||
|
||||
export function resource(): { serviceName: string; serviceVersion: string; attributes: Record<string, string> } {
|
||||
const attributes = resourceAttributes()
|
||||
return {
|
||||
serviceName: "opencode",
|
||||
serviceVersion: InstallationVersion,
|
||||
attributes: {
|
||||
...resourceAttributes(),
|
||||
"deployment.environment.name": InstallationChannel,
|
||||
"opencode.client": Flag.OPENCODE_CLIENT,
|
||||
"opencode.run": runID,
|
||||
"service.instance.id": runID,
|
||||
...attributes,
|
||||
[ATTR_DEPLOYMENT_ENVIRONMENT_NAME]: attributes[ATTR_DEPLOYMENT_ENVIRONMENT_NAME] ?? InstallationChannel,
|
||||
[ATTR_OPENCODE_CLIENT]: Flag.OPENCODE_CLIENT,
|
||||
[ATTR_OPENCODE_RUN]: runID,
|
||||
[ATTR_SERVICE_INSTANCE_ID]: runID,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -52,28 +60,53 @@ export function loggers() {
|
||||
return [OtlpLogger.make({ url: `${endpoint}/v1/logs`, resource: resource(), headers })]
|
||||
}
|
||||
|
||||
export async function tracingLayer() {
|
||||
export const tracingLayer = Effect.gen(function* () {
|
||||
if (!endpoint) return Layer.empty
|
||||
const NodeSdk = await import("@effect/opentelemetry/NodeSdk")
|
||||
const OTLP = await import("@opentelemetry/exporter-trace-otlp-http")
|
||||
const SdkBase = await import("@opentelemetry/sdk-trace-base")
|
||||
const { AsyncLocalStorageContextManager } = await import("@opentelemetry/context-async-hooks")
|
||||
const { context } = await import("@opentelemetry/api")
|
||||
const NodeSdk = yield* Effect.promise(() => import("@effect/opentelemetry/NodeSdk"))
|
||||
const OTLP = yield* Effect.promise(() => import("@opentelemetry/exporter-trace-otlp-http"))
|
||||
const SdkBase = yield* Effect.promise(() => import("@opentelemetry/sdk-trace-base"))
|
||||
const { AsyncLocalStorageContextManager } = yield* Effect.promise(() => import("@opentelemetry/context-async-hooks"))
|
||||
const { context } = yield* Effect.promise(() => import("@opentelemetry/api"))
|
||||
|
||||
// The Effect Node SDK does not register a global context manager, but the AI SDK uses it to parent spans.
|
||||
const manager = new AsyncLocalStorageContextManager()
|
||||
manager.enable()
|
||||
context.setGlobalContextManager(manager)
|
||||
|
||||
return NodeSdk.layer(() => ({
|
||||
resource: resource(),
|
||||
spanProcessor: new SdkBase.BatchSpanProcessor(
|
||||
new OTLP.OTLPTraceExporter({
|
||||
url: `${endpoint}/v1/traces`,
|
||||
headers,
|
||||
const contextManager = Layer.effectDiscard(
|
||||
Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
// The Effect Node SDK does not register a global context manager, but the AI SDK uses it to parent spans.
|
||||
if (installedContextManager) {
|
||||
installedContextManager.references += 1
|
||||
return { installed: true, manager: installedContextManager.manager }
|
||||
}
|
||||
const manager = new AsyncLocalStorageContextManager().enable()
|
||||
const installed = context.setGlobalContextManager(manager)
|
||||
if (!installed) manager.disable()
|
||||
if (installed) installedContextManager = { manager, references: 1 }
|
||||
return { installed, manager }
|
||||
}),
|
||||
({ installed, manager }) =>
|
||||
Effect.sync(() => {
|
||||
if (!installed) return
|
||||
if (installedContextManager?.manager !== manager) return
|
||||
installedContextManager.references -= 1
|
||||
if (installedContextManager.references > 0) return
|
||||
installedContextManager = undefined
|
||||
context.disable()
|
||||
manager.disable()
|
||||
}),
|
||||
),
|
||||
}))
|
||||
}
|
||||
)
|
||||
|
||||
return Layer.merge(
|
||||
contextManager,
|
||||
NodeSdk.layer(() => ({
|
||||
resource: resource(),
|
||||
spanProcessor: new SdkBase.BatchSpanProcessor(
|
||||
new OTLP.OTLPTraceExporter({
|
||||
url: `${endpoint}/v1/traces`,
|
||||
headers,
|
||||
}),
|
||||
),
|
||||
})),
|
||||
)
|
||||
})
|
||||
|
||||
export * as Otlp from "./otlp"
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
export * as CoreSemconv from "./semconv"
|
||||
|
||||
export const ATTR_DEPLOYMENT_ENVIRONMENT_NAME = "deployment.environment.name"
|
||||
export const ATTR_ERROR_TYPE = "error.type"
|
||||
export const ATTR_HTTP_REQUEST_METHOD = "http.request.method"
|
||||
export const ATTR_HTTP_RESPONSE_STATUS_CODE = "http.response.status_code"
|
||||
export const ATTR_SERVER_ADDRESS = "server.address"
|
||||
export const ATTR_SERVER_PORT = "server.port"
|
||||
export const ATTR_SERVICE_INSTANCE_ID = "service.instance.id"
|
||||
export const ATTR_SERVICE_NAMESPACE = "service.namespace"
|
||||
export const ATTR_URL_FULL = "url.full"
|
||||
export const ATTR_URL_PATH = "url.path"
|
||||
export const ATTR_URL_SCHEME = "url.scheme"
|
||||
|
||||
export const ATTR_GEN_AI_AGENT_NAME = "gen_ai.agent.name"
|
||||
export const ATTR_GEN_AI_CONVERSATION_COMPACTED = "gen_ai.conversation.compacted"
|
||||
export const ATTR_GEN_AI_CONVERSATION_ID = "gen_ai.conversation.id"
|
||||
export const ATTR_GEN_AI_OPERATION_NAME = "gen_ai.operation.name"
|
||||
export const ATTR_GEN_AI_TOOL_CALL_ID = "gen_ai.tool.call.id"
|
||||
export const ATTR_GEN_AI_TOOL_NAME = "gen_ai.tool.name"
|
||||
export const ATTR_GEN_AI_TOOL_TYPE = "gen_ai.tool.type"
|
||||
export const ATTR_GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS = "gen_ai.usage.cache_creation.input_tokens"
|
||||
export const ATTR_GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS = "gen_ai.usage.cache_read.input_tokens"
|
||||
export const ATTR_GEN_AI_USAGE_INPUT_TOKENS = "gen_ai.usage.input_tokens"
|
||||
export const ATTR_GEN_AI_USAGE_OUTPUT_TOKENS = "gen_ai.usage.output_tokens"
|
||||
export const ATTR_GEN_AI_USAGE_REASONING_OUTPUT_TOKENS = "gen_ai.usage.reasoning.output_tokens"
|
||||
|
||||
export const GEN_AI_OPERATION_NAME_VALUE_EXECUTE_TOOL = "execute_tool"
|
||||
export const GEN_AI_OPERATION_NAME_VALUE_INVOKE_AGENT = "invoke_agent"
|
||||
|
||||
export const ATTR_OPENCODE_AGENT_STEP_INDEX = "opencode.agent.step.index"
|
||||
export const ATTR_OPENCODE_AGENT_STEP_TRIGGER = "opencode.agent.step.trigger"
|
||||
export const ATTR_OPENCODE_CLIENT = "opencode.client"
|
||||
export const ATTR_OPENCODE_COMPACTION_REASON = "opencode.compaction.reason"
|
||||
export const ATTR_OPENCODE_ERROR_SOURCE = "opencode.error.source"
|
||||
export const ATTR_OPENCODE_ERROR_STAGE = "opencode.error.stage"
|
||||
export const ATTR_OPENCODE_LINK_TYPE = "opencode.link.type"
|
||||
export const ATTR_OPENCODE_RETRY_ATTEMPT = "opencode.retry.attempt"
|
||||
export const ATTR_OPENCODE_RETRY_DELAY_MS = "opencode.retry.delay_ms"
|
||||
export const ATTR_OPENCODE_RETRY_DELAY_SOURCE = "opencode.retry.delay_source"
|
||||
export const ATTR_OPENCODE_RETRY_DECISION = "opencode.retry.decision"
|
||||
export const ATTR_OPENCODE_RETRY_MAX_ATTEMPTS = "opencode.retry.max_attempts"
|
||||
export const ATTR_OPENCODE_RUN = "opencode.run"
|
||||
export const ATTR_OPENCODE_SESSION_INPUT_COUNT = "opencode.session.input.count"
|
||||
export const ATTR_OPENCODE_SESSION_INPUT_DELIVERY = "opencode.session.input.delivery"
|
||||
export const ATTR_OPENCODE_SESSION_PARENT_ID = "opencode.session.parent.id"
|
||||
export const ATTR_OPENCODE_SUBAGENT_AGENT_NAME = "opencode.subagent.agent.name"
|
||||
export const ATTR_OPENCODE_SUBAGENT_SESSION_ID = "opencode.subagent.session.id"
|
||||
export const ATTR_OPENCODE_TOOL_OUTCOME = "opencode.tool.outcome"
|
||||
|
||||
export const EVENT_OPENCODE_COMPACTION_COMPLETED = "opencode.session.compaction.completed"
|
||||
export const EVENT_OPENCODE_COMPACTION_FAILED = "opencode.session.compaction.failed"
|
||||
export const EVENT_OPENCODE_COMPACTION_STARTED = "opencode.session.compaction.started"
|
||||
export const EVENT_OPENCODE_PROVIDER_TOOL_CALLED = "opencode.provider_tool.called"
|
||||
export const EVENT_OPENCODE_PROVIDER_TOOL_COMPLETED = "opencode.provider_tool.completed"
|
||||
export const EVENT_OPENCODE_RETRY_SCHEDULED = "opencode.provider.retry.scheduled"
|
||||
export const EVENT_OPENCODE_RETRY_STOPPED = "opencode.provider.retry.stopped"
|
||||
export const EVENT_OPENCODE_SESSION_INPUT_PROMOTED = "opencode.session.input.promoted"
|
||||
@@ -0,0 +1,68 @@
|
||||
export * as SessionTelemetry from "./session"
|
||||
|
||||
import { Context, Effect, Option } from "effect"
|
||||
import type { AnySpan, Span, SpanLink } from "effect/Tracer"
|
||||
import { DisablePropagation, ParentSpan } from "effect/Tracer"
|
||||
|
||||
// undefined inherits the ambient parent during resume; null explicitly detaches execution.
|
||||
export const TraceParent = Context.Reference<AnySpan | null | undefined>("@opencode/SessionTelemetry/TraceParent", {
|
||||
defaultValue: () => undefined,
|
||||
})
|
||||
|
||||
export const TraceLinks = Context.Reference<ReadonlyArray<SpanLink>>("@opencode/SessionTelemetry/TraceLinks", {
|
||||
defaultValue: () => [],
|
||||
})
|
||||
|
||||
export const TurnLinks = Context.Reference<
|
||||
{ readonly previous: () => Span | undefined; readonly set: (span: Span) => void } | undefined
|
||||
>("@opencode/SessionTelemetry/TurnLinks", { defaultValue: () => undefined })
|
||||
|
||||
export function makeExecution<Key>() {
|
||||
const turnCapacity = 1_024
|
||||
const parents = new Map<Key, AnySpan | null>()
|
||||
const links = new Map<Key, ReadonlyArray<SpanLink>>()
|
||||
const turns = new Map<Key, Span>()
|
||||
const turnLinks = (key: Key) => ({
|
||||
previous: () => {
|
||||
const span = turns.get(key)
|
||||
if (!span) return
|
||||
turns.delete(key)
|
||||
turns.set(key, span)
|
||||
return span
|
||||
},
|
||||
set: (span: Span) => {
|
||||
turns.delete(key)
|
||||
turns.set(key, span)
|
||||
if (turns.size <= turnCapacity) return
|
||||
const oldest = turns.keys().next()
|
||||
if (!oldest.done) turns.delete(oldest.value)
|
||||
},
|
||||
})
|
||||
const drain = <A, E, R>(key: Key, effect: Effect.Effect<A, E, R>) => {
|
||||
const parent = parents.get(key) ?? null
|
||||
const observed = effect.pipe(
|
||||
Effect.provideService(TraceParent, parent),
|
||||
Effect.provideService(TraceLinks, links.get(key) ?? []),
|
||||
Effect.provideService(TurnLinks, turnLinks(key)),
|
||||
)
|
||||
return parent === null ? observed : observed.pipe(Effect.withParentSpan(parent, { captureStackTrace: false }))
|
||||
}
|
||||
const resume = <A, E, R>(key: Key, effect: Effect.Effect<A, E, R>) =>
|
||||
Effect.gen(function* () {
|
||||
const override = yield* TraceParent
|
||||
const ambient = Option.getOrUndefined(yield* Effect.serviceOption(ParentSpan))
|
||||
const inherited = ambient && !Context.get(ambient.annotations, DisablePropagation) ? ambient : undefined
|
||||
const parent = override === null ? null : (override ?? inherited ?? null)
|
||||
if (!parents.has(key)) {
|
||||
parents.set(key, parent)
|
||||
links.set(key, yield* TraceLinks)
|
||||
}
|
||||
return yield* effect
|
||||
})
|
||||
const settled = (key: Key) =>
|
||||
Effect.sync(() => {
|
||||
parents.delete(key)
|
||||
links.delete(key)
|
||||
})
|
||||
return { drain, resume, settled }
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
export * as ToolTelemetry from "./tool"
|
||||
|
||||
import { Cause, Clock, Effect, Exit, Option } from "effect"
|
||||
import type { Span } from "effect/Tracer"
|
||||
import {
|
||||
ATTR_ERROR_TYPE,
|
||||
ATTR_GEN_AI_AGENT_NAME,
|
||||
ATTR_GEN_AI_CONVERSATION_ID,
|
||||
ATTR_GEN_AI_OPERATION_NAME,
|
||||
ATTR_GEN_AI_TOOL_CALL_ID,
|
||||
ATTR_GEN_AI_TOOL_NAME,
|
||||
ATTR_GEN_AI_TOOL_TYPE,
|
||||
ATTR_OPENCODE_ERROR_SOURCE,
|
||||
ATTR_OPENCODE_ERROR_STAGE,
|
||||
ATTR_OPENCODE_LINK_TYPE,
|
||||
ATTR_OPENCODE_SESSION_PARENT_ID,
|
||||
ATTR_OPENCODE_SUBAGENT_AGENT_NAME,
|
||||
ATTR_OPENCODE_SUBAGENT_SESSION_ID,
|
||||
ATTR_OPENCODE_TOOL_OUTCOME,
|
||||
GEN_AI_OPERATION_NAME_VALUE_EXECUTE_TOOL,
|
||||
} from "./semconv"
|
||||
import { AgentTelemetry } from "./agent"
|
||||
import { SessionTelemetry } from "./session"
|
||||
|
||||
export const currentSpan = Effect.option(Effect.currentSpan).pipe(
|
||||
Effect.map(Option.getOrUndefined),
|
||||
Effect.map(findToolSpan),
|
||||
)
|
||||
|
||||
const observe = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
Effect.catchCauseIf(
|
||||
effect,
|
||||
(cause) => !Cause.hasInterrupts(cause),
|
||||
() => Effect.void,
|
||||
)
|
||||
|
||||
export const execute = <A, E, R>(
|
||||
input: {
|
||||
readonly sessionID: string
|
||||
readonly agent: string
|
||||
readonly call: { readonly id: string; readonly name: string }
|
||||
},
|
||||
effect: Effect.Effect<A, E, R>,
|
||||
errorType: (cause: unknown) => string,
|
||||
resultErrorType?: (result: A) => string | undefined,
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
const toolSpan = yield* currentSpan
|
||||
const agentSpan = yield* AgentTelemetry.currentSpan
|
||||
const parent = toolSpan ?? agentSpan
|
||||
const parentSessionID = agentSpan?.attributes.get(ATTR_OPENCODE_SESSION_PARENT_ID)
|
||||
const span = yield* Effect.makeSpan(`execute_tool ${input.call.name}`, {
|
||||
kind: "internal",
|
||||
parent,
|
||||
attributes: {
|
||||
[ATTR_GEN_AI_OPERATION_NAME]: GEN_AI_OPERATION_NAME_VALUE_EXECUTE_TOOL,
|
||||
[ATTR_GEN_AI_TOOL_NAME]: input.call.name,
|
||||
[ATTR_GEN_AI_TOOL_TYPE]: "function",
|
||||
[ATTR_GEN_AI_TOOL_CALL_ID]: input.call.id,
|
||||
[ATTR_GEN_AI_AGENT_NAME]: input.agent,
|
||||
[ATTR_GEN_AI_CONVERSATION_ID]: input.sessionID,
|
||||
...(typeof parentSessionID === "string" ? { [ATTR_OPENCODE_SESSION_PARENT_ID]: parentSessionID } : {}),
|
||||
},
|
||||
}).pipe(
|
||||
Effect.withTracerEnabled(true),
|
||||
Effect.catchCause(() => Effect.succeed(undefined)),
|
||||
)
|
||||
if (!span) return yield* effect
|
||||
return yield* effect.pipe(
|
||||
Effect.tap((settlement) =>
|
||||
observe(
|
||||
Effect.gen(function* () {
|
||||
const type = resultErrorType?.(settlement)
|
||||
span.attribute(ATTR_OPENCODE_TOOL_OUTCOME, type ? "error" : "completed")
|
||||
if (!type) return
|
||||
span.attribute(ATTR_OPENCODE_ERROR_SOURCE, "tool")
|
||||
span.attribute(ATTR_OPENCODE_ERROR_STAGE, "execution")
|
||||
span.attribute(ATTR_ERROR_TYPE, type)
|
||||
span.end(yield* Clock.currentTimeNanos, Exit.fail(new Error(type)))
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.onExit((exit) => {
|
||||
if (span.status._tag === "Ended") return Effect.void
|
||||
if (Exit.isSuccess(exit))
|
||||
return observe(
|
||||
Effect.gen(function* () {
|
||||
span.end(yield* Clock.currentTimeNanos, exit)
|
||||
}),
|
||||
)
|
||||
const canceled = Cause.hasInterrupts(exit.cause)
|
||||
return observe(
|
||||
Effect.gen(function* () {
|
||||
const type = canceled
|
||||
? "canceled"
|
||||
: yield* Effect.sync(() => errorType(Cause.squash(exit.cause))).pipe(
|
||||
Effect.catchCause(() => Effect.succeed("unknown")),
|
||||
)
|
||||
span.attribute(ATTR_OPENCODE_TOOL_OUTCOME, canceled ? "canceled" : "error")
|
||||
span.attribute(ATTR_OPENCODE_ERROR_SOURCE, canceled ? "cancellation" : "tool")
|
||||
span.attribute(ATTR_OPENCODE_ERROR_STAGE, "execution")
|
||||
span.attribute(ATTR_ERROR_TYPE, type)
|
||||
span.end(yield* Clock.currentTimeNanos, Exit.fail(new Error(type)))
|
||||
}),
|
||||
)
|
||||
}),
|
||||
Effect.withParentSpan(span, { captureStackTrace: false }),
|
||||
Effect.withTracerEnabled(false),
|
||||
)
|
||||
})
|
||||
|
||||
export const child = (input: { readonly agent: string; readonly sessionID: string }) =>
|
||||
Effect.gen(function* () {
|
||||
const span = yield* currentSpan
|
||||
if (span)
|
||||
yield* observe(
|
||||
Effect.sync(() => {
|
||||
span.attribute(ATTR_OPENCODE_SUBAGENT_AGENT_NAME, input.agent)
|
||||
span.attribute(ATTR_OPENCODE_SUBAGENT_SESSION_ID, input.sessionID)
|
||||
}),
|
||||
)
|
||||
return {
|
||||
resume: <A, E, R>(effect: Effect.Effect<A, E, R>, background: boolean) =>
|
||||
effect.pipe(
|
||||
Effect.provideService(SessionTelemetry.TraceParent, background ? null : span),
|
||||
Effect.provideService(
|
||||
SessionTelemetry.TraceLinks,
|
||||
background && span ? [{ span, attributes: { [ATTR_OPENCODE_LINK_TYPE]: "subagent" } }] : [],
|
||||
),
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
function findToolSpan(span: Span | undefined): Span | undefined {
|
||||
if (!span) return
|
||||
if (span.attributes.get(ATTR_GEN_AI_OPERATION_NAME) === GEN_AI_OPERATION_NAME_VALUE_EXECUTE_TOOL) return span
|
||||
const parent = Option.getOrUndefined(span.parent)
|
||||
return findToolSpan(parent?._tag === "Span" ? parent : undefined)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as SessionV2 from "./session"
|
||||
export * from "./session/schema"
|
||||
|
||||
import { Effect, Layer, Schema, Context, Stream, Scope } from "effect"
|
||||
import { Cause, Effect, Layer, Schema, Context, Option, Stream, Scope } from "effect"
|
||||
import { ListAnchor } from "@opencode-ai/schema/session"
|
||||
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
|
||||
import { ProjectV2 } from "./project"
|
||||
@@ -43,7 +43,7 @@ import { SkillV2 } from "./skill"
|
||||
import { Job } from "./job"
|
||||
import { CommandV2 } from "./command"
|
||||
import { Shell } from "./shell"
|
||||
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
|
||||
import type { Shell as ShellSchema } from "@opencode-ai/schema/shell"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
@@ -531,6 +531,17 @@ const layer = Layer.effect(
|
||||
}
|
||||
return admitted
|
||||
}),
|
||||
).pipe(
|
||||
Effect.tapCause((cause) =>
|
||||
Effect.logError("Failed to admit Session prompt", cause).pipe(
|
||||
Effect.annotateLogs({
|
||||
operation: "session.prompt",
|
||||
sessionID: input.sessionID,
|
||||
...(input.id === undefined ? {} : { messageID: input.id }),
|
||||
errorType: promptErrorType(cause),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
command: Effect.fn("V2Session.command")(function* (input) {
|
||||
@@ -776,6 +787,14 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
function promptErrorType(cause: Cause.Cause<unknown>) {
|
||||
if (Cause.hasInterruptsOnly(cause)) return "canceled"
|
||||
const error = Option.getOrUndefined(Cause.findErrorOption(cause))
|
||||
if (error && typeof error === "object" && "_tag" in error && typeof error._tag === "string") return error._tag
|
||||
const failure = Cause.squash(cause)
|
||||
return failure instanceof Error ? failure.name : "unknown"
|
||||
}
|
||||
|
||||
function missingShellOutput() {
|
||||
const output = "Shell command output is no longer available."
|
||||
return {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { SessionStore } from "../store"
|
||||
import { SessionExecution } from "../execution"
|
||||
import { toSessionError } from "../to-session-error"
|
||||
import { UserInterruptedError } from "../error"
|
||||
import { SessionTelemetry } from "../../observability/session"
|
||||
|
||||
export function terminal(exit: Exit.Exit<void, SessionRunner.RunError>, reason?: "user" | "shutdown" | "superseded") {
|
||||
if (Exit.isSuccess(exit)) return { type: "succeeded" as const }
|
||||
@@ -19,6 +20,11 @@ export function terminal(exit: Exit.Exit<void, SessionRunner.RunError>, reason?:
|
||||
return { type: "failed" as const, error: toSessionError(failure) }
|
||||
}
|
||||
|
||||
function errorType(cause: Cause.Cause<unknown>) {
|
||||
const error = Cause.squash(cause)
|
||||
return error instanceof Error ? error.name : "unknown"
|
||||
}
|
||||
|
||||
/** Current-process routing for implicit-local Locations. Future remote placement belongs here. */
|
||||
const layer = Layer.effect(
|
||||
SessionExecution.Service,
|
||||
@@ -26,13 +32,19 @@ const layer = Layer.effect(
|
||||
const store = yield* SessionStore.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const events = yield* EventV2.Service
|
||||
const reportLifecycle = <A>(sessionID: SessionSchema.ID, effect: Effect.Effect<A>) =>
|
||||
const telemetry = SessionTelemetry.makeExecution<SessionSchema.ID>()
|
||||
const reportLifecycle = <A>(sessionID: SessionSchema.ID, phase: string, effect: Effect.Effect<A>) =>
|
||||
effect.pipe(
|
||||
Effect.tapCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause)
|
||||
? Effect.void
|
||||
: Effect.logError("Failed to publish Session execution lifecycle", cause).pipe(
|
||||
Effect.annotateLogs({ sessionID }),
|
||||
Effect.annotateLogs({
|
||||
operation: "session.execution.lifecycle",
|
||||
phase,
|
||||
sessionID,
|
||||
errorType: errorType(cause),
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.asVoid,
|
||||
@@ -42,25 +54,34 @@ const layer = Layer.effect(
|
||||
SessionRunner.RunError,
|
||||
"user" | "shutdown" | "superseded"
|
||||
>({
|
||||
started: (sessionID) => reportLifecycle(sessionID, events.publish(SessionEvent.Execution.Started, { sessionID })),
|
||||
started: (sessionID) =>
|
||||
reportLifecycle(sessionID, "started", events.publish(SessionEvent.Execution.Started, { sessionID })),
|
||||
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
||||
return yield* SessionRunner.Service.use((runner) => runner.drain({ sessionID, force })).pipe(
|
||||
const drain = SessionRunner.Service.use((runner) => runner.drain({ sessionID, force })).pipe(
|
||||
Effect.provide(locations.get(session.location)),
|
||||
Effect.tapCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause)
|
||||
? Effect.void
|
||||
: Effect.logError("Failed to drain Session", cause).pipe(Effect.annotateLogs({ sessionID })),
|
||||
: Effect.logError("Failed to drain Session", cause).pipe(
|
||||
Effect.annotateLogs({
|
||||
operation: "session.execution.drain",
|
||||
sessionID,
|
||||
errorType: toSessionError(Cause.squash(cause)).type,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return yield* telemetry.drain(sessionID, drain)
|
||||
}),
|
||||
// One terminal observation per busy period, covering every coalesced drain.
|
||||
settled: (sessionID, exit, reason) =>
|
||||
reportLifecycle(
|
||||
settled: (sessionID, exit, reason) => {
|
||||
const outcome = terminal(exit, reason)
|
||||
return reportLifecycle(
|
||||
sessionID,
|
||||
outcome.type,
|
||||
Effect.gen(function* () {
|
||||
const outcome = terminal(exit, reason)
|
||||
if (outcome.type === "succeeded") {
|
||||
yield* events.publish(SessionEvent.Execution.Succeeded, { sessionID })
|
||||
return
|
||||
@@ -74,13 +95,14 @@ const layer = Layer.effect(
|
||||
error: outcome.error,
|
||||
})
|
||||
}),
|
||||
),
|
||||
).pipe(Effect.ensuring(telemetry.settled(sessionID)))
|
||||
},
|
||||
})
|
||||
|
||||
return SessionExecution.Service.of({
|
||||
active: coordinator.active,
|
||||
interrupt: (sessionID) => coordinator.interrupt(sessionID, "user"),
|
||||
resume: coordinator.run,
|
||||
resume: (sessionID) => telemetry.resume(sessionID, coordinator.run(sessionID)),
|
||||
wake: coordinator.wake,
|
||||
awaitIdle: coordinator.awaitIdle,
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from "@opencode-ai/llm"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
||||
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, References, Semaphore, Stream } from "effect"
|
||||
import { AgentV2 } from "../../agent"
|
||||
import { Config } from "../../config"
|
||||
import { Database } from "../../database/database"
|
||||
@@ -51,6 +51,7 @@ import { llmClient } from "../../effect/app-node-platform"
|
||||
import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "../error"
|
||||
import { toSessionError } from "../to-session-error"
|
||||
import { SessionRunnerRetry } from "./retry"
|
||||
import { AgentTelemetry } from "../../observability/agent"
|
||||
import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
|
||||
import { PluginHooks } from "../../plugin/hooks"
|
||||
import { PluginSupervisor } from "../../plugin/supervisor"
|
||||
@@ -205,25 +206,32 @@ const layer = Layer.effect(
|
||||
sessionID: SessionSchema.ID,
|
||||
promotion: SessionInput.Delivery | undefined,
|
||||
step: number,
|
||||
trigger: AgentTelemetry.ModelCallTrigger,
|
||||
retryAttempt: number,
|
||||
recoverOverflow?: typeof compaction.compactAfterOverflow,
|
||||
assistantMessageID?: SessionMessage.ID,
|
||||
) {
|
||||
const session = yield* getSession(sessionID)
|
||||
const session = yield* AgentTelemetry.stage("session", getSession(sessionID))
|
||||
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
|
||||
return yield* Effect.interrupt
|
||||
yield* plugins.flush
|
||||
const agent = yield* agents.select(session.agent)
|
||||
yield* AgentTelemetry.stage("plugin_readiness", plugins.flush)
|
||||
const agent = yield* AgentTelemetry.stage("agent", agents.select(session.agent))
|
||||
yield* AgentTelemetry.identify({ agent: agent.id, parentSessionID: session.parentID })
|
||||
if (!agent.info)
|
||||
return yield* AgentTelemetry.stage(
|
||||
"agent",
|
||||
new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id }),
|
||||
)
|
||||
const agentInfo = agent.info
|
||||
if (!agentInfo) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
|
||||
// Establish what the model knows before admitting what the user said, so
|
||||
// a blocked first step leaves pending inputs untouched.
|
||||
const checkpoint = yield* InstructionCheckpoint.prepare(
|
||||
db,
|
||||
events,
|
||||
loadInstructions(agent, session.id),
|
||||
session.id,
|
||||
const checkpoint = yield* AgentTelemetry.stage(
|
||||
"instructions",
|
||||
InstructionCheckpoint.prepare(db, events, loadInstructions(agent, session.id), session.id),
|
||||
)
|
||||
let currentStep = step
|
||||
let currentTrigger = trigger
|
||||
let inputDelivery: SessionInput.Delivery | undefined
|
||||
if (promotion) {
|
||||
let promoted = 0
|
||||
if (promotion === "steer") promoted = yield* SessionInput.promoteSteers(db, events, session.id)
|
||||
@@ -231,17 +239,28 @@ const layer = Layer.effect(
|
||||
promoted += Number(yield* SessionInput.promoteNextQueued(db, events, session.id))
|
||||
promoted += yield* SessionInput.promoteSteers(db, events, session.id)
|
||||
}
|
||||
if (promoted > 0) currentStep = 1
|
||||
if (promoted > 0) {
|
||||
currentStep = 1
|
||||
currentTrigger = "input"
|
||||
inputDelivery = promotion
|
||||
yield* AgentTelemetry.inputPromoted({ delivery: promotion, count: promoted })
|
||||
}
|
||||
}
|
||||
const resolved = yield* models.resolve(session)
|
||||
const resolved = yield* AgentTelemetry.stage("model_resolution", models.resolve(session))
|
||||
const model = resolved.model
|
||||
const providerMetadataKey = model.route.providerMetadataKey ?? model.provider
|
||||
const entries = yield* SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq)
|
||||
const entries = yield* AgentTelemetry.stage(
|
||||
"history",
|
||||
SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq),
|
||||
)
|
||||
const context = entries.map((entry) => entry.message)
|
||||
const isLastStep = agentInfo.steps !== undefined && currentStep >= agentInfo.steps
|
||||
const toolMaterialization = isLastStep
|
||||
? undefined
|
||||
: yield* tools.materialize({ permissions: agentInfo.permissions, model })
|
||||
: yield* AgentTelemetry.stage(
|
||||
"tool_materialization",
|
||||
tools.materialize({ permissions: agentInfo.permissions, model }),
|
||||
)
|
||||
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
|
||||
const request = LLM.request({
|
||||
model,
|
||||
@@ -284,12 +303,17 @@ const layer = Layer.effect(
|
||||
})
|
||||
const advertisedTools = new Set(hookedRequest.tools.map((tool) => tool.name))
|
||||
// Automatic compaction completed; rebuild the request from compacted history.
|
||||
if (
|
||||
!(yield* SessionInput.pendingCompaction(db, session.id)) &&
|
||||
(yield* compaction.compactIfNeeded({ sessionID: session.id, messages: context, request: hookedRequest }))
|
||||
)
|
||||
return { _tag: "RestartAfterCompaction", step: currentStep } as const
|
||||
const startSnapshot = yield* snapshots.capture()
|
||||
if (!(yield* SessionInput.pendingCompaction(db, session.id))) {
|
||||
const compacted = yield* AgentTelemetry.stage(
|
||||
"compaction",
|
||||
compaction.compactIfNeeded({ sessionID: session.id, messages: context, request: hookedRequest }),
|
||||
)
|
||||
if (compacted) {
|
||||
yield* AgentTelemetry.compactionCompleted("automatic")
|
||||
return { _tag: "RestartAfterCompaction", step: currentStep } as const
|
||||
}
|
||||
}
|
||||
const startSnapshot = yield* AgentTelemetry.stage("snapshot", snapshots.capture())
|
||||
const publisher = createLLMEventPublisher(events, {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
@@ -306,78 +330,97 @@ const layer = Layer.effect(
|
||||
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
|
||||
const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error))
|
||||
let overflowFailure: ProviderErrorEvent | undefined
|
||||
const providerStream = llm.stream(hookedRequest).pipe(
|
||||
Stream.runForEach((event) =>
|
||||
Effect.gen(function* () {
|
||||
if (overflowFailure || publisher.hasProviderError()) return
|
||||
if (LLMEvent.is.providerError(event)) {
|
||||
if (isContextOverflowFailure(event) && !publisher.hasRetryEvidence()) {
|
||||
overflowFailure = event
|
||||
return
|
||||
}
|
||||
}
|
||||
yield* publish(event)
|
||||
if (event.type !== "tool-call" || event.providerExecuted) return
|
||||
if (!toolMaterialization) {
|
||||
yield* serialized(
|
||||
publisher.failUnsettledTools({
|
||||
type: "tool.execution",
|
||||
message: "Tools are disabled after the maximum agent steps",
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
// A request hook hid this registered tool from the current request. Fail only
|
||||
// this call durably and continue so the model can react, instead of executing
|
||||
// a tool that was not advertised. Unregistered tools flow through settle, which
|
||||
// durably fails them as unknown.
|
||||
if (!advertisedTools.has(event.name) && availableTools.has(event.name)) {
|
||||
needsContinuation = true
|
||||
yield* publish(
|
||||
LLMEvent.toolError({
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
message: `Tool is not available for this request: ${event.name}`,
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
needsContinuation = true
|
||||
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
|
||||
ownedToolFibers.push(
|
||||
yield* Effect.uninterruptibleMask((restore) =>
|
||||
restore(
|
||||
toolMaterialization.settle({
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
assistantMessageID,
|
||||
call: event,
|
||||
}),
|
||||
).pipe(
|
||||
Effect.flatMap((settlement) =>
|
||||
publish(
|
||||
LLMEvent.toolResult({
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
result: settlement.result,
|
||||
output: settlement.output,
|
||||
const telemetry = AgentTelemetry.modelCall({
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
step: currentStep,
|
||||
trigger: currentTrigger,
|
||||
retryAttempt: retryAttempt > 0 ? retryAttempt + 1 : undefined,
|
||||
delivery: inputDelivery,
|
||||
compacted: context.some((message) => message.type === "compaction"),
|
||||
})
|
||||
const providerStream = AgentTelemetry.stage(
|
||||
"model",
|
||||
telemetry.run(
|
||||
llm.stream(hookedRequest).pipe(
|
||||
Stream.provideService(References.TracerEnabled, true),
|
||||
Stream.runForEach((event) =>
|
||||
Effect.gen(function* () {
|
||||
if (overflowFailure || publisher.hasProviderError()) return
|
||||
if (LLMEvent.is.providerError(event)) {
|
||||
if (isContextOverflowFailure(event) && !publisher.hasRetryEvidence()) {
|
||||
overflowFailure = event
|
||||
return
|
||||
}
|
||||
}
|
||||
yield* publish(event)
|
||||
yield* telemetry.observe(event)
|
||||
if (event.type !== "tool-call" || event.providerExecuted) return
|
||||
if (!toolMaterialization) {
|
||||
yield* serialized(
|
||||
publisher.failUnsettledTools({
|
||||
type: "tool.execution",
|
||||
message: "Tools are disabled after the maximum agent steps",
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
// A request hook hid this registered tool from the current request. Fail only
|
||||
// this call durably and continue so the model can react, instead of executing
|
||||
// a tool that was not advertised. Unregistered tools flow through settle, which
|
||||
// durably fails them as unknown.
|
||||
if (!advertisedTools.has(event.name) && availableTools.has(event.name)) {
|
||||
needsContinuation = true
|
||||
yield* publish(
|
||||
LLMEvent.toolError({
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
message: `Tool is not available for this request: ${event.name}`,
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
needsContinuation = true
|
||||
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
|
||||
ownedToolFibers.push(
|
||||
yield* Effect.uninterruptibleMask((restore) =>
|
||||
restore(
|
||||
toolMaterialization.settle({
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
assistantMessageID,
|
||||
call: event,
|
||||
}),
|
||||
settlement.error,
|
||||
).pipe(
|
||||
// Terminal cleanup must own failAssistant because the provider may still be streaming another tool input.
|
||||
Effect.andThen(
|
||||
settlement.error?.type === "permission.rejected"
|
||||
? Effect.fail(new StepFailedError({ error: settlement.error }))
|
||||
: Effect.void,
|
||||
Effect.flatMap((settlement) =>
|
||||
AgentTelemetry.stage(
|
||||
"tool_publication",
|
||||
publish(
|
||||
LLMEvent.toolResult({
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
result: settlement.result,
|
||||
output: settlement.output,
|
||||
}),
|
||||
settlement.error,
|
||||
),
|
||||
).pipe(
|
||||
// Terminal cleanup must own failAssistant because the provider may still be streaming another tool input.
|
||||
Effect.andThen(
|
||||
settlement.error?.type === "permission.rejected"
|
||||
? Effect.fail(new StepFailedError({ error: settlement.error }))
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
).pipe(FiberSet.run(toolFibers)),
|
||||
)
|
||||
}),
|
||||
).pipe(FiberSet.run(toolFibers)),
|
||||
)
|
||||
}),
|
||||
),
|
||||
Effect.ensuring(serialized(publisher.flush())),
|
||||
),
|
||||
),
|
||||
Effect.ensuring(serialized(publisher.flush())),
|
||||
)
|
||||
|
||||
const stepUsage = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) => ({
|
||||
@@ -396,15 +439,18 @@ const layer = Layer.effect(
|
||||
.files({ from: startSnapshot, to: endSnapshot })
|
||||
.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
: undefined
|
||||
yield* serialized(
|
||||
events.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: session.id,
|
||||
assistantMessageID: yield* publisher.startAssistant(),
|
||||
finish: settlement.finish,
|
||||
...stepUsage(settlement),
|
||||
snapshot: endSnapshot,
|
||||
files,
|
||||
}),
|
||||
yield* AgentTelemetry.stage(
|
||||
"step_publication",
|
||||
serialized(
|
||||
events.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: session.id,
|
||||
assistantMessageID: yield* publisher.startAssistant(),
|
||||
finish: settlement.finish,
|
||||
...stepUsage(settlement),
|
||||
snapshot: endSnapshot,
|
||||
files,
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -434,11 +480,10 @@ const layer = Layer.effect(
|
||||
const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined
|
||||
if (llmFailure && !publisher.hasProviderError()) {
|
||||
const error = toSessionError(llmFailure)
|
||||
if (
|
||||
SessionRunnerRetry.isRetryable(llmFailure) &&
|
||||
!publisher.hasRetryEvidence() &&
|
||||
(agentInfo.steps === undefined || currentStep < agentInfo.steps)
|
||||
) {
|
||||
const retryable = SessionRunnerRetry.isRetryable(llmFailure)
|
||||
const hasOutput = publisher.hasRetryEvidence()
|
||||
const stepLimited = agentInfo.steps !== undefined && currentStep >= agentInfo.steps
|
||||
if (retryable && !hasOutput && !stepLimited) {
|
||||
return yield* new SessionRunnerRetry.RetryableFailure({
|
||||
cause: llmFailure,
|
||||
assistantMessageID: yield* publisher.startAssistant(),
|
||||
@@ -446,6 +491,12 @@ const layer = Layer.effect(
|
||||
step: currentStep,
|
||||
})
|
||||
}
|
||||
yield* AgentTelemetry.retryStopped({
|
||||
decision: retryable ? (hasOutput ? "output_started" : "step_limit") : "non_retryable",
|
||||
attempt: retryAttempt + 1,
|
||||
maxAttempts: SessionRunnerRetry.maxAttempts,
|
||||
errorType: error.type,
|
||||
})
|
||||
yield* serialized(publisher.failAssistant(error))
|
||||
}
|
||||
// Provider error events only arrive from the stream, so the flag is final here.
|
||||
@@ -518,16 +569,17 @@ const layer = Layer.effect(
|
||||
: false
|
||||
if (hostedResultMissing && !publisher.stepSettlement())
|
||||
yield* serialized(
|
||||
publisher.failAssistant({
|
||||
type: "tool.result-missing",
|
||||
message: "Provider did not return a tool result",
|
||||
}),
|
||||
publisher.failAssistant({ type: "tool.result-missing", message: "Provider did not return a tool result" }),
|
||||
)
|
||||
|
||||
const stepFailure = publisher.stepFailure()
|
||||
const stepSettlement = publisher.stepSettlement()
|
||||
const stepEndedCleanly =
|
||||
!streamInterrupted && !toolsInterrupted && infraError === undefined && !providerFailed && !stepFailure
|
||||
!streamInterrupted &&
|
||||
!toolsInterrupted &&
|
||||
infraError === undefined &&
|
||||
!providerFailed &&
|
||||
!stepFailure
|
||||
if (stepSettlement && stepEndedCleanly) yield* publishStepEnd(stepSettlement)
|
||||
if (stepFailure)
|
||||
yield* serialized(publisher.publishStepFailure(stepSettlement ? stepUsage(stepSettlement) : undefined))
|
||||
@@ -552,6 +604,7 @@ const layer = Layer.effect(
|
||||
sessionID: SessionSchema.ID,
|
||||
promotion: SessionInput.Delivery | undefined,
|
||||
step: number,
|
||||
trigger: AgentTelemetry.ModelCallTrigger,
|
||||
) {
|
||||
// Compaction restarts rebuild the request from compacted history without re-promoting.
|
||||
// Overflow recovery is one-shot: a post-compaction attempt must not recover another
|
||||
@@ -559,10 +612,24 @@ const layer = Layer.effect(
|
||||
let recoverOverflow: typeof compaction.compactAfterOverflow | undefined = compaction.compactAfterOverflow
|
||||
let currentPromotion = promotion
|
||||
let currentStep = step
|
||||
let currentTrigger = trigger
|
||||
let retryAttempt = 0
|
||||
let assistantMessageID: SessionMessage.ID | undefined
|
||||
while (true) {
|
||||
const attempt = yield* Effect.suspend(() =>
|
||||
attemptStep(sessionID, currentPromotion, currentStep, recoverOverflow, assistantMessageID),
|
||||
AgentTelemetry.resetStage.pipe(
|
||||
Effect.andThen(
|
||||
attemptStep(
|
||||
sessionID,
|
||||
currentPromotion,
|
||||
currentStep,
|
||||
currentTrigger,
|
||||
retryAttempt,
|
||||
recoverOverflow,
|
||||
assistantMessageID,
|
||||
),
|
||||
),
|
||||
),
|
||||
).pipe(
|
||||
Effect.tapError((error) =>
|
||||
error instanceof SessionRunnerRetry.RetryableFailure
|
||||
@@ -570,22 +637,33 @@ const layer = Layer.effect(
|
||||
currentStep = error.step + 1
|
||||
assistantMessageID = error.assistantMessageID
|
||||
currentPromotion = undefined
|
||||
currentTrigger = "retry"
|
||||
retryAttempt += 1
|
||||
})
|
||||
: Effect.void,
|
||||
),
|
||||
Effect.retryOrElse(SessionRunnerRetry.schedule(events, sessionID), (error) => {
|
||||
if (!(error instanceof SessionRunnerRetry.RetryableFailure)) return Effect.fail(error)
|
||||
return events
|
||||
.publish(SessionEvent.Step.Failed, {
|
||||
sessionID,
|
||||
assistantMessageID: error.assistantMessageID,
|
||||
error: error.error,
|
||||
})
|
||||
.pipe(Effect.andThen(Effect.fail(error.cause)))
|
||||
return AgentTelemetry.retryStopped({
|
||||
decision: "exhausted",
|
||||
attempt: retryAttempt,
|
||||
maxAttempts: SessionRunnerRetry.maxAttempts,
|
||||
errorType: error.error.type,
|
||||
}).pipe(
|
||||
Effect.andThen(
|
||||
events.publish(SessionEvent.Step.Failed, {
|
||||
sessionID,
|
||||
assistantMessageID: error.assistantMessageID,
|
||||
error: error.error,
|
||||
}),
|
||||
),
|
||||
Effect.andThen(Effect.fail(error.cause)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
if (attempt._tag === "Completed") return { needsContinuation: attempt.needsContinuation, step: attempt.step }
|
||||
if (attempt._tag === "RestartAfterOverflowCompaction") recoverOverflow = undefined
|
||||
currentTrigger = "compaction"
|
||||
yield* Effect.yieldNow
|
||||
currentPromotion = undefined
|
||||
currentStep = attempt.step
|
||||
@@ -597,6 +675,7 @@ const layer = Layer.effect(
|
||||
) {
|
||||
const pending = yield* SessionInput.pendingCompaction(db, sessionID)
|
||||
if (!pending) return false
|
||||
yield* AgentTelemetry.compactionStarted("manual")
|
||||
const session = yield* getSession(sessionID)
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -609,7 +688,10 @@ const layer = Layer.effect(
|
||||
})
|
||||
}),
|
||||
).pipe(Effect.exit)
|
||||
if (Exit.isSuccess(compacted) && compacted.value) return true
|
||||
if (Exit.isSuccess(compacted) && compacted.value) {
|
||||
yield* AgentTelemetry.compactionCompleted("manual")
|
||||
return true
|
||||
}
|
||||
if (Exit.isFailure(compacted)) {
|
||||
const unsettled = yield* SessionInput.pendingCompaction(db, sessionID)
|
||||
if (unsettled)
|
||||
@@ -619,6 +701,7 @@ const layer = Layer.effect(
|
||||
error: { type: "compaction.failed", message: Cause.pretty(compacted.cause) },
|
||||
inputID: unsettled.id,
|
||||
})
|
||||
yield* AgentTelemetry.compactionFailed("manual")
|
||||
return yield* Effect.failCause(compacted.cause)
|
||||
}
|
||||
const unsettled = yield* SessionInput.pendingCompaction(db, sessionID)
|
||||
@@ -629,6 +712,7 @@ const layer = Layer.effect(
|
||||
error: { type: "compaction.failed", message: "Compaction could not start" },
|
||||
inputID: unsettled.id,
|
||||
})
|
||||
yield* AgentTelemetry.compactionFailed("manual")
|
||||
return true
|
||||
}),
|
||||
)
|
||||
@@ -639,44 +723,65 @@ const layer = Layer.effect(
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly force: boolean
|
||||
}) {
|
||||
yield* runPendingCompaction(input.sessionID)
|
||||
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
|
||||
const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
|
||||
if (!input.force && !hasSteer && !hasQueue) return
|
||||
yield* failInterruptedTools(input.sessionID)
|
||||
let promotion: SessionInput.Delivery | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined
|
||||
let shouldRun = input.force || hasSteer || hasQueue
|
||||
while (shouldRun) {
|
||||
let needsContinuation = true
|
||||
let step = 1
|
||||
// Repeat steps while continuation is needed. A step needs continuation only
|
||||
// when it recorded local tool calls whose results the model has not yet seen;
|
||||
// a provider error suppresses it. Pending steers also continue the loop so
|
||||
// interjections are answered before the session goes idle.
|
||||
while (needsContinuation) {
|
||||
const result = yield* runStep(input.sessionID, promotion, step)
|
||||
// Steer/queue promotion inside runStep has already made the pending input a visible
|
||||
// user message by this point, so the first-user-message check below is reliable.
|
||||
if (!titleAttempted.has(input.sessionID)) {
|
||||
titleAttempted.add(input.sessionID)
|
||||
forkTitle(title.generateForFirstPrompt(yield* getSession(input.sessionID)).pipe(Effect.ignore))
|
||||
}
|
||||
needsContinuation = result.needsContinuation
|
||||
step = result.step + 1
|
||||
if (needsContinuation) {
|
||||
promotion = (yield* SessionInput.pendingCompaction(db, input.sessionID)) ? undefined : "steer"
|
||||
continue
|
||||
}
|
||||
yield* runPendingCompaction(input.sessionID)
|
||||
promotion = "steer"
|
||||
needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer")
|
||||
const run = Effect.gen(function* () {
|
||||
if (yield* SessionInput.pendingCompaction(db, input.sessionID)) {
|
||||
const compactionAgent = yield* agents.select((yield* getSession(input.sessionID)).agent)
|
||||
yield* AgentTelemetry.invoke(
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
agent: compactionAgent.id,
|
||||
errorType: (cause) => toSessionError(cause).type,
|
||||
},
|
||||
AgentTelemetry.stage("compaction", runPendingCompaction(input.sessionID)),
|
||||
)
|
||||
}
|
||||
yield* runPendingCompaction(input.sessionID)
|
||||
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
|
||||
const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
|
||||
shouldRun = hasSteer || hasQueue
|
||||
promotion = hasSteer ? "steer" : hasQueue ? "queue" : undefined
|
||||
}
|
||||
if (!input.force && !hasSteer && !hasQueue) return
|
||||
yield* failInterruptedTools(input.sessionID)
|
||||
let promotion: SessionInput.Delivery | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined
|
||||
let trigger: AgentTelemetry.ModelCallTrigger = hasSteer || hasQueue ? "input" : "resume"
|
||||
let shouldRun = input.force || hasSteer || hasQueue
|
||||
while (shouldRun) {
|
||||
const invocationAgent = yield* agents.select((yield* getSession(input.sessionID)).agent)
|
||||
yield* AgentTelemetry.invoke(
|
||||
{ sessionID: input.sessionID, agent: invocationAgent.id, errorType: (cause) => toSessionError(cause).type },
|
||||
Effect.gen(function* () {
|
||||
let needsContinuation = true
|
||||
let step = 1
|
||||
// One agent turn runs from initial input promotion until the Session would become idle.
|
||||
// Model calls remain individual steps beneath this turn span.
|
||||
while (needsContinuation) {
|
||||
const result = yield* runStep(input.sessionID, promotion, step, trigger)
|
||||
// Steer/queue promotion inside runStep has already made the pending input a visible
|
||||
// user message by this point, so the first-user-message check below is reliable.
|
||||
if (!titleAttempted.has(input.sessionID)) {
|
||||
titleAttempted.add(input.sessionID)
|
||||
forkTitle(title.generateForFirstPrompt(yield* getSession(input.sessionID)).pipe(Effect.ignore))
|
||||
}
|
||||
needsContinuation = result.needsContinuation
|
||||
step = result.step + 1
|
||||
if (needsContinuation) {
|
||||
promotion = (yield* SessionInput.pendingCompaction(db, input.sessionID)) ? undefined : "steer"
|
||||
trigger = "tool_result"
|
||||
continue
|
||||
}
|
||||
yield* runPendingCompaction(input.sessionID)
|
||||
promotion = "steer"
|
||||
trigger = "tool_result"
|
||||
needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer")
|
||||
}
|
||||
}),
|
||||
)
|
||||
yield* runPendingCompaction(input.sessionID)
|
||||
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
|
||||
const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
|
||||
shouldRun = hasSteer || hasQueue
|
||||
promotion = hasSteer ? "steer" : hasQueue ? "queue" : undefined
|
||||
trigger = hasSteer || hasQueue ? "input" : "resume"
|
||||
}
|
||||
})
|
||||
return yield* run
|
||||
})
|
||||
|
||||
return Service.of({ drain })
|
||||
|
||||
@@ -4,11 +4,14 @@ import { LLMError } from "@opencode-ai/llm"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Data, Duration, Effect, Schedule } from "effect"
|
||||
import { EventV2 } from "../../event"
|
||||
import { AgentTelemetry } from "../../observability/agent"
|
||||
import { SessionEvent } from "../event"
|
||||
import { SessionMessage } from "../message"
|
||||
import { SessionSchema } from "../schema"
|
||||
import type { SessionRunner } from "./index"
|
||||
|
||||
export const maxAttempts = 5
|
||||
|
||||
export class RetryableFailure extends Data.TaggedError("SessionRunner.RetryableFailure")<{
|
||||
readonly cause: LLMError
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
@@ -45,7 +48,7 @@ const retryAfter = (failure: RetryableFailure) => {
|
||||
|
||||
export const schedule = (events: EventV2.Interface, sessionID: SessionSchema.ID) =>
|
||||
Schedule.exponential("2 seconds").pipe(
|
||||
Schedule.take(4),
|
||||
Schedule.take(maxAttempts - 1),
|
||||
Schedule.setInputType<RetryableFailure | SessionRunner.RunError>(),
|
||||
Schedule.passthrough,
|
||||
Schedule.while(({ input }) => input instanceof RetryableFailure),
|
||||
@@ -53,15 +56,24 @@ export const schedule = (events: EventV2.Interface, sessionID: SessionSchema.ID)
|
||||
const minimum = failure instanceof RetryableFailure ? retryAfter(failure) : undefined
|
||||
return Effect.succeed(minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum)))
|
||||
}),
|
||||
Schedule.tap((metadata) =>
|
||||
metadata.input instanceof RetryableFailure
|
||||
? events.publish(SessionEvent.RetryScheduled, {
|
||||
sessionID,
|
||||
assistantMessageID: metadata.input.assistantMessageID,
|
||||
attempt: metadata.attempt + 1,
|
||||
at: metadata.now + Duration.toMillis(metadata.duration),
|
||||
error: metadata.input.error,
|
||||
})
|
||||
: Effect.void,
|
||||
),
|
||||
Schedule.tap((metadata) => {
|
||||
const failure = metadata.input
|
||||
if (!(failure instanceof RetryableFailure)) return Effect.void
|
||||
return Effect.gen(function* () {
|
||||
yield* events.publish(SessionEvent.RetryScheduled, {
|
||||
sessionID,
|
||||
assistantMessageID: failure.assistantMessageID,
|
||||
attempt: metadata.attempt + 1,
|
||||
at: metadata.now + Duration.toMillis(metadata.duration),
|
||||
error: failure.error,
|
||||
})
|
||||
yield* AgentTelemetry.retryScheduled({
|
||||
attempt: metadata.attempt + 1,
|
||||
maxAttempts,
|
||||
delayMs: Duration.toMillis(metadata.duration),
|
||||
retryAfterMs: retryAfter(failure),
|
||||
errorType: failure.error.type,
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -3,6 +3,8 @@ export * as ExecuteTool from "./execute"
|
||||
import { CodeMode, Tool, toolError } from "@opencode-ai/codemode"
|
||||
import { ToolOutput } from "@opencode-ai/llm"
|
||||
import { Effect, Ref, Schema } from "effect"
|
||||
import { ToolTelemetry } from "../observability/tool"
|
||||
import { toSessionError } from "../session/to-session-error"
|
||||
import { definition, make, settle, type AnyTool } from "./tool"
|
||||
|
||||
const ExecuteFile = Schema.Struct({
|
||||
@@ -115,18 +117,31 @@ export const create = (options: {
|
||||
(name, registration, input) =>
|
||||
Effect.gen(function* () {
|
||||
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
|
||||
const current = options.current(name)
|
||||
if (!current || current.identity !== registration.identity)
|
||||
return yield* Effect.fail(toolError(`Stale tool call: ${name}`))
|
||||
const output = yield* settle(
|
||||
current.tool,
|
||||
{ type: "tool-call", id: context.toolCallID, name, input },
|
||||
const state = { stale: false }
|
||||
const output = yield* ToolTelemetry.execute(
|
||||
{
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
assistantMessageID: context.assistantMessageID,
|
||||
toolCallID: context.toolCallID,
|
||||
call: { id: `${context.toolCallID}/${index + 1}`, name },
|
||||
},
|
||||
Effect.gen(function* () {
|
||||
const current = options.current(name)
|
||||
if (!current || current.identity !== registration.identity) {
|
||||
state.stale = true
|
||||
return yield* Effect.fail(toolError(`Stale tool call: ${name}`))
|
||||
}
|
||||
return yield* settle(
|
||||
current.tool,
|
||||
{ type: "tool-call", id: context.toolCallID, name, input },
|
||||
{
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
assistantMessageID: context.assistantMessageID,
|
||||
toolCallID: context.toolCallID,
|
||||
},
|
||||
)
|
||||
}),
|
||||
(cause) => (state.stale ? "tool.stale" : toSessionError(cause).type),
|
||||
).pipe(Effect.mapError((failure) => toolError(failure.message, failure)))
|
||||
const outputFileParts = outputFiles(output)
|
||||
if (outputFileParts.length > 0)
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ToolHooks } from "./hooks"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { toSessionError } from "../session/to-session-error"
|
||||
import { ToolTelemetry } from "../observability/tool"
|
||||
|
||||
export type ExecuteInput = {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
@@ -212,15 +213,27 @@ const registryLayer = Layer.effect(
|
||||
...Array.from(direct, ([name, registration]) => definition(name, registration.tool)),
|
||||
...(execute ? [definition("execute", execute)] : []),
|
||||
],
|
||||
settle: (input) => {
|
||||
if (input.call.name === "execute" && execute) return settleTool(input, execute)
|
||||
const registration = direct.get(input.call.name)
|
||||
if (registration) return settleWith(input, registration.identity)
|
||||
return Effect.succeed({
|
||||
result: { type: "error", value: `Unknown tool: ${input.call.name}` },
|
||||
error: { type: "tool.unknown", message: `Unknown tool: ${input.call.name}` },
|
||||
})
|
||||
},
|
||||
settle: (input) =>
|
||||
ToolTelemetry.execute(
|
||||
input,
|
||||
Effect.suspend(() => {
|
||||
if (input.call.name === "execute" && execute) return settleTool(input, execute)
|
||||
const registration = direct.get(input.call.name)
|
||||
if (registration) return settleWith(input, registration.identity)
|
||||
return Effect.succeed({
|
||||
result: { type: "error" as const, value: `Unknown tool: ${input.call.name}` },
|
||||
error: { type: "tool.unknown" as const, message: `Unknown tool: ${input.call.name}` },
|
||||
})
|
||||
}),
|
||||
(cause) => toSessionError(cause).type,
|
||||
(settlement) => {
|
||||
if (settlement.error) return settlement.error.type
|
||||
if (input.call.name !== "execute") return
|
||||
const structured = settlement.output?.structured
|
||||
if (typeof structured !== "object" || structured === null || !("error" in structured)) return
|
||||
return structured.error === true ? "tool.execution" : undefined
|
||||
},
|
||||
),
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plu
|
||||
import { Effect, Schema, Scope } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { PluginRuntime } from "../plugin/runtime"
|
||||
import { ToolTelemetry } from "../observability/tool"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
import { Tool } from "./tool"
|
||||
@@ -147,13 +148,14 @@ export const Plugin = {
|
||||
(error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
|
||||
),
|
||||
)
|
||||
const telemetry = yield* ToolTelemetry.child({ agent: input.agent, sessionID: child.id })
|
||||
|
||||
const background = input.background === true
|
||||
|
||||
const run = Effect.gen(function* () {
|
||||
// The child session owns its agent/model (set at create); prompt only admits input.
|
||||
yield* runtime.session.prompt({ sessionID: child.id, text: input.prompt, resume: false })
|
||||
yield* runtime.session.resume(child.id)
|
||||
yield* telemetry.resume(runtime.session.resume(child.id), background)
|
||||
return yield* latestAssistantText(child.id)
|
||||
}).pipe(Effect.onInterrupt(() => runtime.session.interrupt(child.id)))
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Duration, Effect, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Parser } from "htmlparser2"
|
||||
import TurndownService from "turndown"
|
||||
import { HttpTelemetry } from "../observability/http"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { collectBoundedResponseBody } from "./http-body"
|
||||
import { Tool } from "./tool"
|
||||
@@ -84,7 +85,21 @@ const assertHttpUrl = (url: URL) => {
|
||||
}
|
||||
|
||||
const execute = (http: HttpClient.HttpClient, url: string, format: Format, userAgent = browserUserAgent) =>
|
||||
http.execute(request(url, format, userAgent)).pipe(Effect.flatMap(HttpClientResponse.filterStatusOk))
|
||||
HttpTelemetry.use(
|
||||
http,
|
||||
request(url, format, userAgent),
|
||||
(response) =>
|
||||
Effect.gen(function* () {
|
||||
const contentType = response.headers["content-type"] || ""
|
||||
const mime = mimeFrom(contentType)
|
||||
if (isImageAttachment(mime))
|
||||
return yield* Effect.fail(new Error(`Unsupported fetched image content type: ${mime}`))
|
||||
if (!isTextualMime(mime))
|
||||
return yield* Effect.fail(new Error(`Unsupported fetched file content type: ${mime}`))
|
||||
return { body: yield* collectBody(response), contentType }
|
||||
}),
|
||||
HttpClientResponse.filterStatusOk,
|
||||
)
|
||||
|
||||
const collectBody = (response: HttpClientResponse.HttpClientResponse) =>
|
||||
collectBoundedResponseBody(
|
||||
@@ -144,18 +159,8 @@ export const Plugin = {
|
||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
})
|
||||
|
||||
const { body, contentType } = yield* Effect.gen(function* () {
|
||||
const response = yield* execute(http, input.url, input.format).pipe(
|
||||
Effect.catchIf(isCloudflareChallenge, () => execute(http, input.url, input.format, "opencode")),
|
||||
)
|
||||
const contentType = response.headers["content-type"] || ""
|
||||
const mime = mimeFrom(contentType)
|
||||
if (isImageAttachment(mime))
|
||||
return yield* Effect.fail(new Error(`Unsupported fetched image content type: ${mime}`))
|
||||
if (!isTextualMime(mime))
|
||||
return yield* Effect.fail(new Error(`Unsupported fetched file content type: ${mime}`))
|
||||
return { body: yield* collectBody(response), contentType }
|
||||
}).pipe(
|
||||
const { body, contentType } = yield* execute(http, input.url, input.format).pipe(
|
||||
Effect.catchIf(isCloudflareChallenge, () => execute(http, input.url, input.format, "opencode")),
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.seconds(input.timeout ?? DEFAULT_TIMEOUT_SECONDS),
|
||||
orElse: () => Effect.fail(new Error("Request timed out")),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { truthy } from "../flag/flag"
|
||||
import { InstallationVersion } from "../installation/version"
|
||||
import { HttpTelemetry } from "../observability/http"
|
||||
import { PositiveInt } from "../schema"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
@@ -167,13 +168,16 @@ const callMcp = <F extends Schema.Struct.Fields>(
|
||||
}),
|
||||
)
|
||||
return yield* Effect.gen(function* () {
|
||||
const response = yield* HttpClient.filterStatusOk(http).execute(request)
|
||||
const body = yield* collectBoundedResponseBody(
|
||||
response,
|
||||
MAX_RESPONSE_BYTES,
|
||||
() => new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`),
|
||||
return yield* HttpTelemetry.use(
|
||||
HttpClient.filterStatusOk(http),
|
||||
request,
|
||||
(response) =>
|
||||
collectBoundedResponseBody(
|
||||
response,
|
||||
MAX_RESPONSE_BYTES,
|
||||
() => new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`),
|
||||
).pipe(Effect.flatMap((body) => parseResponse(body.toString("utf8")))),
|
||||
)
|
||||
return yield* parseResponse(body.toString("utf8"))
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.seconds(25),
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Effect, Layer, Logger } from "effect"
|
||||
import {
|
||||
ATTR_DEPLOYMENT_ENVIRONMENT_NAME,
|
||||
ATTR_ERROR_TYPE,
|
||||
ATTR_GEN_AI_CONVERSATION_ID,
|
||||
ATTR_OPENCODE_ERROR_SOURCE,
|
||||
ATTR_OPENCODE_ERROR_STAGE,
|
||||
ATTR_OPENCODE_LINK_TYPE,
|
||||
ATTR_OPENCODE_CLIENT,
|
||||
ATTR_OPENCODE_RUN,
|
||||
ATTR_OPENCODE_TOOL_OUTCOME,
|
||||
ATTR_SERVICE_INSTANCE_ID,
|
||||
ATTR_SERVICE_NAMESPACE,
|
||||
ATTR_URL_FULL,
|
||||
} from "@opencode-ai/core/observability/semconv"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Logger, Option, Tracer } from "effect"
|
||||
import { ParentSpan, type Span } from "effect/Tracer"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { fileLogger } from "../../src/observability/logging"
|
||||
import { resource } from "../../src/observability/otlp"
|
||||
import { SessionTelemetry } from "../../src/observability/session"
|
||||
import { HttpTelemetry } from "../../src/observability/http"
|
||||
import { AgentTelemetry } from "../../src/observability/agent"
|
||||
import { ToolTelemetry } from "../../src/observability/tool"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
const otelResourceAttributes = process.env.OTEL_RESOURCE_ATTRIBUTES
|
||||
const opencodeClient = process.env.OPENCODE_CLIENT
|
||||
@@ -20,8 +41,7 @@ afterEach(() => {
|
||||
|
||||
describe("resource", () => {
|
||||
test("parses and decodes OTEL resource attributes", () => {
|
||||
process.env.OTEL_RESOURCE_ATTRIBUTES =
|
||||
"service.namespace=anomalyco,team=platform%2Cobservability,label=hello%3Dworld,key%2Fname=value%20here"
|
||||
process.env.OTEL_RESOURCE_ATTRIBUTES = `${ATTR_SERVICE_NAMESPACE}=anomalyco,team=platform%2Cobservability,label=hello%3Dworld,key%2Fname=value%20here`
|
||||
|
||||
expect(resource().attributes).toMatchObject({
|
||||
"service.namespace": "anomalyco",
|
||||
@@ -32,26 +52,298 @@ describe("resource", () => {
|
||||
})
|
||||
|
||||
test("drops OTEL resource attributes when any entry is invalid", () => {
|
||||
process.env.OTEL_RESOURCE_ATTRIBUTES = "service.namespace=anomalyco,broken"
|
||||
process.env.OTEL_RESOURCE_ATTRIBUTES = `${ATTR_SERVICE_NAMESPACE}=anomalyco,broken`
|
||||
|
||||
expect(resource().attributes["service.namespace"]).toBeUndefined()
|
||||
expect(resource().attributes["opencode.client"]).toBeDefined()
|
||||
expect(resource().attributes[ATTR_SERVICE_NAMESPACE]).toBeUndefined()
|
||||
expect(resource().attributes[ATTR_OPENCODE_CLIENT]).toBeDefined()
|
||||
})
|
||||
|
||||
test("keeps built-in attributes when env values conflict", () => {
|
||||
process.env.OPENCODE_CLIENT = "cli"
|
||||
process.env.OTEL_RESOURCE_ATTRIBUTES =
|
||||
"opencode.client=web,service.instance.id=override,service.namespace=anomalyco"
|
||||
process.env.OTEL_RESOURCE_ATTRIBUTES = `${ATTR_OPENCODE_CLIENT}=web,${ATTR_SERVICE_INSTANCE_ID}=override,${ATTR_SERVICE_NAMESPACE}=anomalyco`
|
||||
|
||||
expect(resource().attributes).toMatchObject({
|
||||
"opencode.client": "cli",
|
||||
"service.namespace": "anomalyco",
|
||||
[ATTR_OPENCODE_CLIENT]: "cli",
|
||||
[ATTR_SERVICE_NAMESPACE]: "anomalyco",
|
||||
})
|
||||
expect(resource().attributes["service.instance.id"]).not.toBe("override")
|
||||
expect(resource().attributes["opencode.run"]).toMatch(/^[0-9a-f]{8}$/)
|
||||
expect(resource().attributes[ATTR_SERVICE_INSTANCE_ID]).not.toBe("override")
|
||||
expect(resource().attributes[ATTR_OPENCODE_RUN]).toMatch(/^[0-9a-f]{8}$/)
|
||||
})
|
||||
|
||||
test("uses deployment environment from OTEL resource attributes", () => {
|
||||
process.env.OTEL_RESOURCE_ATTRIBUTES = `${ATTR_DEPLOYMENT_ENVIRONMENT_NAME}=development`
|
||||
|
||||
expect(resource().attributes[ATTR_DEPLOYMENT_ENVIRONMENT_NAME]).toBe("development")
|
||||
})
|
||||
})
|
||||
|
||||
it.effect("retains an execution trace parent until the execution settles", () =>
|
||||
Effect.gen(function* () {
|
||||
const telemetry = SessionTelemetry.makeExecution<string>()
|
||||
const started = yield* Deferred.make<void>()
|
||||
let parent: Span | undefined
|
||||
|
||||
yield* Effect.useSpan("parent", (span) =>
|
||||
Effect.gen(function* () {
|
||||
parent = span
|
||||
const joiner = yield* telemetry
|
||||
.resume("session", Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)))
|
||||
.pipe(Effect.provideService(SessionTelemetry.TraceParent, span), Effect.forkChild)
|
||||
yield* Deferred.await(started)
|
||||
yield* Fiber.interrupt(joiner)
|
||||
}),
|
||||
)
|
||||
|
||||
const retained = Option.getOrUndefined(yield* telemetry.drain("session", Effect.serviceOption(ParentSpan)))
|
||||
expect(retained).toBe(parent)
|
||||
|
||||
yield* telemetry.settled("session")
|
||||
const released = Option.getOrUndefined(yield* telemetry.drain("session", Effect.serviceOption(ParentSpan)))
|
||||
expect(released).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains an external ambient trace parent", () =>
|
||||
Effect.gen(function* () {
|
||||
const telemetry = SessionTelemetry.makeExecution<string>()
|
||||
const parent = Tracer.externalSpan({ traceId: "1".repeat(32), spanId: "2".repeat(16) })
|
||||
|
||||
yield* telemetry.resume("session", Effect.void).pipe(Effect.withParentSpan(parent))
|
||||
const retained = Option.getOrUndefined(yield* telemetry.drain("session", Effect.serviceOption(ParentSpan)))
|
||||
|
||||
expect(retained).toBe(parent)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("detaches a top-level execution from its acquisition parent", () =>
|
||||
Effect.gen(function* () {
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
const telemetry = SessionTelemetry.makeExecution<string>()
|
||||
|
||||
yield* Effect.useSpan("startup", () =>
|
||||
telemetry.drain(
|
||||
"session",
|
||||
AgentTelemetry.invoke({ sessionID: "session", agent: "build", errorType: () => "unknown" }, Effect.void),
|
||||
),
|
||||
).pipe(Effect.provideService(Tracer.Tracer, tracer))
|
||||
|
||||
expect(spans.find((span) => span.name === "invoke_agent build")?.parent._tag).toBe("None")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("links a detached execution to its spawning span", () =>
|
||||
Effect.gen(function* () {
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
const telemetry = SessionTelemetry.makeExecution<string>()
|
||||
|
||||
yield* Effect.useSpan("execute_tool subagent", (parent) =>
|
||||
telemetry
|
||||
.resume("session", Effect.void)
|
||||
.pipe(
|
||||
Effect.provideService(SessionTelemetry.TraceParent, null),
|
||||
Effect.provideService(SessionTelemetry.TraceLinks, [{ span: parent, attributes: {} }]),
|
||||
),
|
||||
).pipe(Effect.provideService(Tracer.Tracer, tracer))
|
||||
yield* telemetry
|
||||
.drain(
|
||||
"session",
|
||||
AgentTelemetry.invoke({ sessionID: "session", agent: "explore", errorType: () => "unknown" }, Effect.void),
|
||||
)
|
||||
.pipe(Effect.provideService(Tracer.Tracer, tracer))
|
||||
|
||||
const parent = spans.find((span) => span.name === "execute_tool subagent")
|
||||
const child = spans.find((span) => span.name === "invoke_agent explore")
|
||||
expect(child?.parent._tag).toBe("None")
|
||||
expect(child?.links).toHaveLength(1)
|
||||
expect(child?.links[0]?.span).toBe(parent)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("links each agent turn to the previous Session turn", () =>
|
||||
Effect.gen(function* () {
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
const telemetry = SessionTelemetry.makeExecution<string>()
|
||||
const run = telemetry
|
||||
.drain(
|
||||
"session",
|
||||
AgentTelemetry.invoke({ sessionID: "session", agent: "build", errorType: () => "unknown" }, Effect.void),
|
||||
)
|
||||
.pipe(Effect.provideService(Tracer.Tracer, tracer))
|
||||
|
||||
yield* run
|
||||
yield* telemetry.settled("session")
|
||||
yield* run
|
||||
|
||||
const turns = spans.filter((span) => span.name === "invoke_agent build")
|
||||
expect(turns).toHaveLength(2)
|
||||
expect(turns[1]?.links).toHaveLength(1)
|
||||
expect(turns[1]?.links[0]?.span).toBe(turns[0])
|
||||
expect(turns[1]?.links[0]?.attributes[ATTR_OPENCODE_LINK_TYPE]).toBe("previous_turn")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps previous-turn links isolated by Session", () =>
|
||||
Effect.gen(function* () {
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
const telemetry = SessionTelemetry.makeExecution<string>()
|
||||
const run = (sessionID: string) =>
|
||||
telemetry
|
||||
.drain(
|
||||
sessionID,
|
||||
AgentTelemetry.invoke({ sessionID, agent: "build", errorType: () => "unknown" }, Effect.void),
|
||||
)
|
||||
.pipe(Effect.provideService(Tracer.Tracer, tracer))
|
||||
|
||||
yield* run("a")
|
||||
yield* run("b")
|
||||
yield* run("a")
|
||||
|
||||
const a = spans.filter((span) => span.attributes.get(ATTR_GEN_AI_CONVERSATION_ID) === "a")
|
||||
const b = spans.filter((span) => span.attributes.get(ATTR_GEN_AI_CONVERSATION_ID) === "b")
|
||||
expect(a).toHaveLength(2)
|
||||
expect(b).toHaveLength(1)
|
||||
expect(a[1]?.links[0]?.span).toBe(a[0])
|
||||
expect(b[0]?.links).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("closes an active agent span when its execution scope is interrupted", () =>
|
||||
Effect.gen(function* () {
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
const started = yield* Deferred.make<void>()
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const fiber = yield* AgentTelemetry.invoke(
|
||||
{ sessionID: "session", agent: "build", errorType: () => "unknown" },
|
||||
Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
|
||||
).pipe(Effect.provideService(SessionTelemetry.TraceParent, null), Effect.forkChild)
|
||||
yield* Deferred.await(started)
|
||||
yield* Fiber.interrupt(fiber)
|
||||
}).pipe(Effect.provideService(Tracer.Tracer, tracer))
|
||||
|
||||
const span = spans.find((span) => span.name === "invoke_agent build")
|
||||
expect(span?.attributes.get(ATTR_ERROR_TYPE)).toBe("canceled")
|
||||
expect(span?.status._tag === "Ended" && span.status.exit._tag).toBe("Failure")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies a tool cause containing interruption as canceled", () =>
|
||||
Effect.gen(function* () {
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
const cause = Cause.fromReasons([
|
||||
Cause.makeFailReason(new Error("concurrent failure")),
|
||||
Cause.makeInterruptReason(),
|
||||
])
|
||||
|
||||
const exit = yield* ToolTelemetry.execute(
|
||||
{ sessionID: "session", agent: "explore", call: { id: "call", name: "read" } },
|
||||
Effect.failCause(cause),
|
||||
() => "tool.execution",
|
||||
).pipe(Effect.exit, Effect.provideService(Tracer.Tracer, tracer))
|
||||
|
||||
const span = spans.find((span) => span.name === "execute_tool read")
|
||||
expect(span?.attributes.get(ATTR_ERROR_TYPE)).toBe("canceled")
|
||||
expect(span?.attributes.get(ATTR_OPENCODE_ERROR_SOURCE)).toBe("cancellation")
|
||||
expect(span?.attributes.get(ATTR_OPENCODE_ERROR_STAGE)).toBe("execution")
|
||||
expect(span?.attributes.get(ATTR_OPENCODE_TOOL_OUTCOME)).toBe("canceled")
|
||||
expect(span?.status._tag === "Ended" && span.status.exit._tag).toBe("Failure")
|
||||
expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBeTrue()
|
||||
expect(Exit.isFailure(exit) ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined).toBeInstanceOf(
|
||||
Error,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("applies HTTP response validation without a parent span", () =>
|
||||
Effect.gen(function* () {
|
||||
const request = HttpClientRequest.get("https://example.test/missing")
|
||||
const http = HttpClient.make((request) =>
|
||||
Effect.succeed(HttpClientResponse.fromWeb(request, new Response("missing", { status: 404 }))),
|
||||
)
|
||||
|
||||
const exit = yield* HttpTelemetry.use(
|
||||
http,
|
||||
request,
|
||||
Effect.succeed,
|
||||
HttpClientResponse.filterStatusOk,
|
||||
).pipe(Effect.exit)
|
||||
|
||||
expect(exit._tag).toBe("Failure")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits URL credentials, query, and fragment without changing the request", () =>
|
||||
Effect.gen(function* () {
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
const url = "https://user:password@example.test/path?region=us-east-1#fragment"
|
||||
let executedUrl: string | undefined
|
||||
const request = HttpClientRequest.get(url)
|
||||
const http = HttpClient.make((request) => {
|
||||
executedUrl = request.url
|
||||
return Effect.succeed(HttpClientResponse.fromWeb(request, new Response("ok")))
|
||||
})
|
||||
|
||||
yield* HttpTelemetry.use(http, request, Effect.succeed).pipe(
|
||||
Effect.withSpan("execute_tool webfetch"),
|
||||
Effect.provideService(Tracer.Tracer, tracer),
|
||||
)
|
||||
|
||||
expect(executedUrl).toBe(url)
|
||||
expect(spans.find((span) => span.name === "GET")?.attributes.get(ATTR_URL_FULL)).toBe(
|
||||
"https://example.test/path",
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
test("falls back to local logging when OTLP initialization fails", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-observability-test-"))
|
||||
await using _ = {
|
||||
|
||||
@@ -28,7 +28,8 @@ import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { McpTool } from "@opencode-ai/core/tool/mcp"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { Deferred, Effect, Exit, Fiber, Layer, Stream } from "effect"
|
||||
import { ATTR_ERROR_TYPE } from "@opencode-ai/core/observability/semconv"
|
||||
import { Deferred, Effect, Exit, Fiber, Layer, Stream, Tracer } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { location } from "./fixture/location"
|
||||
import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
|
||||
@@ -589,6 +590,14 @@ it.effect("waits for permission before calling an MCP tool", () =>
|
||||
|
||||
it.effect("does not call MCP when permission is blocked", () =>
|
||||
Effect.gen(function* () {
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
calls = 0
|
||||
assertion = yield* Deferred.make<PermissionV2.AssertInput>()
|
||||
decision = Effect.fail(new PermissionV2.BlockedError({ rules: [], permission: "demo_search", resources: ["*"] }))
|
||||
@@ -604,12 +613,17 @@ it.effect("does not call MCP when permission is blocked", () =>
|
||||
name: "execute",
|
||||
input: { code: "return await tools.demo.search({})" },
|
||||
},
|
||||
})
|
||||
}).pipe(Effect.provideService(Tracer.Tracer, tracer))
|
||||
expect(settlement.result).toEqual({ type: "text", value: "Unable to execute demo_search" })
|
||||
expect(settlement.output?.structured).toEqual({
|
||||
toolCalls: [{ tool: "demo.search", status: "error" }],
|
||||
error: true,
|
||||
})
|
||||
expect(calls).toBe(0)
|
||||
const outer = spans.find((span) => span.name === "execute_tool execute")
|
||||
const nested = spans.find((span) => span.name === "execute_tool demo_search")
|
||||
expect(outer?.attributes.get(ATTR_ERROR_TYPE)).toBe("tool.execution")
|
||||
expect(nested?.attributes.get(ATTR_ERROR_TYPE)).toBe("tool.execution")
|
||||
expect(nested?.parent._tag === "Some" && nested.parent.value === outer).toBeTrue()
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { HttpRecorder } from "@opencode-ai/http-recorder"
|
||||
import {
|
||||
ATTR_GEN_AI_CONVERSATION_ID,
|
||||
ATTR_GEN_AI_USAGE_INPUT_TOKENS,
|
||||
ATTR_GEN_AI_USAGE_OUTPUT_TOKENS,
|
||||
ATTR_HTTP_REQUEST_METHOD,
|
||||
} from "@opencode-ai/core/observability/semconv"
|
||||
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
|
||||
import { Auth, LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
@@ -35,10 +41,11 @@ import { Instructions } from "@opencode-ai/core/instructions"
|
||||
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
|
||||
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
|
||||
import { McpGuidance } from "@opencode-ai/core/mcp/guidance"
|
||||
import { SessionTelemetry } from "@opencode-ai/core/observability/session"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Effect, Layer, References, Tracer } from "effect"
|
||||
import path from "node:path"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
@@ -92,22 +99,40 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
[PluginSupervisor.node, pluginSupervisor],
|
||||
])
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
const execution = Layer.effect(
|
||||
SessionExecution.Service,
|
||||
Effect.gen(function* () {
|
||||
const sessionRunner = yield* SessionRunner.Service
|
||||
const telemetry = SessionTelemetry.makeExecution<SessionV2.ID>()
|
||||
const coordinator = yield* SessionRunCoordinator.make<SessionV2.ID, SessionRunner.RunError>({
|
||||
drain: (sessionID, force) => sessionRunner.drain({ sessionID, force }),
|
||||
drain: (sessionID, force) => telemetry.drain(sessionID, sessionRunner.drain({ sessionID, force })),
|
||||
settled: (sessionID) => telemetry.settled(sessionID),
|
||||
})
|
||||
return SessionExecution.Service.of({
|
||||
active: coordinator.active,
|
||||
resume: coordinator.run,
|
||||
resume: (sessionID) => telemetry.resume(sessionID, coordinator.run(sessionID)),
|
||||
wake: coordinator.wake,
|
||||
interrupt: coordinator.interrupt,
|
||||
awaitIdle: coordinator.awaitIdle,
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(runnerLayer))
|
||||
).pipe(
|
||||
Layer.provide(runnerLayer),
|
||||
Layer.provideMerge(
|
||||
Layer.mergeAll(
|
||||
Layer.succeed(Tracer.Tracer, tracer),
|
||||
Layer.succeed(References.TracerEnabled, false),
|
||||
),
|
||||
),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
@@ -149,6 +174,7 @@ const sessionID = SessionV2.ID.make("ses_runner_recorded")
|
||||
describe("SessionRunnerLLM recorded", () => {
|
||||
it.effect("executes one recorded V2 prompt through the recorded HTTP transport", () =>
|
||||
Effect.gen(function* () {
|
||||
spans.length = 0
|
||||
const agents = yield* AgentV2.Service
|
||||
yield* agents.transform((draft) =>
|
||||
draft.update(AgentV2.ID.make("build"), (agent) => {
|
||||
@@ -182,7 +208,7 @@ describe("SessionRunnerLLM recorded", () => {
|
||||
resume: false,
|
||||
})
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
yield* session.resume(sessionID).pipe(Effect.provideService(SessionTelemetry.TraceParent, null))
|
||||
|
||||
const messages = yield* session.context(sessionID)
|
||||
expect(messages).toHaveLength(2)
|
||||
@@ -191,6 +217,16 @@ describe("SessionRunnerLLM recorded", () => {
|
||||
expect(messages[1]?.type === "assistant" ? messages[1].content : []).toMatchObject([
|
||||
{ type: "text", text: "Hello!" },
|
||||
])
|
||||
const agent = spans.find((span) => span.name === "invoke_agent build")
|
||||
const model = spans.find((span) => span.name === "chat gpt-4o-mini")
|
||||
const http = spans.find((span) => span.attributes.get(ATTR_HTTP_REQUEST_METHOD) === "POST")
|
||||
expect(agent?.parent._tag).toBe("None")
|
||||
expect(agent?.attributes.get(ATTR_GEN_AI_CONVERSATION_ID)).toBe(sessionID)
|
||||
expect(model?.parent._tag === "Some" ? model.parent.value.spanId : undefined).toBe(agent?.spanId)
|
||||
expect(http?.parent._tag === "Some" ? http.parent.value.spanId : undefined).toBe(model?.spanId)
|
||||
expect(model?.attributes.get(ATTR_GEN_AI_USAGE_INPUT_TOKENS)).toBeNumber()
|
||||
expect(model?.attributes.get(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS)).toBeNumber()
|
||||
expect(spans.filter((span) => span.name.startsWith("SessionRunner."))).toEqual([])
|
||||
expect(
|
||||
(yield* db
|
||||
.select({ type: EventTable.type })
|
||||
|
||||
@@ -7,8 +7,22 @@ import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ATTR_ERROR_TYPE, ATTR_GEN_AI_TOOL_CALL_ID } from "@opencode-ai/core/observability/semconv"
|
||||
import { executeTool, settleTool, testModel, toolDefinitions } from "./lib/tool"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
|
||||
import {
|
||||
Cause,
|
||||
Deferred,
|
||||
Effect,
|
||||
Exit,
|
||||
Fiber,
|
||||
Layer,
|
||||
Option,
|
||||
Schema,
|
||||
SchemaGetter,
|
||||
SchemaIssue,
|
||||
Scope,
|
||||
Tracer,
|
||||
} from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const bounds: ToolOutputStore.BoundInput[] = []
|
||||
@@ -215,6 +229,33 @@ describe("ToolRegistry", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("traces unknown and stale tool attempts", () =>
|
||||
Effect.gen(function* () {
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* service.register({ echo: make() })
|
||||
const materialized = yield* service.materialize({ model: testModel })
|
||||
|
||||
yield* materialized.settle(call("missing", "call-unknown")).pipe(Effect.provideService(Tracer.Tracer, tracer))
|
||||
yield* service.register({ echo: make() })
|
||||
yield* materialized.settle(call("echo", "call-stale")).pipe(Effect.provideService(Tracer.Tracer, tracer))
|
||||
|
||||
expect(spans.map((span) => span.name)).toEqual(["execute_tool missing", "execute_tool echo"])
|
||||
expect(spans[0]?.attributes.get(ATTR_ERROR_TYPE)).toBe("tool.unknown")
|
||||
expect(spans[0]?.attributes.get(ATTR_GEN_AI_TOOL_CALL_ID)).toBe("call-unknown")
|
||||
expect(spans[1]?.attributes.get(ATTR_ERROR_TYPE)).toBe("tool.stale")
|
||||
expect(spans[1]?.attributes.get(ATTR_GEN_AI_TOOL_CALL_ID)).toBe("call-stale")
|
||||
expect(spans.every((span) => span.status._tag === "Ended" && span.status.exit._tag === "Failure")).toBeTrue()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("propagates retention failures through settlement", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
|
||||
@@ -8,9 +8,45 @@ import {
|
||||
TransportReason,
|
||||
InvalidRequestReason,
|
||||
RateLimitReason,
|
||||
Usage,
|
||||
type LLMClientShape,
|
||||
type LLMRequest,
|
||||
} from "@opencode-ai/llm"
|
||||
import {
|
||||
ATTR_ERROR_TYPE,
|
||||
ATTR_GEN_AI_AGENT_NAME,
|
||||
ATTR_GEN_AI_CONVERSATION_ID,
|
||||
ATTR_GEN_AI_OPERATION_NAME,
|
||||
ATTR_GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS,
|
||||
ATTR_GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS,
|
||||
ATTR_GEN_AI_USAGE_INPUT_TOKENS,
|
||||
ATTR_GEN_AI_USAGE_OUTPUT_TOKENS,
|
||||
ATTR_GEN_AI_USAGE_REASONING_OUTPUT_TOKENS,
|
||||
ATTR_GEN_AI_TOOL_CALL_ID,
|
||||
ATTR_GEN_AI_TOOL_NAME,
|
||||
GEN_AI_OPERATION_NAME_VALUE_EXECUTE_TOOL,
|
||||
GEN_AI_OPERATION_NAME_VALUE_INVOKE_AGENT,
|
||||
ATTR_OPENCODE_AGENT_STEP_INDEX,
|
||||
ATTR_OPENCODE_AGENT_STEP_TRIGGER,
|
||||
ATTR_OPENCODE_COMPACTION_REASON,
|
||||
ATTR_OPENCODE_ERROR_STAGE,
|
||||
ATTR_OPENCODE_ERROR_SOURCE,
|
||||
ATTR_OPENCODE_RETRY_DECISION,
|
||||
ATTR_OPENCODE_RETRY_DELAY_SOURCE,
|
||||
ATTR_OPENCODE_RETRY_MAX_ATTEMPTS,
|
||||
ATTR_OPENCODE_RETRY_ATTEMPT,
|
||||
ATTR_OPENCODE_RETRY_DELAY_MS,
|
||||
ATTR_OPENCODE_SESSION_INPUT_COUNT,
|
||||
ATTR_OPENCODE_SESSION_INPUT_DELIVERY,
|
||||
ATTR_OPENCODE_SESSION_PARENT_ID,
|
||||
ATTR_OPENCODE_TOOL_OUTCOME,
|
||||
EVENT_OPENCODE_COMPACTION_FAILED,
|
||||
EVENT_OPENCODE_COMPACTION_COMPLETED,
|
||||
EVENT_OPENCODE_COMPACTION_STARTED,
|
||||
EVENT_OPENCODE_RETRY_SCHEDULED,
|
||||
EVENT_OPENCODE_RETRY_STOPPED,
|
||||
EVENT_OPENCODE_SESSION_INPUT_PROMOTED,
|
||||
} from "@opencode-ai/core/observability/semconv"
|
||||
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
|
||||
@@ -59,10 +95,11 @@ import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
|
||||
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
|
||||
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
|
||||
import { McpGuidance } from "@opencode-ai/core/mcp/guidance"
|
||||
import { SessionTelemetry } from "@opencode-ai/core/observability/session"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, References, Schema, Stream, Tracer } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { and, asc, eq } from "drizzle-orm"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -345,22 +382,40 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
[PluginSupervisor.node, pluginSupervisor],
|
||||
])
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
const execution = Layer.effect(
|
||||
SessionExecution.Service,
|
||||
Effect.gen(function* () {
|
||||
const sessionRunner = yield* SessionRunner.Service
|
||||
const telemetry = SessionTelemetry.makeExecution<SessionV2.ID>()
|
||||
const coordinator = yield* SessionRunCoordinator.make<SessionV2.ID, SessionRunner.RunError>({
|
||||
drain: (sessionID, force) => sessionRunner.drain({ sessionID, force }),
|
||||
drain: (sessionID, force) => telemetry.drain(sessionID, sessionRunner.drain({ sessionID, force })),
|
||||
settled: (sessionID) => telemetry.settled(sessionID),
|
||||
})
|
||||
return SessionExecution.Service.of({
|
||||
active: coordinator.active,
|
||||
resume: coordinator.run,
|
||||
resume: (sessionID) => telemetry.resume(sessionID, coordinator.run(sessionID)),
|
||||
wake: coordinator.wake,
|
||||
interrupt: coordinator.interrupt,
|
||||
awaitIdle: coordinator.awaitIdle,
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(runnerLayer))
|
||||
).pipe(
|
||||
Layer.provide(runnerLayer),
|
||||
Layer.provideMerge(
|
||||
Layer.mergeAll(
|
||||
Layer.succeed(Tracer.Tracer, tracer),
|
||||
Layer.succeed(References.TracerEnabled, false),
|
||||
),
|
||||
),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
@@ -449,6 +504,7 @@ const setup = Effect.gen(function* () {
|
||||
toolExecutionsReady = 5
|
||||
activeToolExecutions = 0
|
||||
maxActiveToolExecutions = 0
|
||||
spans.length = 0
|
||||
const agents = yield* AgentV2.Service
|
||||
yield* agents.transform((draft) =>
|
||||
draft.update(AgentV2.ID.make("build"), (agent) => {
|
||||
@@ -752,6 +808,186 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
|
||||
})
|
||||
|
||||
describe("SessionRunnerLLM", () => {
|
||||
it.effect("parents forked tool spans under the V2 agent turn", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, text: "Use echo", resume: false })
|
||||
const usage = new Usage({
|
||||
inputTokens: 8,
|
||||
outputTokens: 3,
|
||||
cacheReadInputTokens: 2,
|
||||
cacheWriteInputTokens: 1,
|
||||
reasoningTokens: 1,
|
||||
})
|
||||
responses = [
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-telemetry", name: "echo", input: { text: "hello" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls", usage }),
|
||||
LLMEvent.finish({ reason: "tool-calls", usage }),
|
||||
],
|
||||
[],
|
||||
]
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
const agent = spans.find((span) => span.name === "invoke_agent build")
|
||||
const tool = spans.find((span) => span.name === "execute_tool echo")
|
||||
expect(agent?.attributes).toMatchObject(
|
||||
new Map([
|
||||
[ATTR_GEN_AI_OPERATION_NAME, GEN_AI_OPERATION_NAME_VALUE_INVOKE_AGENT],
|
||||
[ATTR_GEN_AI_AGENT_NAME, "build"],
|
||||
[ATTR_GEN_AI_CONVERSATION_ID, sessionID],
|
||||
]),
|
||||
)
|
||||
expect(agent?.events.find(([name]) => name === EVENT_OPENCODE_SESSION_INPUT_PROMOTED)?.[2]).toMatchObject({
|
||||
[ATTR_OPENCODE_SESSION_INPUT_DELIVERY]: "steer",
|
||||
[ATTR_OPENCODE_SESSION_INPUT_COUNT]: 1,
|
||||
})
|
||||
expect(agent?.parent._tag).toBe("None")
|
||||
expect(spans.filter((span) => span.name.startsWith("SessionRunner."))).toEqual([])
|
||||
expect(agent?.attributes.get(ATTR_GEN_AI_USAGE_INPUT_TOKENS)).toBe(8)
|
||||
expect(agent?.attributes.get(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS)).toBe(3)
|
||||
expect(agent?.attributes.get(ATTR_GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS)).toBe(2)
|
||||
expect(agent?.attributes.get(ATTR_GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS)).toBe(1)
|
||||
expect(agent?.attributes.get(ATTR_GEN_AI_USAGE_REASONING_OUTPUT_TOKENS)).toBe(1)
|
||||
expect(tool?.attributes).toMatchObject(
|
||||
new Map([
|
||||
[ATTR_GEN_AI_OPERATION_NAME, GEN_AI_OPERATION_NAME_VALUE_EXECUTE_TOOL],
|
||||
[ATTR_GEN_AI_TOOL_NAME, "echo"],
|
||||
[ATTR_GEN_AI_TOOL_CALL_ID, "call-telemetry"],
|
||||
[ATTR_GEN_AI_CONVERSATION_ID, sessionID],
|
||||
]),
|
||||
)
|
||||
expect(tool?.attributes.get(ATTR_OPENCODE_TOOL_OUTCOME)).toBe("completed")
|
||||
expect(ancestorNames(tool).some((name) => name.startsWith("invoke_agent"))).toBeTrue()
|
||||
expect(ancestorNames(tool)).not.toContain("SessionRunner.attemptStep")
|
||||
expect(ancestorNames(tool)).not.toContain("chat fake-model")
|
||||
expect(tool?.status._tag === "Ended" && tool.status.exit._tag).toBe("Success")
|
||||
requests.length = 0
|
||||
spans.length = 0
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("tracks V2 subagent sessions with their parent session", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const agents = yield* AgentV2.Service
|
||||
yield* agents.transform((editor) =>
|
||||
editor.update(AgentV2.ID.make("explore"), (agent) => {
|
||||
agent.mode = "subagent"
|
||||
}),
|
||||
)
|
||||
const session = yield* SessionV2.Service
|
||||
const child = yield* session.create({
|
||||
id: otherSessionID,
|
||||
parentID: sessionID,
|
||||
agent: AgentV2.ID.make("explore"),
|
||||
})
|
||||
yield* session.prompt({
|
||||
sessionID: child.id,
|
||||
text: "Explore",
|
||||
resume: false,
|
||||
})
|
||||
|
||||
responses = [
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-subagent-telemetry", name: "echo", input: { text: "found" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
],
|
||||
[],
|
||||
]
|
||||
yield* session.resume(child.id)
|
||||
|
||||
const agent = spans.find((span) => span.name === "invoke_agent explore")
|
||||
const tool = spans.find((span) => span.name === "execute_tool echo")
|
||||
expect(agent?.attributes).toMatchObject(
|
||||
new Map([
|
||||
[ATTR_GEN_AI_OPERATION_NAME, GEN_AI_OPERATION_NAME_VALUE_INVOKE_AGENT],
|
||||
[ATTR_GEN_AI_AGENT_NAME, "explore"],
|
||||
[ATTR_GEN_AI_CONVERSATION_ID, child.id],
|
||||
[ATTR_OPENCODE_SESSION_PARENT_ID, sessionID],
|
||||
]),
|
||||
)
|
||||
expect(tool?.attributes).toMatchObject(
|
||||
new Map([
|
||||
[ATTR_GEN_AI_AGENT_NAME, "explore"],
|
||||
[ATTR_GEN_AI_CONVERSATION_ID, child.id],
|
||||
[ATTR_OPENCODE_SESSION_PARENT_ID, sessionID],
|
||||
]),
|
||||
)
|
||||
expect(ancestorNames(tool).some((name) => name.startsWith("invoke_agent"))).toBeTrue()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("distinguishes input and tool-result model calls", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({
|
||||
sessionID,
|
||||
text: "Use echo twice",
|
||||
resume: false,
|
||||
})
|
||||
responses = [
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-context-1", name: "echo", input: { text: "first" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
],
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-context-2", name: "echo", input: { text: "second" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
],
|
||||
[],
|
||||
]
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
const tools = spans.filter((span) => span.name === "execute_tool echo")
|
||||
expect(tools).toHaveLength(2)
|
||||
expect(tools[0]?.attributes.get(ATTR_OPENCODE_AGENT_STEP_INDEX)).toBe(1)
|
||||
expect(tools[0]?.attributes.get(ATTR_OPENCODE_AGENT_STEP_TRIGGER)).toBe("input")
|
||||
expect(tools[1]?.attributes.get(ATTR_OPENCODE_AGENT_STEP_INDEX)).toBe(2)
|
||||
expect(tools[1]?.attributes.get(ATTR_OPENCODE_AGENT_STEP_TRIGGER)).toBe("tool_result")
|
||||
expect(spans.filter((span) => span.name === "invoke_agent build")).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("marks failed tool spans before returning the error to the model", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, text: "Fail tool", resume: false })
|
||||
responses = [
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-telemetry-failure", name: "echo", input: {} }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
],
|
||||
[],
|
||||
]
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
const tool = spans.find((span) => span.name === "execute_tool echo")
|
||||
expect(tool?.attributes.get(ATTR_ERROR_TYPE)).toBe("tool.execution")
|
||||
expect(tool?.attributes.get(ATTR_OPENCODE_ERROR_SOURCE)).toBe("tool")
|
||||
expect(tool?.attributes.get(ATTR_OPENCODE_ERROR_STAGE)).toBe("execution")
|
||||
expect(tool?.attributes.get(ATTR_OPENCODE_TOOL_OUTCOME)).toBe("error")
|
||||
expect(tool?.status._tag === "Ended" && tool.status.exit._tag).toBe("Failure")
|
||||
requests.length = 0
|
||||
spans.length = 0
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("advertises and executes a location registered tool", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
@@ -1491,6 +1727,10 @@ describe("SessionRunnerLLM", () => {
|
||||
status: "completed",
|
||||
summary: "durable summary",
|
||||
})
|
||||
const turn = spans.find((span) => span.name === "invoke_agent build")
|
||||
expect(turn?.events.map(([name]) => name)).toEqual(
|
||||
expect.arrayContaining([EVENT_OPENCODE_COMPACTION_STARTED, EVENT_OPENCODE_COMPACTION_COMPLETED]),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1531,6 +1771,10 @@ describe("SessionRunnerLLM", () => {
|
||||
(type) => type === EventV2.versionedType(SessionEvent.Compaction.Failed.type, 1),
|
||||
),
|
||||
).toHaveLength(1)
|
||||
const turn = spans.find((span) => span.name === "invoke_agent build")
|
||||
expect(turn?.events.map(([name]) => name)).toEqual(
|
||||
expect.arrayContaining([EVENT_OPENCODE_COMPACTION_STARTED, EVENT_OPENCODE_COMPACTION_FAILED]),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1554,6 +1798,10 @@ describe("SessionRunnerLLM", () => {
|
||||
(type) => type === EventV2.versionedType(SessionEvent.Compaction.Failed.type, 1),
|
||||
),
|
||||
).toHaveLength(1)
|
||||
const turn = spans.find((span) => span.name === "invoke_agent build")
|
||||
expect(turn?.events.map(([name]) => name)).toEqual(
|
||||
expect.arrayContaining([EVENT_OPENCODE_COMPACTION_STARTED, EVENT_OPENCODE_COMPACTION_FAILED]),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1577,6 +1825,10 @@ describe("SessionRunnerLLM", () => {
|
||||
(type) => type === EventV2.versionedType(SessionEvent.Compaction.Failed.type, 1),
|
||||
),
|
||||
).toHaveLength(1)
|
||||
const turn = spans.find((span) => span.name === "invoke_agent build")
|
||||
expect(turn?.events.map(([name]) => name)).toEqual(
|
||||
expect.arrayContaining([EVENT_OPENCODE_COMPACTION_STARTED, EVENT_OPENCODE_COMPACTION_FAILED]),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1597,6 +1849,12 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(
|
||||
spans
|
||||
.filter((span) => span.name.startsWith("invoke_agent"))
|
||||
.at(-1)
|
||||
?.events.find(([name]) => name === EVENT_OPENCODE_COMPACTION_COMPLETED)?.[2],
|
||||
).toMatchObject({ [ATTR_OPENCODE_COMPACTION_REASON]: "automatic" })
|
||||
expect(userTexts(requests[0])[0]).toContain("## Objective")
|
||||
expect(userTexts(requests[1])).toHaveLength(1)
|
||||
expect(userTexts(requests[1])[0]).toContain("<summary>\n## Objective\n- Preserve the task\n</summary>")
|
||||
@@ -2266,6 +2524,7 @@ describe("SessionRunnerLLM", () => {
|
||||
"user",
|
||||
"assistant",
|
||||
])
|
||||
expect(spans.filter((span) => span.name === "invoke_agent build")).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2294,6 +2553,9 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(userTexts(requests[0]!)).toEqual(["Start working"])
|
||||
expect(userTexts(requests[1]!)).toEqual(["Start working"])
|
||||
expect(userTexts(requests[2]!)).toEqual(["Start working", "Wait until continuation ends"])
|
||||
const turns = spans.filter((span) => span.name === "invoke_agent build")
|
||||
expect(turns).toHaveLength(2)
|
||||
expect(turns[1]?.links.at(-1)?.span).toBe(turns[0])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2387,6 +2649,11 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(userTexts(requests[0]!)).toEqual(["Start working"])
|
||||
expect(userTexts(requests[1]!)).toEqual(["Start working", "Queue first"])
|
||||
expect(userTexts(requests[2]!)).toEqual(["Start working", "Queue first", "Queue second"])
|
||||
const turns = spans.filter((span) => span.name === "invoke_agent build")
|
||||
expect(turns).toHaveLength(3)
|
||||
expect(new Set(turns.map((span) => span.traceId)).size).toBe(3)
|
||||
expect(turns[1]?.links.at(-1)?.span).toBe(turns[0])
|
||||
expect(turns[2]?.links.at(-1)?.span).toBe(turns[1])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -3289,6 +3556,9 @@ describe("SessionRunnerLLM", () => {
|
||||
{ type: "assistant", finish: "error", error: { type: "aborted", message: "Step interrupted" } },
|
||||
])
|
||||
expect(yield* recordedEventTypes(sessionID)).toContain("session.step.failed.1")
|
||||
const agent = spans.find((span) => span.name.startsWith("invoke_agent"))
|
||||
expect(agent?.attributes.get(ATTR_ERROR_TYPE)).toBe("canceled")
|
||||
expect(agent?.status._tag === "Ended" && agent.status.exit._tag).toBe("Failure")
|
||||
yield* session.interrupt(sessionID)
|
||||
}),
|
||||
)
|
||||
@@ -3565,13 +3835,29 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(requests).toHaveLength(2)
|
||||
const eventTypes = yield* recordedEventTypes(sessionID)
|
||||
expect(eventTypes).toContain("session.retry.scheduled.1")
|
||||
expect(
|
||||
spans
|
||||
.find((span) => span.name.startsWith("invoke_agent"))
|
||||
?.events.find(([name]) => name === EVENT_OPENCODE_RETRY_SCHEDULED)?.[2],
|
||||
).toMatchObject({
|
||||
[ATTR_OPENCODE_RETRY_ATTEMPT]: 2,
|
||||
[ATTR_OPENCODE_RETRY_MAX_ATTEMPTS]: 5,
|
||||
[ATTR_OPENCODE_RETRY_DELAY_MS]: 2_000,
|
||||
[ATTR_OPENCODE_RETRY_DELAY_SOURCE]: "backoff",
|
||||
[ATTR_OPENCODE_RETRY_DECISION]: "scheduled",
|
||||
[ATTR_ERROR_TYPE]: "provider.transport",
|
||||
})
|
||||
expect(eventTypes.filter((type) => type === "session.step.started.1")).toHaveLength(2)
|
||||
expect(
|
||||
spans.find((span) => span.name.startsWith("invoke_agent"))?.attributes.has(ATTR_OPENCODE_ERROR_STAGE),
|
||||
).toBeFalse()
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user" },
|
||||
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] },
|
||||
])
|
||||
yield* replaySessionProjection(sessionID)
|
||||
expect((yield* session.context(sessionID)).filter((message) => message.type === "assistant")).toHaveLength(1)
|
||||
expect(spans.filter((span) => span.name === "invoke_agent build")).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -3589,6 +3875,14 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* TestClock.adjust("1 millis")
|
||||
yield* Fiber.join(run)
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(
|
||||
spans
|
||||
.find((span) => span.name === "invoke_agent build")
|
||||
?.events.find(([name]) => name === EVENT_OPENCODE_RETRY_SCHEDULED)?.[2],
|
||||
).toMatchObject({
|
||||
[ATTR_OPENCODE_RETRY_DELAY_MS]: 5_000,
|
||||
[ATTR_OPENCODE_RETRY_DELAY_SOURCE]: "max(backoff,retry_after)",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -3621,8 +3915,26 @@ describe("SessionRunnerLLM", () => {
|
||||
{ attempt: 4, at: 14_000 },
|
||||
{ attempt: 5, at: 30_000 },
|
||||
])
|
||||
expect(
|
||||
spans
|
||||
.find((span) => span.name.startsWith("invoke_agent"))
|
||||
?.events.find(([name]) => name === EVENT_OPENCODE_RETRY_STOPPED)?.[2],
|
||||
).toMatchObject({
|
||||
[ATTR_OPENCODE_RETRY_DECISION]: "exhausted",
|
||||
[ATTR_OPENCODE_RETRY_ATTEMPT]: 5,
|
||||
[ATTR_OPENCODE_RETRY_MAX_ATTEMPTS]: 5,
|
||||
})
|
||||
expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.step.started.1")).toHaveLength(5)
|
||||
expect((yield* session.context(sessionID)).filter((message) => message.type === "assistant")).toHaveLength(1)
|
||||
const agent = spans.find((span) => span.name.startsWith("invoke_agent"))
|
||||
expect(agent?.attributes.get(ATTR_ERROR_TYPE)).toBe("provider.transport")
|
||||
expect(agent?.attributes.get(ATTR_OPENCODE_ERROR_SOURCE)).toBe("provider")
|
||||
expect(agent?.attributes.get(ATTR_OPENCODE_ERROR_STAGE)).toBe("model")
|
||||
if (agent?.status._tag === "Ended" && agent.status.exit._tag === "Failure") {
|
||||
const failure = Cause.squash(agent.status.exit.cause)
|
||||
expect(failure).toMatchObject({ message: "provider.transport" })
|
||||
expect(failure).not.toMatchObject({ message: "Provider unavailable" })
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -3650,6 +3962,11 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(eventTypes.filter((type) => type === "session.step.started.1")).toHaveLength(2)
|
||||
expect(eventTypes.filter((type) => type === "session.retry.scheduled.1")).toHaveLength(1)
|
||||
expect((yield* session.context(sessionID)).filter((message) => message.type === "assistant")).toHaveLength(1)
|
||||
expect(
|
||||
spans
|
||||
.find((span) => span.name === "invoke_agent build")
|
||||
?.events.find(([name]) => name === EVENT_OPENCODE_RETRY_STOPPED)?.[2],
|
||||
).toMatchObject({ [ATTR_OPENCODE_RETRY_DECISION]: "step_limit" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -3663,6 +3980,11 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(yield* recordedEventTypes(sessionID)).not.toContain("session.retry.scheduled.1")
|
||||
expect(
|
||||
spans
|
||||
.find((span) => span.name === "invoke_agent build")
|
||||
?.events.find(([name]) => name === EVENT_OPENCODE_RETRY_STOPPED)?.[2],
|
||||
).toMatchObject({ [ATTR_OPENCODE_RETRY_DECISION]: "non_retryable" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4003,3 +4325,13 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
function ancestorNames(span: Tracer.NativeSpan | undefined) {
|
||||
const names: string[] = []
|
||||
let current = span?.parent._tag === "Some" ? span.parent.value : undefined
|
||||
while (current?._tag === "Span") {
|
||||
names.push(current.name)
|
||||
current = current.parent._tag === "Some" ? current.parent.value : undefined
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { DateTime, Effect, Fiber, Layer, Schema, Stream, Tracer } from "effect"
|
||||
import {
|
||||
ATTR_OPENCODE_LINK_TYPE,
|
||||
ATTR_OPENCODE_SUBAGENT_AGENT_NAME,
|
||||
ATTR_OPENCODE_SUBAGENT_SESSION_ID,
|
||||
} from "@opencode-ai/core/observability/semconv"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
@@ -25,6 +30,7 @@ import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { SubagentTool } from "@opencode-ai/core/tool/subagent"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { SessionTelemetry } from "@opencode-ai/core/observability/session"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { executeTool, settleTool, testModel, toolIdentity, waitForTool } from "./lib/tool"
|
||||
@@ -33,6 +39,11 @@ const childText = "child final response"
|
||||
const childModel = ModelV2.Ref.make({ id: ModelV2.ID.make("child"), providerID: ProviderV2.ID.make("test") })
|
||||
const parentModel = ModelV2.Ref.make({ id: ModelV2.ID.make("parent"), providerID: ProviderV2.ID.make("test") })
|
||||
const tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||
const resumedContexts: Array<{
|
||||
readonly sessionID: SessionV2.ID
|
||||
readonly parent: Tracer.AnySpan | null | undefined
|
||||
readonly links: ReadonlyArray<Tracer.SpanLink>
|
||||
}> = []
|
||||
|
||||
const outputSessionID = (value: unknown) => Schema.decodeUnknownSync(SubagentTool.Output)(value).sessionID
|
||||
|
||||
@@ -79,7 +90,15 @@ const executionNode = makeGlobalNode({
|
||||
})
|
||||
return SessionExecution.Service.of({
|
||||
active: Effect.succeed(new Set()),
|
||||
resume: complete,
|
||||
resume: (sessionID) =>
|
||||
Effect.gen(function* () {
|
||||
resumedContexts.push({
|
||||
sessionID,
|
||||
parent: yield* SessionTelemetry.TraceParent,
|
||||
links: yield* SessionTelemetry.TraceLinks,
|
||||
})
|
||||
return yield* complete(sessionID)
|
||||
}),
|
||||
wake: () => Effect.void,
|
||||
interrupt: () => Effect.void,
|
||||
awaitIdle: (sessionID) => complete(sessionID).pipe(Effect.exit, Effect.asVoid),
|
||||
@@ -146,6 +165,7 @@ describe("SubagentTool", () => {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
yield* waitForTool(registry, SubagentTool.name)
|
||||
resumedContexts.length = 0
|
||||
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toContain(
|
||||
SubagentTool.name,
|
||||
)
|
||||
@@ -180,6 +200,14 @@ describe("SubagentTool", () => {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
yield* waitForTool(registry, SubagentTool.name)
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
|
||||
const settled = yield* settleTool(registry, {
|
||||
sessionID: parent.id,
|
||||
@@ -190,7 +218,7 @@ describe("SubagentTool", () => {
|
||||
name: SubagentTool.name,
|
||||
input: { agent: "reviewer", description: "review", prompt: "review this" },
|
||||
},
|
||||
})
|
||||
}).pipe(Effect.provideService(Tracer.Tracer, tracer))
|
||||
|
||||
expect(settled.output?.structured).toMatchObject({ status: "completed", output: childText })
|
||||
const child = yield* sessions.get(outputSessionID(settled.output?.structured))
|
||||
@@ -200,6 +228,12 @@ describe("SubagentTool", () => {
|
||||
agent: "reviewer",
|
||||
model: childModel,
|
||||
})
|
||||
const span = spans.find((span) => span.name === "execute_tool subagent")
|
||||
expect(span?.attributes.get(ATTR_OPENCODE_SUBAGENT_AGENT_NAME)).toBe("reviewer")
|
||||
expect(span?.attributes.get(ATTR_OPENCODE_SUBAGENT_SESSION_ID)).toBe(child.id)
|
||||
const resumed = resumedContexts.find((context) => context.sessionID === child.id)
|
||||
expect(resumed?.parent?.spanId).toBe(span?.spanId)
|
||||
expect(resumed?.links).toEqual([])
|
||||
|
||||
const fallback = yield* settleTool(registry, {
|
||||
sessionID: parent.id,
|
||||
@@ -264,6 +298,15 @@ describe("SubagentTool", () => {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
yield* waitForTool(registry, SubagentTool.name)
|
||||
resumedContexts.length = 0
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
const events = yield* EventV2.Service
|
||||
const admitted = yield* events.subscribe(SessionEvent.InputAdmitted).pipe(
|
||||
Stream.filter((event) => event.data.sessionID === parent.id && event.data.input.type === "synthetic"),
|
||||
@@ -281,7 +324,7 @@ describe("SubagentTool", () => {
|
||||
name: SubagentTool.name,
|
||||
input: { agent: "reviewer", description: "background review", prompt: "review", background: true },
|
||||
},
|
||||
})
|
||||
}).pipe(Effect.provideService(Tracer.Tracer, tracer))
|
||||
const childID = outputSessionID(settled.output?.structured)
|
||||
expect(settled.output?.structured).toMatchObject({ status: "running" })
|
||||
|
||||
@@ -293,6 +336,14 @@ describe("SubagentTool", () => {
|
||||
expect(synthetic).toHaveLength(1)
|
||||
expect(synthetic[0]?.text).toContain(`<subagent id="${childID}" state="completed"`)
|
||||
expect(synthetic[0]?.text).toContain(childText)
|
||||
const resumed = resumedContexts.find((context) => context.sessionID === childID)
|
||||
expect(resumed?.parent).toBeNull()
|
||||
const span = spans.find((span) => span.name === "execute_tool subagent")
|
||||
expect(span).toBeDefined()
|
||||
if (!span) return
|
||||
expect(resumed?.links).toHaveLength(1)
|
||||
expect(resumed?.links[0]?.span.spanId).toBe(span.spanId)
|
||||
expect(resumed?.links[0]?.attributes[ATTR_OPENCODE_LINK_TYPE]).toBe("subagent")
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Duration, Effect, Fiber, Layer, Schema } from "effect"
|
||||
import { Duration, Effect, Fiber, Layer, Schema, Tracer } from "effect"
|
||||
import {
|
||||
ATTR_ERROR_TYPE,
|
||||
ATTR_HTTP_REQUEST_METHOD,
|
||||
ATTR_HTTP_RESPONSE_STATUS_CODE,
|
||||
ATTR_SERVER_PORT,
|
||||
ATTR_URL_FULL,
|
||||
} from "@opencode-ai/core/observability/semconv"
|
||||
import * as TestClock from "effect/testing/TestClock"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -88,9 +95,21 @@ describe("WebFetchTool registration", () => {
|
||||
reset()
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const url = "http://example.com/public"
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["webfetch"])
|
||||
expect(yield* settleTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({
|
||||
expect(
|
||||
yield* settleTool(registry, call({ url, format: "text", timeout: 4 })).pipe(
|
||||
Effect.provideService(Tracer.Tracer, tracer),
|
||||
),
|
||||
).toEqual({
|
||||
result: { type: "text", value: "hello" },
|
||||
output: {
|
||||
structured: { url, contentType: "text/plain", format: "text", output: "hello" },
|
||||
@@ -101,6 +120,38 @@ describe("WebFetchTool registration", () => {
|
||||
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text", timeout: 4 } },
|
||||
])
|
||||
expect(requests).toMatchObject([{ url, headers: { accept: expect.stringContaining("text/plain;q=1.0") } }])
|
||||
const tool = spans.find((span) => span.name === "execute_tool webfetch")
|
||||
const http = spans.find((span) => span.attributes.get(ATTR_HTTP_REQUEST_METHOD) === "GET")
|
||||
expect(http?.name).toBe("GET")
|
||||
expect(http?.attributes.get(ATTR_SERVER_PORT)).toBe(80)
|
||||
expect(http?.attributes.get(ATTR_URL_FULL)).toBe(url)
|
||||
expect(http?.parent._tag === "Some" ? http.parent.value.spanId : undefined).toBe(tool?.spanId)
|
||||
expect(requests[0]?.headers.traceparent).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("marks rejected HTTP statuses on the HTTP span", () =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
respond = () => Effect.succeed(new Response("missing", { status: 404 }))
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
|
||||
yield* settleTool(registry, call({ url: "https://example.com/missing", format: "text" })).pipe(
|
||||
Effect.provideService(Tracer.Tracer, tracer),
|
||||
)
|
||||
|
||||
const http = spans.find((span) => span.attributes.get(ATTR_HTTP_REQUEST_METHOD) === "GET")
|
||||
expect(http?.attributes.get(ATTR_HTTP_RESPONSE_STATUS_CODE)).toBe(404)
|
||||
expect(http?.attributes.get(ATTR_ERROR_TYPE)).toBe("StatusCodeError")
|
||||
expect(http?.status._tag === "Ended" && http.status.exit._tag).toBe("Failure")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -178,6 +178,7 @@ const FakeProtocol = Protocol.make<FakeBody, string, string, void>({
|
||||
// Protocol ids are open strings, so external packages can define their own
|
||||
// protocols without changing this package.
|
||||
id: "fake-echo",
|
||||
operation: "chat",
|
||||
body: {
|
||||
schema: FakeBody,
|
||||
from: (request) =>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
".": "./src/index.ts",
|
||||
"./route": "./src/route/index.ts",
|
||||
"./provider": "./src/provider.ts",
|
||||
"./semconv": "./src/semconv.ts",
|
||||
"./providers": "./src/providers/index.ts",
|
||||
"./provider-package": "./src/provider-package.ts",
|
||||
"./providers/amazon-bedrock": "./src/providers/amazon-bedrock.ts",
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from "../schema"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
import { isContextOverflow } from "../provider-error"
|
||||
import * as Cache from "./utils/cache"
|
||||
import { newBreakpoints, ttlBucket, type Breakpoints } from "./utils/cache"
|
||||
import { Lifecycle } from "./utils/lifecycle"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema"
|
||||
import { ToolStream } from "./utils/tool-stream"
|
||||
@@ -254,14 +254,14 @@ const ANTHROPIC_BREAKPOINT_CAP = 4
|
||||
const EPHEMERAL_5M = { type: "ephemeral" as const }
|
||||
const EPHEMERAL_1H = { type: "ephemeral" as const, ttl: "1h" as const }
|
||||
|
||||
const cacheControl = (breakpoints: Cache.Breakpoints, cache: CacheHint | undefined) => {
|
||||
const cacheControl = (breakpoints: Breakpoints, cache: CacheHint | undefined) => {
|
||||
if (cache?.type !== "ephemeral" && cache?.type !== "persistent") return undefined
|
||||
if (breakpoints.remaining <= 0) {
|
||||
breakpoints.dropped += 1
|
||||
return undefined
|
||||
}
|
||||
breakpoints.remaining -= 1
|
||||
return Cache.ttlBucket(cache.ttlSeconds) === "1h" ? EPHEMERAL_1H : EPHEMERAL_5M
|
||||
return ttlBucket(cache.ttlSeconds) === "1h" ? EPHEMERAL_1H : EPHEMERAL_5M
|
||||
}
|
||||
|
||||
const anthropicMetadata = (metadata: Record<string, unknown>): ProviderMetadata => ({ anthropic: metadata })
|
||||
@@ -272,7 +272,7 @@ const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string |
|
||||
return typeof anthropic.signature === "string" ? anthropic.signature : undefined
|
||||
}
|
||||
|
||||
const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSchema: JsonSchema): AnthropicTool => ({
|
||||
const lowerTool = (breakpoints: Breakpoints, tool: ToolDefinition, inputSchema: JsonSchema): AnthropicTool => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
input_schema: inputSchema,
|
||||
@@ -400,7 +400,7 @@ const splitsLocalToolResults = (messages: LLMRequest["messages"], index: number)
|
||||
|
||||
const lowerNativeSystemUpdate = Effect.fn("AnthropicMessages.lowerNativeSystemUpdate")(function* (
|
||||
message: LLMRequest["messages"][number],
|
||||
breakpoints: Cache.Breakpoints,
|
||||
breakpoints: Breakpoints,
|
||||
) {
|
||||
const content = yield* ProviderShared.systemUpdateText("Anthropic Messages", message)
|
||||
return {
|
||||
@@ -415,7 +415,7 @@ const lowerNativeSystemUpdate = Effect.fn("AnthropicMessages.lowerNativeSystemUp
|
||||
|
||||
const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
request: LLMRequest,
|
||||
breakpoints: Cache.Breakpoints,
|
||||
breakpoints: Breakpoints,
|
||||
) {
|
||||
const messages: AnthropicMessage[] = []
|
||||
|
||||
@@ -541,7 +541,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
|
||||
// messages. Tools live highest in the cache hierarchy, so when callers
|
||||
// over-mark we keep their tool hints and shed the message-tail ones first.
|
||||
const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP)
|
||||
const breakpoints = newBreakpoints(ANTHROPIC_BREAKPOINT_CAP)
|
||||
const tools =
|
||||
request.tools.length === 0 || request.toolChoice?.type === "none"
|
||||
? undefined
|
||||
@@ -862,6 +862,7 @@ const step = (state: ParserState, event: AnthropicEvent) => {
|
||||
*/
|
||||
export const protocol = Protocol.make({
|
||||
id: ADAPTER,
|
||||
operation: "chat",
|
||||
body: {
|
||||
schema: AnthropicMessagesBody,
|
||||
from: fromRequest,
|
||||
|
||||
@@ -636,6 +636,7 @@ const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> =>
|
||||
*/
|
||||
export const protocol = Protocol.make({
|
||||
id: ADAPTER,
|
||||
operation: "chat",
|
||||
body: {
|
||||
schema: BedrockConverseBody,
|
||||
from: fromRequest,
|
||||
|
||||
@@ -485,6 +485,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
*/
|
||||
export const protocol = Protocol.make({
|
||||
id: ADAPTER,
|
||||
operation: "generate_content",
|
||||
body: {
|
||||
schema: GeminiBody,
|
||||
from: fromRequest,
|
||||
|
||||
@@ -476,6 +476,7 @@ const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
*/
|
||||
export const protocol = Protocol.make({
|
||||
id: ADAPTER,
|
||||
operation: "chat",
|
||||
body: {
|
||||
schema: OpenAIChatBody,
|
||||
from: fromRequest,
|
||||
|
||||
@@ -964,6 +964,7 @@ const step = (state: ParserState, event: OpenAIResponsesEvent) => {
|
||||
*/
|
||||
export const protocol = Protocol.make({
|
||||
id: ADAPTER,
|
||||
operation: "chat",
|
||||
body: {
|
||||
schema: OpenAIResponsesBody,
|
||||
from: fromRequest,
|
||||
|
||||
@@ -37,6 +37,7 @@ export type OpenRouterBody = Schema.Schema.Type<typeof OpenRouterBody>
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
id: "openrouter-chat",
|
||||
operation: OpenAIChat.protocol.operation,
|
||||
body: {
|
||||
schema: OpenRouterBody,
|
||||
from: (request) =>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { Cause, Context, Effect, Layer, Schema, Stream } from "effect"
|
||||
import * as Option from "effect/Option"
|
||||
import { Auth, type Auth as AuthDef } from "./auth"
|
||||
import { Cause, Context, Effect, Layer, Option, Schema, Stream } from "effect"
|
||||
import { Auth } from "./auth"
|
||||
import { Endpoint, type EndpointPatch } from "./endpoint"
|
||||
import { RequestExecutor } from "./executor"
|
||||
import type { Framing } from "./framing"
|
||||
import { HttpTransport } from "./transport"
|
||||
import type { Transport, TransportRuntime } from "./transport"
|
||||
import { WebSocketExecutor } from "./transport"
|
||||
import type { Protocol } from "./protocol"
|
||||
import type { GenAIOperation, Protocol } from "./protocol"
|
||||
import { applyCachePolicy } from "../cache-policy"
|
||||
import * as ProviderShared from "../protocols/shared"
|
||||
import type { LLMError, LLMEvent, PreparedRequestOf, ProtocolID, ProviderOptions } from "../schema"
|
||||
import { LLMTelemetry } from "../telemetry"
|
||||
import { ProviderShared } from "../protocols/shared"
|
||||
import type { LLMEvent, PreparedRequestOf, ProtocolID, ProviderOptions } from "../schema"
|
||||
import {
|
||||
GenerationOptions,
|
||||
HttpOptions,
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
LLMResponse,
|
||||
Model,
|
||||
ModelLimits,
|
||||
LLMError as LLMErrorClass,
|
||||
LLMError,
|
||||
PreparedRequest,
|
||||
ProviderID,
|
||||
mergeGenerationOptions,
|
||||
@@ -39,8 +39,9 @@ export interface Route<Body, Prepared = unknown> {
|
||||
/** ProviderMetadata namespace emitted and consumed by this route. */
|
||||
readonly providerMetadataKey?: string
|
||||
readonly protocol: ProtocolID
|
||||
readonly operation: GenAIOperation
|
||||
readonly endpoint: Endpoint<Body>
|
||||
readonly auth: AuthDef
|
||||
readonly auth: Auth
|
||||
readonly transport: Transport<Body, Prepared, unknown>
|
||||
readonly defaults: RouteDefaults
|
||||
readonly body: RouteBody<Body>
|
||||
@@ -85,7 +86,7 @@ export interface RouteDefaultsInput {
|
||||
export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
|
||||
readonly id?: string
|
||||
readonly provider?: string | ProviderID
|
||||
readonly auth?: AuthDef
|
||||
readonly auth?: Auth
|
||||
readonly transport?: Transport<Body, Prepared, unknown>
|
||||
readonly endpoint?: EndpointPatch<Body>
|
||||
}
|
||||
@@ -193,7 +194,7 @@ export interface MakeInput<Body, Frame, Event, State> {
|
||||
/** Where the request is sent. */
|
||||
readonly endpoint: Endpoint<Body>
|
||||
/** Per-request transport auth. Provider facades override this via `route.with(...)`. */
|
||||
readonly auth?: AuthDef
|
||||
readonly auth?: Auth
|
||||
/** Stream framing — bytes -> frames before `protocol.stream.event` decoding. */
|
||||
readonly framing: Framing<Frame>
|
||||
/** Static / per-request headers added before `auth` runs. */
|
||||
@@ -214,7 +215,7 @@ export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
|
||||
/** Where the request is sent. */
|
||||
readonly endpoint: Endpoint<Body>
|
||||
/** Per-request transport auth. Provider facades override this via `route.with(...)`. */
|
||||
readonly auth?: AuthDef
|
||||
readonly auth?: Auth
|
||||
/** Static / per-request headers added before `auth` runs. */
|
||||
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
|
||||
/** Runnable transport route. */
|
||||
@@ -225,7 +226,7 @@ export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
|
||||
|
||||
const streamError = (route: string, message: string, cause: Cause.Cause<unknown>) => {
|
||||
const failed = cause.reasons.find(Cause.isFailReason)?.error
|
||||
if (failed instanceof LLMErrorClass) return failed
|
||||
if (failed instanceof LLMError) return failed
|
||||
return ProviderShared.eventError(route, message, Cause.pretty(cause))
|
||||
}
|
||||
|
||||
@@ -256,6 +257,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
provider: routeInput.provider === undefined ? undefined : ProviderID.make(routeInput.provider),
|
||||
providerMetadataKey: routeInput.providerMetadataKey,
|
||||
protocol: protocol.id,
|
||||
operation: protocol.operation,
|
||||
endpoint: routeInput.endpoint,
|
||||
auth: routeInput.auth ?? Auth.none,
|
||||
transport: routeInput.transport,
|
||||
@@ -349,22 +351,24 @@ export function make<Body, Prepared, Frame, Event, State>(
|
||||
// `compile` is the important boundary: it turns a common `LLMRequest` into a
|
||||
// validated provider body plus transport-private prepared data, but does not
|
||||
// execute transport.
|
||||
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
|
||||
const resolved = applyCachePolicy(resolveRequestOptions(request))
|
||||
const route = resolved.model.route
|
||||
const compileResolved = (resolved: LLMRequest) =>
|
||||
Effect.gen(function* () {
|
||||
const route = resolved.model.route
|
||||
|
||||
const body = yield* route.body
|
||||
.from(resolved)
|
||||
.pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema))))
|
||||
const prepared = yield* route.prepareTransport(body, resolved)
|
||||
const body = yield* route.body
|
||||
.from(resolved)
|
||||
.pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema))))
|
||||
const prepared = yield* route.prepareTransport(body, resolved)
|
||||
|
||||
return {
|
||||
request: resolved,
|
||||
route,
|
||||
body,
|
||||
prepared,
|
||||
}
|
||||
})
|
||||
return {
|
||||
request: resolved,
|
||||
route,
|
||||
body,
|
||||
prepared,
|
||||
}
|
||||
})
|
||||
|
||||
const compile = (request: LLMRequest) => compileResolved(applyCachePolicy(resolveRequestOptions(request)))
|
||||
|
||||
const prepareWith = Effect.fn("LLMClient.prepare")(function* (request: LLMRequest) {
|
||||
const compiled = yield* compile(request)
|
||||
@@ -379,13 +383,22 @@ const prepareWith = Effect.fn("LLMClient.prepare")(function* (request: LLMReques
|
||||
})
|
||||
})
|
||||
|
||||
const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const compiled = yield* compile(request)
|
||||
return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime)
|
||||
}),
|
||||
const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) => {
|
||||
return Stream.unwrap(
|
||||
Effect.sync(() => applyCachePolicy(resolveRequestOptions(request))).pipe(
|
||||
Effect.map((resolved) =>
|
||||
LLMTelemetry.stream(
|
||||
resolved,
|
||||
Stream.unwrap(
|
||||
compileResolved(resolved).pipe(
|
||||
Effect.map((compiled) => compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const generateWith = (stream: Interface["stream"]) =>
|
||||
Effect.fn("LLM.generate")(function* (request: LLMRequest) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Schema, type Effect } from "effect"
|
||||
import type { LLMError, LLMEvent, LLMRequest, ProtocolID } from "../schema"
|
||||
|
||||
export type GenAIOperation = "chat" | "generate_content"
|
||||
|
||||
/**
|
||||
* The semantic API contract of one model server family.
|
||||
*
|
||||
@@ -36,6 +38,8 @@ import type { LLMError, LLMEvent, LLMRequest, ProtocolID } from "../schema"
|
||||
export interface Protocol<Body, Frame, Event, State> {
|
||||
/** Stable id for the wire protocol implementation. */
|
||||
readonly id: ProtocolID
|
||||
/** OpenTelemetry GenAI operation represented by one request. */
|
||||
readonly operation: GenAIOperation
|
||||
/** Request side: schema for the provider-native body and how to build it. */
|
||||
readonly body: ProtocolBody<Body>
|
||||
/** Response side: streaming state machine. */
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import { Auth } from "../auth"
|
||||
import { render as renderEndpoint } from "../endpoint"
|
||||
import { Framing, type Framing as FramingDef } from "../framing"
|
||||
import { Endpoint } from "../endpoint"
|
||||
import { Framing } from "../framing"
|
||||
import type { Transport, TransportPrepareInput } from "./index"
|
||||
import * as ProviderShared from "../../protocols/shared"
|
||||
import { ProviderShared } from "../../protocols/shared"
|
||||
import { mergeJsonRecords, type LLMRequest } from "../../schema"
|
||||
import { LLMHttpTelemetry } from "../../telemetry/http"
|
||||
|
||||
export type JsonRequestInput<Body> = TransportPrepareInput<Body>
|
||||
|
||||
@@ -18,7 +19,7 @@ export interface JsonRequestParts<Body = unknown> {
|
||||
|
||||
export interface HttpPrepared<Frame> {
|
||||
readonly request: HttpClientRequest.HttpClientRequest
|
||||
readonly framing: FramingDef<Frame>
|
||||
readonly framing: Framing<Frame>
|
||||
}
|
||||
|
||||
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||
@@ -88,7 +89,7 @@ const bodyWithOverlay = <Body>(body: Body, request: LLMRequest, encodeBody: (bod
|
||||
export const jsonRequestParts = <Body>(input: JsonRequestInput<Body>) =>
|
||||
Effect.gen(function* () {
|
||||
const url = applyQuery(
|
||||
renderEndpoint(input.endpoint, { request: input.request, body: input.body }).toString(),
|
||||
Endpoint.render(input.endpoint, { request: input.request, body: input.body }).toString(),
|
||||
input.request.http?.query,
|
||||
)
|
||||
const body = yield* bodyWithOverlay(input.body, input.request, input.encodeBody)
|
||||
@@ -106,7 +107,7 @@ export const jsonRequestParts = <Body>(input: JsonRequestInput<Body>) =>
|
||||
})
|
||||
|
||||
export interface HttpJsonInput<_Body, Frame> {
|
||||
readonly framing: FramingDef<Frame>
|
||||
readonly framing: Framing<Frame>
|
||||
}
|
||||
|
||||
export type HttpJsonPatch<Body, Frame> = Partial<HttpJsonInput<Body, Frame>>
|
||||
@@ -128,20 +129,30 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
|
||||
})),
|
||||
),
|
||||
frames: (prepared, request, runtime) =>
|
||||
Stream.unwrap(
|
||||
runtime.http
|
||||
.execute(prepared.request)
|
||||
.pipe(
|
||||
Effect.map((response) =>
|
||||
prepared.framing.frame(
|
||||
response.stream.pipe(
|
||||
Stream.mapError((error) =>
|
||||
ProviderShared.eventError(
|
||||
`${request.model.provider}/${request.model.route.id}`,
|
||||
`Failed to read ${request.model.provider}/${request.model.route.id} stream`,
|
||||
ProviderShared.errorText(error),
|
||||
),
|
||||
),
|
||||
LLMHttpTelemetry.stream(
|
||||
prepared.request,
|
||||
(providerRequest) =>
|
||||
Stream.unwrap(
|
||||
runtime.http.execute(providerRequest).pipe(
|
||||
Effect.map((response) =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const received = yield* LLMHttpTelemetry.ResponseReceived
|
||||
if (received) yield* received(response.status)
|
||||
const firstChunk = yield* LLMHttpTelemetry.ResponseChunkReceived
|
||||
return prepared.framing.frame(
|
||||
response.stream.pipe(
|
||||
Stream.tap(() => firstChunk ?? Effect.void),
|
||||
Stream.mapError((error) =>
|
||||
ProviderShared.eventError(
|
||||
`${request.model.provider}/${request.model.route.id}`,
|
||||
`Failed to read ${request.model.provider}/${request.model.route.id} stream`,
|
||||
ProviderShared.errorText(error),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
|
||||
import { Cause, Clock, Context, Effect, Layer, Queue, Stream } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { LLMError, TransportReason } from "../../schema"
|
||||
import * as HttpTransport from "./http"
|
||||
import { LLMWebSocketTelemetry } from "../../telemetry/websocket"
|
||||
import { jsonRequestParts } from "./http"
|
||||
import type { Transport } from "./index"
|
||||
|
||||
export interface WebSocketRequest {
|
||||
@@ -228,7 +229,7 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
|
||||
with: (patch) => json({ ...input, ...patch }),
|
||||
prepare: (prepareInput) =>
|
||||
Effect.gen(function* () {
|
||||
const parts = yield* HttpTransport.jsonRequestParts({
|
||||
const parts = yield* jsonRequestParts({
|
||||
...prepareInput,
|
||||
})
|
||||
return {
|
||||
@@ -239,24 +240,32 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
|
||||
}),
|
||||
frames: (prepared, _request, runtime) => {
|
||||
const webSocket = runtime.webSocket
|
||||
if (!webSocket) {
|
||||
if (!webSocket)
|
||||
return Stream.fail(
|
||||
transportError("json", "WebSocket JSON transport requires WebSocketExecutor.Service", {
|
||||
url: prepared.url,
|
||||
kind: "websocket",
|
||||
}),
|
||||
)
|
||||
}
|
||||
const decoder = new TextDecoder()
|
||||
return Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const connection = yield* Effect.acquireRelease(
|
||||
webSocket.open({ url: prepared.url, headers: prepared.headers }),
|
||||
(connection) => connection.close,
|
||||
)
|
||||
yield* connection.sendText(prepared.message)
|
||||
return connection.messages.pipe(Stream.map((message) => messageText(message, decoder)))
|
||||
}),
|
||||
return LLMWebSocketTelemetry.stream(
|
||||
prepared.url,
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const connection = yield* Effect.acquireRelease(
|
||||
webSocket.open({ url: prepared.url, headers: prepared.headers }),
|
||||
(connection) => connection.close,
|
||||
)
|
||||
const requestIssued = yield* LLMWebSocketTelemetry.RequestIssued
|
||||
if (requestIssued) yield* requestIssued(yield* Clock.currentTimeNanos)
|
||||
yield* connection.sendText(prepared.message)
|
||||
const firstChunk = yield* LLMWebSocketTelemetry.ResponseChunkReceived
|
||||
return connection.messages.pipe(
|
||||
Stream.tap(() => firstChunk ?? Effect.void),
|
||||
Stream.map((message) => messageText(message, decoder)),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
export * as LLMSemconv from "./semconv"
|
||||
|
||||
// Keep this projection synced with the OpenTelemetry semantic conventions.
|
||||
// GenAI conventions are specification-only and do not publish a JavaScript package:
|
||||
// https://github.com/open-telemetry/semantic-conventions-genai/blob/c321d7eb4443ae1d1d88c2e24eda849f62049008/model/gen-ai/registry.yaml
|
||||
|
||||
export const ATTR_ERROR_TYPE = "error.type"
|
||||
export const ATTR_HTTP_REQUEST_METHOD = "http.request.method"
|
||||
export const ATTR_HTTP_RESPONSE_STATUS_CODE = "http.response.status_code"
|
||||
export const ATTR_NETWORK_PROTOCOL_NAME = "network.protocol.name"
|
||||
export const ATTR_NETWORK_TRANSPORT = "network.transport"
|
||||
export const ATTR_SERVER_ADDRESS = "server.address"
|
||||
export const ATTR_SERVER_PORT = "server.port"
|
||||
export const ATTR_URL_FULL = "url.full"
|
||||
export const ATTR_URL_PATH = "url.path"
|
||||
export const ATTR_URL_SCHEME = "url.scheme"
|
||||
|
||||
export const ATTR_GEN_AI_OPERATION_NAME = "gen_ai.operation.name"
|
||||
export const ATTR_GEN_AI_OUTPUT_TYPE = "gen_ai.output.type"
|
||||
export const ATTR_GEN_AI_PROVIDER_NAME = "gen_ai.provider.name"
|
||||
export const ATTR_GEN_AI_REQUEST_FREQUENCY_PENALTY = "gen_ai.request.frequency_penalty"
|
||||
export const ATTR_GEN_AI_REQUEST_MAX_TOKENS = "gen_ai.request.max_tokens"
|
||||
export const ATTR_GEN_AI_REQUEST_MODEL = "gen_ai.request.model"
|
||||
export const ATTR_GEN_AI_REQUEST_PRESENCE_PENALTY = "gen_ai.request.presence_penalty"
|
||||
export const ATTR_GEN_AI_REQUEST_SEED = "gen_ai.request.seed"
|
||||
export const ATTR_GEN_AI_REQUEST_STOP_SEQUENCES = "gen_ai.request.stop_sequences"
|
||||
export const ATTR_GEN_AI_REQUEST_STREAM = "gen_ai.request.stream"
|
||||
export const ATTR_GEN_AI_REQUEST_TEMPERATURE = "gen_ai.request.temperature"
|
||||
export const ATTR_GEN_AI_REQUEST_TOP_K = "gen_ai.request.top_k"
|
||||
export const ATTR_GEN_AI_REQUEST_TOP_P = "gen_ai.request.top_p"
|
||||
export const ATTR_GEN_AI_RESPONSE_FINISH_REASONS = "gen_ai.response.finish_reasons"
|
||||
export const ATTR_GEN_AI_RESPONSE_TIME_TO_FIRST_CHUNK = "gen_ai.response.time_to_first_chunk"
|
||||
export const ATTR_GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS = "gen_ai.usage.cache_creation.input_tokens"
|
||||
export const ATTR_GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS = "gen_ai.usage.cache_read.input_tokens"
|
||||
export const ATTR_GEN_AI_USAGE_INPUT_TOKENS = "gen_ai.usage.input_tokens"
|
||||
export const ATTR_GEN_AI_USAGE_OUTPUT_TOKENS = "gen_ai.usage.output_tokens"
|
||||
export const ATTR_GEN_AI_USAGE_REASONING_OUTPUT_TOKENS = "gen_ai.usage.reasoning.output_tokens"
|
||||
export const ATTR_GEN_AI_CONVERSATION_ID = "gen_ai.conversation.id"
|
||||
export const ATTR_GEN_AI_INPUT_MESSAGES = "gen_ai.input.messages"
|
||||
export const ATTR_GEN_AI_OUTPUT_MESSAGES = "gen_ai.output.messages"
|
||||
export const ATTR_GEN_AI_SYSTEM_INSTRUCTIONS = "gen_ai.system_instructions"
|
||||
|
||||
// OpenCode extensions used alongside the upstream GenAI conventions.
|
||||
export const ATTR_OPENCODE_ERROR_SOURCE = "opencode.error.source"
|
||||
export const ATTR_OPENCODE_ERROR_STAGE = "opencode.error.stage"
|
||||
export const ATTR_OPENCODE_LLM_PROTOCOL = "opencode.llm.protocol"
|
||||
export const ATTR_OPENCODE_LLM_ROUTE = "opencode.llm.route"
|
||||
export const ATTR_OPENCODE_PROVIDER_HTTP_STATUS_CODE = "opencode.provider.http.status_code"
|
||||
export const ATTR_OPENCODE_PROVIDER_REQUEST_ID = "opencode.provider.request.id"
|
||||
export const ATTR_OPENCODE_TRANSPORT_KIND = "opencode.transport.kind"
|
||||
|
||||
export const GEN_AI_OPERATION_NAME_VALUE_CHAT = "chat"
|
||||
export const GEN_AI_OPERATION_NAME_VALUE_GENERATE_CONTENT = "generate_content"
|
||||
export const GEN_AI_OUTPUT_TYPE_VALUE_JSON = "json"
|
||||
export const GEN_AI_OUTPUT_TYPE_VALUE_TEXT = "text"
|
||||
@@ -0,0 +1,306 @@
|
||||
export * as LLMTelemetry from "./telemetry"
|
||||
|
||||
import { Cause, Clock, Effect, Exit, References, Stream } from "effect"
|
||||
import { ParentSpan, type Span } from "effect/Tracer"
|
||||
import {
|
||||
ATTR_ERROR_TYPE,
|
||||
ATTR_GEN_AI_OPERATION_NAME,
|
||||
ATTR_GEN_AI_OUTPUT_TYPE,
|
||||
ATTR_GEN_AI_PROVIDER_NAME,
|
||||
ATTR_GEN_AI_REQUEST_FREQUENCY_PENALTY,
|
||||
ATTR_GEN_AI_REQUEST_MAX_TOKENS,
|
||||
ATTR_GEN_AI_REQUEST_MODEL,
|
||||
ATTR_GEN_AI_REQUEST_PRESENCE_PENALTY,
|
||||
ATTR_GEN_AI_REQUEST_SEED,
|
||||
ATTR_GEN_AI_REQUEST_STOP_SEQUENCES,
|
||||
ATTR_GEN_AI_REQUEST_STREAM,
|
||||
ATTR_GEN_AI_REQUEST_TEMPERATURE,
|
||||
ATTR_GEN_AI_REQUEST_TOP_K,
|
||||
ATTR_GEN_AI_REQUEST_TOP_P,
|
||||
ATTR_GEN_AI_RESPONSE_FINISH_REASONS,
|
||||
ATTR_GEN_AI_RESPONSE_TIME_TO_FIRST_CHUNK,
|
||||
ATTR_GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS,
|
||||
ATTR_GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS,
|
||||
ATTR_GEN_AI_USAGE_INPUT_TOKENS,
|
||||
ATTR_GEN_AI_USAGE_OUTPUT_TOKENS,
|
||||
ATTR_GEN_AI_USAGE_REASONING_OUTPUT_TOKENS,
|
||||
ATTR_OPENCODE_LLM_PROTOCOL,
|
||||
ATTR_OPENCODE_LLM_ROUTE,
|
||||
ATTR_OPENCODE_ERROR_SOURCE,
|
||||
ATTR_OPENCODE_ERROR_STAGE,
|
||||
ATTR_OPENCODE_PROVIDER_HTTP_STATUS_CODE,
|
||||
ATTR_OPENCODE_PROVIDER_REQUEST_ID,
|
||||
ATTR_OPENCODE_TRANSPORT_KIND,
|
||||
ATTR_SERVER_ADDRESS,
|
||||
ATTR_SERVER_PORT,
|
||||
GEN_AI_OUTPUT_TYPE_VALUE_JSON,
|
||||
GEN_AI_OUTPUT_TYPE_VALUE_TEXT,
|
||||
} from "./semconv"
|
||||
import { LLMError, LLMEvent, type LLMRequest, type Usage } from "./schema"
|
||||
import { RequestIssued, ResponseChunkReceived } from "./telemetry/http"
|
||||
|
||||
export { RequestIssued, ResponseChunkReceived } from "./telemetry/http"
|
||||
|
||||
type TelemetryState = {
|
||||
requestIssued?: bigint
|
||||
firstChunkReceived?: bigint
|
||||
usage?: Usage
|
||||
terminal?: Exit.Exit<void, Error>
|
||||
}
|
||||
|
||||
const observe = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
Effect.catchCauseIf(
|
||||
effect,
|
||||
(cause) => !Cause.hasInterrupts(cause),
|
||||
() => Effect.void,
|
||||
)
|
||||
|
||||
export function stream(request: LLMRequest, source: Stream.Stream<LLMEvent, LLMError>) {
|
||||
const generation = request.generation
|
||||
const operation = request.model.route.operation
|
||||
const baseURL = request.model.route.endpoint.baseURL
|
||||
const url = baseURL && URL.canParse(baseURL) ? new URL(baseURL) : undefined
|
||||
const server = serverAttributes(url)
|
||||
const outputType =
|
||||
request.responseFormat?.type === "text"
|
||||
? GEN_AI_OUTPUT_TYPE_VALUE_TEXT
|
||||
: request.responseFormat?.type === "json"
|
||||
? GEN_AI_OUTPUT_TYPE_VALUE_JSON
|
||||
: undefined
|
||||
const attributes = {
|
||||
[ATTR_GEN_AI_OPERATION_NAME]: operation,
|
||||
[ATTR_GEN_AI_PROVIDER_NAME]: request.model.provider,
|
||||
[ATTR_GEN_AI_REQUEST_MODEL]: request.model.id,
|
||||
[ATTR_GEN_AI_REQUEST_STREAM]: true,
|
||||
[ATTR_OPENCODE_LLM_ROUTE]: request.model.route.id,
|
||||
[ATTR_OPENCODE_LLM_PROTOCOL]: request.model.route.protocol,
|
||||
...server,
|
||||
...(generation?.maxTokens === undefined ? {} : { [ATTR_GEN_AI_REQUEST_MAX_TOKENS]: generation.maxTokens }),
|
||||
...(generation?.temperature === undefined ? {} : { [ATTR_GEN_AI_REQUEST_TEMPERATURE]: generation.temperature }),
|
||||
...(generation?.topK === undefined || !Number.isInteger(generation.topK)
|
||||
? {}
|
||||
: { [ATTR_GEN_AI_REQUEST_TOP_K]: generation.topK }),
|
||||
...(generation?.topP === undefined ? {} : { [ATTR_GEN_AI_REQUEST_TOP_P]: generation.topP }),
|
||||
...(generation?.frequencyPenalty === undefined
|
||||
? {}
|
||||
: { [ATTR_GEN_AI_REQUEST_FREQUENCY_PENALTY]: generation.frequencyPenalty }),
|
||||
...(generation?.presencePenalty === undefined
|
||||
? {}
|
||||
: { [ATTR_GEN_AI_REQUEST_PRESENCE_PENALTY]: generation.presencePenalty }),
|
||||
...(generation?.seed === undefined ? {} : { [ATTR_GEN_AI_REQUEST_SEED]: generation.seed }),
|
||||
...(generation?.stop === undefined ? {} : { [ATTR_GEN_AI_REQUEST_STOP_SEQUENCES]: generation.stop }),
|
||||
...(outputType === undefined ? {} : { [ATTR_GEN_AI_OUTPUT_TYPE]: outputType }),
|
||||
}
|
||||
return Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
if (!(yield* References.TracerEnabled)) return source
|
||||
const span = yield* Effect.makeSpan(`${operation} ${request.model.id}`, {
|
||||
kind: "client",
|
||||
attributes,
|
||||
})
|
||||
const state: TelemetryState = {}
|
||||
yield* Effect.addFinalizer((exit) => observe(finalize(span, state, exit)))
|
||||
return observeStream({
|
||||
span,
|
||||
state,
|
||||
stream: source,
|
||||
stopSequences: generation?.stop,
|
||||
})
|
||||
}).pipe(
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterrupts(cause),
|
||||
() => Effect.succeed(source),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function observeStream(input: {
|
||||
readonly span: Span
|
||||
readonly state: TelemetryState
|
||||
readonly stream: Stream.Stream<LLMEvent, LLMError>
|
||||
readonly stopSequences?: ReadonlyArray<string>
|
||||
}) {
|
||||
const terminate = (exit: Exit.Exit<void, Error>, errorType?: string) => {
|
||||
if (input.state.terminal) return false
|
||||
input.state.terminal = exit
|
||||
if (errorType) input.span.attribute(ATTR_ERROR_TYPE, errorType)
|
||||
return true
|
||||
}
|
||||
return input.stream.pipe(
|
||||
Stream.onStart(
|
||||
observe(
|
||||
Effect.sync(() => {
|
||||
if (input.stopSequences !== undefined)
|
||||
input.span.attribute(ATTR_GEN_AI_REQUEST_STOP_SEQUENCES, [...input.stopSequences])
|
||||
}),
|
||||
),
|
||||
),
|
||||
Stream.tap((event) =>
|
||||
observe(
|
||||
Effect.sync(() => {
|
||||
if (input.state.terminal) return
|
||||
if ("usage" in event && event.usage !== undefined) input.state.usage = event.usage
|
||||
if (LLMEvent.is.finish(event)) {
|
||||
const type = event.reason === "error" ? "provider_error" : undefined
|
||||
if (!terminate(type ? Exit.fail(new Error(type)) : Exit.void, type)) return
|
||||
const attributes = finishAttributes(event)
|
||||
for (const [key, value] of Object.entries(attributes)) input.span.attribute(key, value)
|
||||
if (type) {
|
||||
input.span.attribute(ATTR_OPENCODE_ERROR_SOURCE, "provider")
|
||||
input.span.attribute(ATTR_OPENCODE_ERROR_STAGE, "response")
|
||||
}
|
||||
}
|
||||
if (LLMEvent.is.providerError(event)) {
|
||||
const type = errorType(event)
|
||||
input.span.attribute(ATTR_OPENCODE_ERROR_SOURCE, "provider")
|
||||
input.span.attribute(ATTR_OPENCODE_ERROR_STAGE, "response")
|
||||
terminate(Exit.fail(new Error(type)), type)
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
Stream.tapCause((cause) =>
|
||||
observe(
|
||||
Effect.gen(function* () {
|
||||
const type = Cause.hasInterruptsOnly(cause) ? "canceled" : causeErrorType(cause)
|
||||
const error = Cause.squash(cause)
|
||||
if (error instanceof LLMError) {
|
||||
for (const [key, value] of Object.entries(llmErrorAttributes(error))) input.span.attribute(key, value)
|
||||
}
|
||||
terminate(Exit.fail(new Error(type)), type)
|
||||
}),
|
||||
),
|
||||
),
|
||||
Stream.provideService(ParentSpan, input.span),
|
||||
Stream.provideService(RequestIssued, (time) =>
|
||||
observe(
|
||||
Effect.sync(() => {
|
||||
input.state.requestIssued ??= time
|
||||
recordFirstChunk(input.span, input.state)
|
||||
}),
|
||||
),
|
||||
),
|
||||
Stream.provideService(
|
||||
ResponseChunkReceived,
|
||||
observe(
|
||||
Effect.gen(function* () {
|
||||
input.state.firstChunkReceived ??= yield* Clock.currentTimeNanos
|
||||
recordFirstChunk(input.span, input.state)
|
||||
}),
|
||||
),
|
||||
),
|
||||
Stream.provideService(References.TracerEnabled, false),
|
||||
)
|
||||
}
|
||||
|
||||
function finalize(span: Span, state: TelemetryState, scopeExit: Exit.Exit<unknown, unknown>) {
|
||||
return Effect.gen(function* () {
|
||||
if (!state.terminal) {
|
||||
const type = Exit.isFailure(scopeExit) && Cause.hasInterruptsOnly(scopeExit.cause)
|
||||
? "canceled"
|
||||
: "incomplete_response"
|
||||
span.attribute(ATTR_ERROR_TYPE, type)
|
||||
state.terminal = Exit.fail(new Error(type))
|
||||
}
|
||||
if (state.usage) {
|
||||
for (const [key, value] of Object.entries(usageAttributes(state.usage))) span.attribute(key, value)
|
||||
}
|
||||
span.end(yield* Clock.currentTimeNanos, state.terminal)
|
||||
})
|
||||
}
|
||||
|
||||
function recordFirstChunk(span: Span, state: TelemetryState) {
|
||||
if (state.requestIssued === undefined || state.firstChunkReceived === undefined) return
|
||||
span.attribute(ATTR_GEN_AI_RESPONSE_TIME_TO_FIRST_CHUNK, Number(state.firstChunkReceived - state.requestIssued) / 1e9)
|
||||
}
|
||||
|
||||
function finishAttributes(event: Extract<LLMEvent, { readonly type: "finish" }>) {
|
||||
return {
|
||||
[ATTR_GEN_AI_RESPONSE_FINISH_REASONS]: [finishReason(event.reason)],
|
||||
}
|
||||
}
|
||||
|
||||
function finishReason(reason: Extract<LLMEvent, { readonly type: "finish" }>["reason"]) {
|
||||
if (reason === "tool-calls") return "tool_calls"
|
||||
if (reason === "content-filter") return "content_filter"
|
||||
return reason
|
||||
}
|
||||
|
||||
function errorType(event: Extract<LLMEvent, { readonly type: "provider-error" }>) {
|
||||
if (event.classification) return event.classification
|
||||
return "provider_error"
|
||||
}
|
||||
|
||||
function llmErrorAttributes(error: LLMError) {
|
||||
const reason = error.reason
|
||||
const http = "http" in reason ? reason.http : undefined
|
||||
const status = http?.response?.status
|
||||
const providerStream = error.method === "stream"
|
||||
const source =
|
||||
reason._tag === "Transport"
|
||||
? "transport"
|
||||
: reason._tag === "InvalidProviderOutput"
|
||||
? "protocol"
|
||||
: reason._tag === "NoRoute"
|
||||
? "configuration"
|
||||
: providerStream
|
||||
? "provider"
|
||||
: reason._tag === "InvalidRequest" && http === undefined
|
||||
? "request"
|
||||
: reason._tag === "Authentication" && http === undefined
|
||||
? "configuration"
|
||||
: "provider"
|
||||
const stage =
|
||||
reason._tag === "Transport"
|
||||
? "transport"
|
||||
: reason._tag === "InvalidProviderOutput"
|
||||
? "parse"
|
||||
: reason._tag === "NoRoute"
|
||||
? "resolve"
|
||||
: providerStream
|
||||
? "response"
|
||||
: http === undefined && (reason._tag === "InvalidRequest" || reason._tag === "Authentication")
|
||||
? "compile"
|
||||
: "response"
|
||||
return {
|
||||
[ATTR_OPENCODE_ERROR_SOURCE]: source,
|
||||
[ATTR_OPENCODE_ERROR_STAGE]: stage,
|
||||
...(status === undefined ? {} : { [ATTR_OPENCODE_PROVIDER_HTTP_STATUS_CODE]: status }),
|
||||
...(http?.requestId === undefined ? {} : { [ATTR_OPENCODE_PROVIDER_REQUEST_ID]: http.requestId }),
|
||||
...(reason._tag === "Transport" && reason.kind ? { [ATTR_OPENCODE_TRANSPORT_KIND]: reason.kind } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function causeErrorType(cause: Cause.Cause<unknown>) {
|
||||
const error = Cause.squash(cause)
|
||||
if (error instanceof LLMError) return error.reason._tag
|
||||
if (error instanceof Error) return error.name
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
function usageAttributes(usage: Usage | undefined) {
|
||||
if (!usage) return {}
|
||||
return {
|
||||
...(usage.inputTokens === undefined ? {} : { [ATTR_GEN_AI_USAGE_INPUT_TOKENS]: usage.inputTokens }),
|
||||
...(usage.outputTokens === undefined ? {} : { [ATTR_GEN_AI_USAGE_OUTPUT_TOKENS]: usage.outputTokens }),
|
||||
...(usage.cacheReadInputTokens === undefined
|
||||
? {}
|
||||
: { [ATTR_GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS]: usage.cacheReadInputTokens }),
|
||||
...(usage.cacheWriteInputTokens === undefined
|
||||
? {}
|
||||
: { [ATTR_GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS]: usage.cacheWriteInputTokens }),
|
||||
...(usage.reasoningTokens === undefined
|
||||
? {}
|
||||
: { [ATTR_GEN_AI_USAGE_REASONING_OUTPUT_TOKENS]: usage.reasoningTokens }),
|
||||
}
|
||||
}
|
||||
|
||||
function serverAttributes(url: URL | undefined) {
|
||||
if (!url) return {}
|
||||
const port = url.port ? Number(url.port) : url.protocol === "https:" ? 443 : url.protocol === "http:" ? 80 : undefined
|
||||
return {
|
||||
[ATTR_SERVER_ADDRESS]: url.hostname,
|
||||
...(port === undefined ? {} : { [ATTR_SERVER_PORT]: port }),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Effect, Option } from "effect"
|
||||
import type { Span } from "effect/Tracer"
|
||||
import { ATTR_OPENCODE_LLM_ROUTE } from "../semconv"
|
||||
|
||||
export const currentModelSpan = Effect.option(Effect.currentSpan).pipe(
|
||||
Effect.map(Option.getOrUndefined),
|
||||
Effect.map(findModelSpan),
|
||||
)
|
||||
|
||||
function findModelSpan(span: Span | undefined): Span | undefined {
|
||||
if (!span) return
|
||||
if (span.attributes.has(ATTR_OPENCODE_LLM_ROUTE)) return span
|
||||
const parent = Option.getOrUndefined(span.parent)
|
||||
return findModelSpan(parent?._tag === "Span" ? parent : undefined)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
export * as LLMHttpTelemetry from "./http"
|
||||
|
||||
import { Cause, Clock, Context, Effect, Exit, Option, References, Stream } from "effect"
|
||||
import { ParentSpan, type Span } from "effect/Tracer"
|
||||
import { HttpClient, HttpClientRequest, HttpTraceContext } from "effect/unstable/http"
|
||||
import {
|
||||
ATTR_ERROR_TYPE,
|
||||
ATTR_HTTP_REQUEST_METHOD,
|
||||
ATTR_HTTP_RESPONSE_STATUS_CODE,
|
||||
ATTR_OPENCODE_ERROR_SOURCE,
|
||||
ATTR_OPENCODE_ERROR_STAGE,
|
||||
ATTR_SERVER_ADDRESS,
|
||||
ATTR_SERVER_PORT,
|
||||
ATTR_URL_FULL,
|
||||
ATTR_URL_PATH,
|
||||
ATTR_URL_SCHEME,
|
||||
} from "../semconv"
|
||||
import { LLMError } from "../schema"
|
||||
import { currentModelSpan } from "./context"
|
||||
|
||||
export const RequestIssued = Context.Reference<((time: bigint) => Effect.Effect<void>) | undefined>(
|
||||
"@opencode/LLM/Telemetry/RequestIssued",
|
||||
{ defaultValue: () => undefined },
|
||||
)
|
||||
|
||||
export const ResponseReceived = Context.Reference<((status: number) => Effect.Effect<void>) | undefined>(
|
||||
"@opencode/LLM/Telemetry/ResponseReceived",
|
||||
{ defaultValue: () => undefined },
|
||||
)
|
||||
|
||||
export const ResponseChunkReceived = Context.Reference<Effect.Effect<void> | undefined>(
|
||||
"@opencode/LLM/Telemetry/ResponseChunkReceived",
|
||||
{ defaultValue: () => undefined },
|
||||
)
|
||||
|
||||
const observe = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
Effect.catchCauseIf(
|
||||
effect,
|
||||
(cause) => !Cause.hasInterrupts(cause),
|
||||
() => Effect.void,
|
||||
)
|
||||
|
||||
export const stream = <A, R>(
|
||||
request: HttpClientRequest.HttpClientRequest,
|
||||
source: (request: HttpClientRequest.HttpClientRequest) => Stream.Stream<A, LLMError, R>,
|
||||
) =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const parent = yield* currentModelSpan
|
||||
if (!parent) return source(request)
|
||||
const url = URL.canParse(request.url) ? new URL(request.url) : undefined
|
||||
const port = url?.port
|
||||
? Number(url.port)
|
||||
: url?.protocol === "https:"
|
||||
? 443
|
||||
: url?.protocol === "http:"
|
||||
? 80
|
||||
: undefined
|
||||
const span = yield* Effect.makeSpan(request.method, {
|
||||
kind: "client",
|
||||
parent,
|
||||
attributes: {
|
||||
[ATTR_HTTP_REQUEST_METHOD]: request.method,
|
||||
...(url
|
||||
? {
|
||||
[ATTR_SERVER_ADDRESS]: url.hostname,
|
||||
...(port === undefined ? {} : { [ATTR_SERVER_PORT]: port }),
|
||||
[ATTR_URL_FULL]: safeUrl(url),
|
||||
[ATTR_URL_PATH]: url.pathname,
|
||||
[ATTR_URL_SCHEME]: url.protocol.slice(0, -1),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
const state: State = { responseReceived: false }
|
||||
yield* Effect.addFinalizer((exit) => observe(finalize(span, state, exit)))
|
||||
return observeStream(
|
||||
span,
|
||||
state,
|
||||
source(HttpClientRequest.setHeaders(request, HttpTraceContext.toHeaders(span))),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.withTracerEnabled(true),
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterrupts(cause),
|
||||
() => Effect.succeed(source(request)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
type State = {
|
||||
responseReceived: boolean
|
||||
terminal?: Exit.Exit<void, unknown>
|
||||
}
|
||||
|
||||
function observeStream<A, R>(span: Span, state: State, source: Stream.Stream<A, LLMError, R>) {
|
||||
const terminate = (exit: Exit.Exit<void, unknown>, type?: string) => {
|
||||
if (state.terminal) return
|
||||
state.terminal = exit
|
||||
if (type) span.attribute(ATTR_ERROR_TYPE, type)
|
||||
}
|
||||
return source.pipe(
|
||||
Stream.onStart(
|
||||
observe(
|
||||
Effect.gen(function* () {
|
||||
const requestIssued = yield* RequestIssued
|
||||
if (requestIssued) yield* requestIssued(yield* Clock.currentTimeNanos)
|
||||
}),
|
||||
),
|
||||
),
|
||||
Stream.tapCause((cause) =>
|
||||
observe(
|
||||
Effect.gen(function* () {
|
||||
const error = Option.getOrUndefined(Cause.findErrorOption(cause))
|
||||
if (Cause.hasInterruptsOnly(cause)) {
|
||||
terminate(Exit.failCause(cause))
|
||||
return
|
||||
}
|
||||
const type = error?.reason._tag ?? "unknown"
|
||||
const status = error && "http" in error.reason ? error.reason.http?.response?.status : undefined
|
||||
if (status !== undefined) span.attribute(ATTR_HTTP_RESPONSE_STATUS_CODE, status)
|
||||
span.attribute(
|
||||
ATTR_OPENCODE_ERROR_SOURCE,
|
||||
error?.reason._tag === "Transport"
|
||||
? "transport"
|
||||
: error?.reason._tag === "InvalidProviderOutput"
|
||||
? "protocol"
|
||||
: "provider",
|
||||
)
|
||||
span.attribute(
|
||||
ATTR_OPENCODE_ERROR_STAGE,
|
||||
status !== undefined ? "response" : state.responseReceived ? "response_stream" : "request",
|
||||
)
|
||||
terminate(Exit.fail(new Error(type)), type)
|
||||
}),
|
||||
),
|
||||
),
|
||||
Stream.provideService(ParentSpan, span),
|
||||
Stream.provideService(ResponseReceived, (status) =>
|
||||
observe(
|
||||
Effect.sync(() => {
|
||||
state.responseReceived = true
|
||||
span.attribute(ATTR_HTTP_RESPONSE_STATUS_CODE, status)
|
||||
}),
|
||||
),
|
||||
),
|
||||
Stream.provideService(HttpClient.TracerDisabledWhen, () => true),
|
||||
Stream.provideService(HttpClient.TracerPropagationEnabled, false),
|
||||
Stream.provideService(References.TracerEnabled, false),
|
||||
)
|
||||
}
|
||||
|
||||
function finalize(span: Span, state: State, scopeExit: Exit.Exit<unknown, unknown>) {
|
||||
return Effect.gen(function* () {
|
||||
if (!state.terminal) {
|
||||
state.terminal = Exit.isFailure(scopeExit) && Cause.hasInterruptsOnly(scopeExit.cause)
|
||||
? Exit.failCause(scopeExit.cause)
|
||||
: Exit.void
|
||||
}
|
||||
span.end(yield* Clock.currentTimeNanos, state.terminal)
|
||||
})
|
||||
}
|
||||
|
||||
export function safeUrl(url: URL) {
|
||||
const safe = new URL(url)
|
||||
safe.username = ""
|
||||
safe.password = ""
|
||||
safe.search = ""
|
||||
safe.hash = ""
|
||||
return safe.toString()
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
export * as LLMWebSocketTelemetry from "./websocket"
|
||||
|
||||
import { Cause, Clock, Effect, Exit, Option, References, Stream } from "effect"
|
||||
import { ParentSpan, type Span } from "effect/Tracer"
|
||||
import {
|
||||
ATTR_ERROR_TYPE,
|
||||
ATTR_NETWORK_PROTOCOL_NAME,
|
||||
ATTR_NETWORK_TRANSPORT,
|
||||
ATTR_OPENCODE_ERROR_SOURCE,
|
||||
ATTR_OPENCODE_ERROR_STAGE,
|
||||
ATTR_SERVER_ADDRESS,
|
||||
ATTR_SERVER_PORT,
|
||||
ATTR_URL_FULL,
|
||||
ATTR_URL_PATH,
|
||||
ATTR_URL_SCHEME,
|
||||
} from "../semconv"
|
||||
import { LLMError } from "../schema"
|
||||
import { safeUrl } from "./http"
|
||||
import { currentModelSpan } from "./context"
|
||||
|
||||
export { RequestIssued, ResponseChunkReceived } from "./http"
|
||||
|
||||
const observe = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
Effect.catchCauseIf(
|
||||
effect,
|
||||
(cause) => !Cause.hasInterrupts(cause),
|
||||
() => Effect.void,
|
||||
)
|
||||
|
||||
export const stream = <A, R>(urlValue: string, source: Stream.Stream<A, LLMError, R>) =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const parent = yield* currentModelSpan
|
||||
if (!parent) return source
|
||||
const url = URL.canParse(urlValue) ? new URL(urlValue) : undefined
|
||||
const port = url?.port
|
||||
? Number(url.port)
|
||||
: url?.protocol === "wss:"
|
||||
? 443
|
||||
: url?.protocol === "ws:"
|
||||
? 80
|
||||
: undefined
|
||||
const span = yield* Effect.makeSpan("websocket.exchange", {
|
||||
kind: "client",
|
||||
parent,
|
||||
attributes: {
|
||||
[ATTR_NETWORK_PROTOCOL_NAME]: "websocket",
|
||||
[ATTR_NETWORK_TRANSPORT]: "tcp",
|
||||
...(url
|
||||
? {
|
||||
[ATTR_SERVER_ADDRESS]: url.hostname,
|
||||
...(port === undefined ? {} : { [ATTR_SERVER_PORT]: port }),
|
||||
[ATTR_URL_FULL]: safeUrl(url),
|
||||
[ATTR_URL_PATH]: url.pathname,
|
||||
[ATTR_URL_SCHEME]: url.protocol.slice(0, -1),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
const state: State = {}
|
||||
yield* Effect.addFinalizer((exit) => observe(finalize(span, state, exit)))
|
||||
return observeStream(span, state, source)
|
||||
}).pipe(
|
||||
Effect.withTracerEnabled(true),
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterrupts(cause),
|
||||
() => Effect.succeed(source),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
type State = { terminal?: Exit.Exit<void, unknown> }
|
||||
|
||||
function observeStream<A, R>(span: Span, state: State, source: Stream.Stream<A, LLMError, R>) {
|
||||
const terminate = (exit: Exit.Exit<void, unknown>, type?: string) => {
|
||||
if (state.terminal) return
|
||||
state.terminal = exit
|
||||
if (type) span.attribute(ATTR_ERROR_TYPE, type)
|
||||
}
|
||||
return source.pipe(
|
||||
Stream.tapCause((cause) =>
|
||||
observe(
|
||||
Effect.gen(function* () {
|
||||
const error = Option.getOrUndefined(Cause.findErrorOption(cause))
|
||||
if (Cause.hasInterruptsOnly(cause)) {
|
||||
terminate(Exit.failCause(cause))
|
||||
return
|
||||
}
|
||||
const type = error?.reason._tag ?? "unknown"
|
||||
span.attribute(
|
||||
ATTR_OPENCODE_ERROR_SOURCE,
|
||||
error?.reason._tag === "Transport"
|
||||
? "transport"
|
||||
: error?.reason._tag === "InvalidProviderOutput"
|
||||
? "protocol"
|
||||
: "provider",
|
||||
)
|
||||
span.attribute(ATTR_OPENCODE_ERROR_STAGE, "websocket")
|
||||
terminate(Exit.fail(new Error(type)), type)
|
||||
}),
|
||||
),
|
||||
),
|
||||
Stream.provideService(ParentSpan, span),
|
||||
Stream.provideService(References.TracerEnabled, false),
|
||||
)
|
||||
}
|
||||
|
||||
function finalize(span: Span, state: State, scopeExit: Exit.Exit<unknown, unknown>) {
|
||||
return Effect.gen(function* () {
|
||||
if (!state.terminal) {
|
||||
state.terminal = Exit.isFailure(scopeExit) && Cause.hasInterruptsOnly(scopeExit.cause)
|
||||
? Exit.failCause(scopeExit.cause)
|
||||
: Exit.void
|
||||
}
|
||||
span.end(yield* Clock.currentTimeNanos, state.terminal)
|
||||
})
|
||||
}
|
||||
@@ -45,6 +45,7 @@ const raiseEvent = (event: FakeEvent): import("../src/schema").LLMEvent =>
|
||||
|
||||
const fakeProtocol = Protocol.make<FakeBody, FakeEvent, FakeEvent, void>({
|
||||
id: "fake",
|
||||
operation: "chat",
|
||||
body: {
|
||||
schema: Schema.Struct({
|
||||
body: Schema.String,
|
||||
|
||||
@@ -107,6 +107,29 @@ describe("RequestExecutor", () => {
|
||||
}).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))),
|
||||
)
|
||||
|
||||
it.effect("retains known HTTP status when an error response body cannot be read", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectLLMError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status: 500, http: { response: { status: 500 } } })
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.error(new Error("provider body secret"))
|
||||
},
|
||||
}),
|
||||
{ status: 500 },
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("returns redacted diagnostics for rate limits", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
|
||||
@@ -492,8 +492,6 @@ describe("Anthropic Messages route", () => {
|
||||
),
|
||||
)
|
||||
|
||||
// Prefix the error type so consumers can distinguish overloads, rate
|
||||
// limits, and quota errors without parsing the message string.
|
||||
expect(response.events).toEqual([{ type: "provider-error", message: "overloaded_error: Overloaded" }])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { ConfigProvider, Effect, Layer, Stream } from "effect"
|
||||
import { ConfigProvider, Effect, Layer, Stream, Tracer } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, LLMError, Message, Model, ToolCallPart, Usage } from "../../src"
|
||||
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
|
||||
@@ -11,6 +11,7 @@ import { continuationRequest, nativeOpenAIResponsesContinuation } from "../conti
|
||||
import { it } from "../lib/effect"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http"
|
||||
import { sseEvents } from "../lib/sse"
|
||||
import { ATTR_ERROR_TYPE, ATTR_NETWORK_PROTOCOL_NAME, ATTR_NETWORK_TRANSPORT, ATTR_URL_FULL } from "../../src/semconv"
|
||||
|
||||
const model = OpenAIResponses.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
||||
@@ -188,7 +189,19 @@ describe("OpenAI Responses route", () => {
|
||||
it.effect("streams OpenAI Responses over WebSocket", () =>
|
||||
Effect.gen(function* () {
|
||||
const sent: string[] = []
|
||||
const opened: Array<{ readonly url: string; readonly authorization: string | undefined }> = []
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
const opened: Array<{
|
||||
readonly url: string
|
||||
readonly authorization: string | undefined
|
||||
readonly traceparent: string | undefined
|
||||
}> = []
|
||||
let closed = false
|
||||
const deps = Layer.mergeAll(
|
||||
Layer.succeed(
|
||||
@@ -204,7 +217,11 @@ describe("OpenAI Responses route", () => {
|
||||
Effect.succeed({
|
||||
sendText: (message) =>
|
||||
Effect.sync(() => {
|
||||
opened.push({ url: input.url, authorization: input.headers.authorization })
|
||||
opened.push({
|
||||
url: input.url,
|
||||
authorization: input.headers.authorization,
|
||||
traceparent: input.headers.traceparent,
|
||||
})
|
||||
sent.push(message)
|
||||
}),
|
||||
messages: Stream.fromArray([
|
||||
@@ -225,10 +242,12 @@ describe("OpenAI Responses route", () => {
|
||||
),
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))))
|
||||
).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))), Effect.provideService(Tracer.Tracer, tracer))
|
||||
|
||||
expect(response.text).toBe("Hi")
|
||||
expect(opened).toEqual([{ url: "wss://api.openai.test/v1/responses", authorization: "Bearer test" }])
|
||||
expect(opened).toEqual([
|
||||
{ url: "wss://api.openai.test/v1/responses", authorization: "Bearer test", traceparent: undefined },
|
||||
])
|
||||
expect(closed).toBe(true)
|
||||
expect(sent).toHaveLength(1)
|
||||
expect(JSON.parse(sent[0])).toEqual({
|
||||
@@ -237,6 +256,16 @@ describe("OpenAI Responses route", () => {
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "Say hello." }] }],
|
||||
store: false,
|
||||
})
|
||||
const websocket = spans.find((span) => span.name === "websocket.exchange")
|
||||
expect(websocket?.attributes.get(ATTR_NETWORK_PROTOCOL_NAME)).toBe("websocket")
|
||||
expect(websocket?.attributes.get(ATTR_NETWORK_TRANSPORT)).toBe("tcp")
|
||||
expect(websocket?.attributes.get(ATTR_URL_FULL)).toBe("wss://api.openai.test/v1/responses")
|
||||
expect(websocket?.attributes.has(ATTR_ERROR_TYPE)).toBeFalse()
|
||||
expect(
|
||||
websocket?.parent._tag === "Some" && websocket.parent.value._tag === "Span"
|
||||
? websocket.parent.value.name
|
||||
: undefined,
|
||||
).toBe("chat gpt-4.1-mini")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1374,10 +1403,6 @@ describe("OpenAI Responses route", () => {
|
||||
Effect.provide(fixedResponse(sseEvents({ type: "error", code: "rate_limit_exceeded", message: "Slow down" }))),
|
||||
)
|
||||
|
||||
// Prefix the code so consumers see the failure mode, not just the
|
||||
// sometimes-generic provider message. The bare message alone meant
|
||||
// production errors like rate limits were indistinguishable from
|
||||
// unrelated stream failures.
|
||||
expect(response.events).toEqual([{ type: "provider-error", message: "rate_limit_exceeded: Slow down" }])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,713 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Cause, Clock, Deferred, Effect, Fiber, References, Stream, Tracer } from "effect"
|
||||
import * as TestClock from "effect/testing/TestClock"
|
||||
import { FetchHttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, LLMEvent, Message, Usage } from "../src"
|
||||
import * as Gemini from "../src/protocols/gemini"
|
||||
import * as OpenAIChat from "../src/protocols/openai-chat"
|
||||
import * as OpenAIResponses from "../src/protocols/openai-responses"
|
||||
import { LLMClient } from "../src/route"
|
||||
import { it } from "./lib/effect"
|
||||
import { dynamicResponse, fixedResponse, runtimeLayer } from "./lib/http"
|
||||
import { deltaChunk, usageChunk } from "./lib/openai-chunks"
|
||||
import { sseEvents } from "./lib/sse"
|
||||
import {
|
||||
ATTR_ERROR_TYPE,
|
||||
ATTR_GEN_AI_CONVERSATION_ID,
|
||||
ATTR_GEN_AI_INPUT_MESSAGES,
|
||||
ATTR_GEN_AI_OPERATION_NAME,
|
||||
ATTR_GEN_AI_OUTPUT_MESSAGES,
|
||||
ATTR_GEN_AI_PROVIDER_NAME,
|
||||
ATTR_GEN_AI_REQUEST_MAX_TOKENS,
|
||||
ATTR_GEN_AI_REQUEST_MODEL,
|
||||
ATTR_GEN_AI_REQUEST_STREAM,
|
||||
ATTR_GEN_AI_REQUEST_TEMPERATURE,
|
||||
ATTR_GEN_AI_REQUEST_TOP_K,
|
||||
ATTR_GEN_AI_REQUEST_TOP_P,
|
||||
ATTR_GEN_AI_RESPONSE_FINISH_REASONS,
|
||||
ATTR_GEN_AI_RESPONSE_TIME_TO_FIRST_CHUNK,
|
||||
ATTR_GEN_AI_SYSTEM_INSTRUCTIONS,
|
||||
ATTR_GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS,
|
||||
ATTR_GEN_AI_USAGE_INPUT_TOKENS,
|
||||
ATTR_GEN_AI_USAGE_OUTPUT_TOKENS,
|
||||
ATTR_GEN_AI_USAGE_REASONING_OUTPUT_TOKENS,
|
||||
ATTR_HTTP_REQUEST_METHOD,
|
||||
ATTR_HTTP_RESPONSE_STATUS_CODE,
|
||||
ATTR_OPENCODE_ERROR_SOURCE,
|
||||
ATTR_OPENCODE_ERROR_STAGE,
|
||||
ATTR_OPENCODE_LLM_PROTOCOL,
|
||||
ATTR_OPENCODE_LLM_ROUTE,
|
||||
ATTR_OPENCODE_PROVIDER_HTTP_STATUS_CODE,
|
||||
ATTR_SERVER_ADDRESS,
|
||||
ATTR_SERVER_PORT,
|
||||
ATTR_URL_FULL,
|
||||
GEN_AI_OPERATION_NAME_VALUE_CHAT,
|
||||
} from "../src/semconv"
|
||||
import { RequestIssued, ResponseChunkReceived, stream as instrument } from "../src/telemetry"
|
||||
import { LLMHttpTelemetry } from "../src/telemetry/http"
|
||||
import { LLMWebSocketTelemetry } from "../src/telemetry/websocket"
|
||||
|
||||
const ATTR_AGENT_STEP_INDEX = "test.agent.step.index"
|
||||
const ATTR_AGENT_STEP_TRIGGER = "test.agent.step.trigger"
|
||||
|
||||
describe("GenAI telemetry", () => {
|
||||
it.effect("tracks the OpenTelemetry GenAI registry projection", () =>
|
||||
Effect.sync(() => {
|
||||
expect({
|
||||
ATTR_GEN_AI_OPERATION_NAME,
|
||||
ATTR_GEN_AI_PROVIDER_NAME,
|
||||
ATTR_GEN_AI_REQUEST_STREAM,
|
||||
ATTR_GEN_AI_RESPONSE_TIME_TO_FIRST_CHUNK,
|
||||
ATTR_GEN_AI_USAGE_REASONING_OUTPUT_TOKENS,
|
||||
GEN_AI_OPERATION_NAME_VALUE_CHAT,
|
||||
}).toMatchObject({
|
||||
ATTR_GEN_AI_OPERATION_NAME: "gen_ai.operation.name",
|
||||
ATTR_GEN_AI_PROVIDER_NAME: "gen_ai.provider.name",
|
||||
ATTR_GEN_AI_REQUEST_STREAM: "gen_ai.request.stream",
|
||||
ATTR_GEN_AI_RESPONSE_TIME_TO_FIRST_CHUNK: "gen_ai.response.time_to_first_chunk",
|
||||
ATTR_GEN_AI_USAGE_REASONING_OUTPUT_TOKENS: "gen_ai.usage.reasoning.output_tokens",
|
||||
GEN_AI_OPERATION_NAME_VALUE_CHAT: "chat",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("records safe semantic convention attributes for a streaming model call", () =>
|
||||
Effect.gen(function* () {
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
const usage = {
|
||||
prompt_tokens: 5,
|
||||
completion_tokens: 2,
|
||||
total_tokens: 7,
|
||||
prompt_tokens_details: { cached_tokens: 1 },
|
||||
completion_tokens_details: { reasoning_tokens: 1 },
|
||||
}
|
||||
const model = OpenAIChat.route
|
||||
.with({
|
||||
endpoint: {
|
||||
baseURL: "https://api.openai.test/v1",
|
||||
query: { api_key: "secret-key", key: "short-key", sig: "signed-value" },
|
||||
},
|
||||
})
|
||||
.model({ id: "gpt-4o-mini" })
|
||||
let traceparent: string | undefined
|
||||
const request = LLM.request({
|
||||
model,
|
||||
system: "secret system prompt",
|
||||
prompt: "secret user prompt",
|
||||
generation: { maxTokens: 20, temperature: 0, topP: 0.9, topK: 40 },
|
||||
})
|
||||
const response = yield* Effect.useSpan("invoke_agent build", (agent) =>
|
||||
LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.sync(() => {
|
||||
traceparent = input.request.headers.traceparent
|
||||
return input.respond(
|
||||
sseEvents(
|
||||
deltaChunk({ role: "assistant", content: "Hello" }),
|
||||
deltaChunk({ content: " there" }),
|
||||
deltaChunk({}, "stop"),
|
||||
usageChunk(usage),
|
||||
),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.annotateSpans({
|
||||
[ATTR_GEN_AI_CONVERSATION_ID]: "session-1",
|
||||
[ATTR_AGENT_STEP_INDEX]: 1,
|
||||
[ATTR_AGENT_STEP_TRIGGER]: "input",
|
||||
}),
|
||||
Effect.withParentSpan(agent),
|
||||
),
|
||||
).pipe(Effect.provideService(Tracer.Tracer, tracer))
|
||||
|
||||
const span = spans.find((span) => span.name === "chat gpt-4o-mini")
|
||||
expect(response.usage).toEqual(
|
||||
new Usage({
|
||||
inputTokens: 5,
|
||||
outputTokens: 2,
|
||||
nonCachedInputTokens: 4,
|
||||
cacheReadInputTokens: 1,
|
||||
reasoningTokens: 1,
|
||||
totalTokens: 7,
|
||||
providerMetadata: { openai: usage },
|
||||
}),
|
||||
)
|
||||
expect(span?.kind).toBe("client")
|
||||
expect(Object.fromEntries(span?.attributes ?? [])).toMatchObject({
|
||||
[ATTR_GEN_AI_OPERATION_NAME]: GEN_AI_OPERATION_NAME_VALUE_CHAT,
|
||||
[ATTR_GEN_AI_PROVIDER_NAME]: "openai",
|
||||
[ATTR_GEN_AI_REQUEST_MODEL]: "gpt-4o-mini",
|
||||
[ATTR_GEN_AI_REQUEST_STREAM]: true,
|
||||
[ATTR_GEN_AI_REQUEST_MAX_TOKENS]: 20,
|
||||
[ATTR_GEN_AI_REQUEST_TEMPERATURE]: 0,
|
||||
[ATTR_GEN_AI_REQUEST_TOP_P]: 0.9,
|
||||
[ATTR_GEN_AI_REQUEST_TOP_K]: 40,
|
||||
[ATTR_GEN_AI_RESPONSE_FINISH_REASONS]: ["stop"],
|
||||
[ATTR_GEN_AI_USAGE_INPUT_TOKENS]: 5,
|
||||
[ATTR_GEN_AI_USAGE_OUTPUT_TOKENS]: 2,
|
||||
[ATTR_GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS]: 1,
|
||||
[ATTR_GEN_AI_USAGE_REASONING_OUTPUT_TOKENS]: 1,
|
||||
[ATTR_GEN_AI_CONVERSATION_ID]: "session-1",
|
||||
[ATTR_AGENT_STEP_INDEX]: 1,
|
||||
[ATTR_AGENT_STEP_TRIGGER]: "input",
|
||||
[ATTR_SERVER_ADDRESS]: "api.openai.test",
|
||||
[ATTR_SERVER_PORT]: 443,
|
||||
[ATTR_OPENCODE_LLM_ROUTE]: "openai-chat",
|
||||
[ATTR_OPENCODE_LLM_PROTOCOL]: "openai-chat",
|
||||
})
|
||||
expect(span?.attributes.get(ATTR_GEN_AI_RESPONSE_TIME_TO_FIRST_CHUNK)).toBeNumber()
|
||||
expect(span?.attributes.has(ATTR_GEN_AI_SYSTEM_INSTRUCTIONS)).toBe(false)
|
||||
expect(span?.attributes.has(ATTR_GEN_AI_INPUT_MESSAGES)).toBe(false)
|
||||
expect(span?.attributes.has(ATTR_GEN_AI_OUTPUT_MESSAGES)).toBe(false)
|
||||
expect(ancestorNames(span)).toContain("invoke_agent build")
|
||||
const http = spans.find((span) => span.attributes.get(ATTR_HTTP_REQUEST_METHOD) === "POST")
|
||||
expect(http?.name).toBe("POST")
|
||||
expect(http?.attributes.get(ATTR_HTTP_RESPONSE_STATUS_CODE)).toBe(200)
|
||||
expect(http?.attributes.get(ATTR_SERVER_PORT)).toBe(443)
|
||||
expect(http?.attributes.get(ATTR_URL_FULL)).toStartWith("https://api.openai.test/")
|
||||
expect(http?.attributes.get(ATTR_URL_FULL)).not.toContain("?")
|
||||
expect(http?.attributes.get(ATTR_URL_FULL)).not.toContain("secret-key")
|
||||
expect(http?.attributes.get(ATTR_URL_FULL)).not.toContain("short-key")
|
||||
expect(http?.attributes.get(ATTR_URL_FULL)).not.toContain("signed-value")
|
||||
expect(ancestorNames(http)).toContain("chat gpt-4o-mini")
|
||||
expect(
|
||||
span?.status._tag === "Ended" && http?.status._tag === "Ended"
|
||||
? span.status.endTime >= http.status.endTime
|
||||
: false,
|
||||
).toBeTrue()
|
||||
expect(traceparent?.split("-")[1]).toBe(http?.traceId)
|
||||
expect(traceparent?.split("-")[2]).toBe(http?.spanId)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the last reported usage when finish omits it", () =>
|
||||
Effect.gen(function* () {
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
const model = OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
|
||||
.model({ id: "gpt-4o-mini" })
|
||||
const request = LLM.request({ model, prompt: "secret" })
|
||||
|
||||
yield* instrument(
|
||||
request,
|
||||
Stream.fromIterable([
|
||||
LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 17, outputTokens: 9 } }),
|
||||
LLMEvent.finish({ reason: "stop" }),
|
||||
]),
|
||||
).pipe(Stream.runDrain, Effect.provideService(Tracer.Tracer, tracer))
|
||||
|
||||
const span = spans.find((span) => span.name === "chat gpt-4o-mini")
|
||||
expect(span?.attributes.get(ATTR_GEN_AI_USAGE_INPUT_TOKENS)).toBe(17)
|
||||
expect(span?.attributes.get(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS)).toBe(9)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves the model stream when span creation defects", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
|
||||
.model({ id: "broken-tracer-model" })
|
||||
const events = [LLMEvent.finish({ reason: "stop" })]
|
||||
const tracer = Tracer.make({
|
||||
span() {
|
||||
throw new Error("broken tracer")
|
||||
},
|
||||
})
|
||||
|
||||
const result = yield* instrument(LLM.request({ model, prompt: "secret" }), Stream.fromIterable(events)).pipe(
|
||||
Stream.runCollect,
|
||||
Effect.provideService(Tracer.Tracer, tracer),
|
||||
)
|
||||
|
||||
expect(Array.from(result)).toEqual(events)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("respects disabled tracing", () =>
|
||||
Effect.gen(function* () {
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
const model = OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
|
||||
.model({ id: "disabled-model" })
|
||||
const events = [LLMEvent.finish({ reason: "stop" })]
|
||||
|
||||
const result = yield* instrument(LLM.request({ model, prompt: "secret" }), Stream.fromIterable(events)).pipe(
|
||||
Stream.runCollect,
|
||||
Effect.provideService(References.TracerEnabled, false),
|
||||
Effect.provideService(Tracer.Tracer, tracer),
|
||||
)
|
||||
|
||||
expect(Array.from(result)).toEqual(events)
|
||||
expect(spans).toHaveLength(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not create transport spans beneath a disabled model span", () =>
|
||||
Effect.gen(function* () {
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
const model = OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
|
||||
.model({ id: "disabled-transport-model" })
|
||||
|
||||
yield* Effect.useSpan("parent", () =>
|
||||
LLMClient.generate(LLM.request({ model, prompt: "secret" })).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(deltaChunk({}, "stop")))),
|
||||
Effect.withTracerEnabled(false),
|
||||
),
|
||||
).pipe(Effect.provideService(Tracer.Tracer, tracer))
|
||||
|
||||
expect(spans.map((span) => span.name)).toEqual(["parent"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("requires an explicit model span for transport instrumentation", () =>
|
||||
Effect.gen(function* () {
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
|
||||
yield* Effect.useSpan("ambient", () =>
|
||||
Effect.all(
|
||||
[
|
||||
LLMHttpTelemetry.stream(HttpClientRequest.post("https://example.test/path"), () => Stream.empty).pipe(
|
||||
Stream.runDrain,
|
||||
),
|
||||
LLMWebSocketTelemetry.stream("wss://example.test/path", Stream.empty).pipe(Stream.runDrain),
|
||||
],
|
||||
{ discard: true },
|
||||
),
|
||||
).pipe(Effect.provideService(Tracer.Tracer, tracer))
|
||||
|
||||
expect(spans.map((span) => span.name)).toEqual(["ambient"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not attribute downstream consumer failures to transports", () =>
|
||||
Effect.gen(function* () {
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
const model = OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
|
||||
.model({ id: "consumer-failure-model" })
|
||||
const failure = new Error("consumer failed")
|
||||
|
||||
const error = yield* LLMClient.stream(LLM.request({ model, prompt: "secret" })).pipe(
|
||||
Stream.runForEach(() => Effect.fail(failure)),
|
||||
Effect.provide(fixedResponse(sseEvents(deltaChunk({ role: "assistant", content: "Hello" })))),
|
||||
Effect.flip,
|
||||
Effect.provideService(Tracer.Tracer, tracer),
|
||||
)
|
||||
|
||||
expect(error).toBe(failure)
|
||||
const modelSpan = spans.find((span) => span.name === "chat consumer-failure-model")
|
||||
const httpSpan = spans.find((span) => span.attributes.get(ATTR_HTTP_REQUEST_METHOD) === "POST")
|
||||
expect(modelSpan?.attributes.get(ATTR_ERROR_TYPE)).toBe("incomplete_response")
|
||||
expect(httpSpan?.attributes.has(ATTR_ERROR_TYPE)).toBeFalse()
|
||||
expect(httpSpan?.status._tag === "Ended" && httpSpan.status.exit._tag).toBe("Success")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("closes a model span when its stream fiber is interrupted", () =>
|
||||
Effect.gen(function* () {
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
const started = yield* Deferred.make<void>()
|
||||
const model = OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
|
||||
.model({ id: "interrupted-model" })
|
||||
const source = Stream.concat(
|
||||
Stream.fromEffect(Deferred.succeed(started, undefined).pipe(Effect.as(LLMEvent.stepStart({ index: 0 })))),
|
||||
Stream.never,
|
||||
)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const fiber = yield* instrument(LLM.request({ model, prompt: "secret" }), source).pipe(
|
||||
Stream.runDrain,
|
||||
Effect.forkChild,
|
||||
)
|
||||
yield* Deferred.await(started)
|
||||
yield* Fiber.interrupt(fiber)
|
||||
}).pipe(Effect.provideService(Tracer.Tracer, tracer))
|
||||
|
||||
const span = spans.find((span) => span.name === "chat interrupted-model")
|
||||
expect(span?.attributes.get(ATTR_ERROR_TYPE)).toBe("canceled")
|
||||
expect(span?.status._tag === "Ended" && span.status.exit._tag).toBe("Failure")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("measures first-chunk latency from request issuance", () =>
|
||||
Effect.gen(function* () {
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
const model = OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
|
||||
.model({ id: "timing-model" })
|
||||
const source = Stream.concat(
|
||||
Stream.fromEffect(
|
||||
Effect.gen(function* () {
|
||||
const requestIssued = yield* RequestIssued
|
||||
if (requestIssued) yield* requestIssued(yield* Clock.currentTimeNanos)
|
||||
yield* TestClock.adjust("250 millis")
|
||||
const firstChunk = yield* ResponseChunkReceived
|
||||
if (firstChunk) yield* firstChunk
|
||||
return LLMEvent.stepStart({ index: 0 })
|
||||
}),
|
||||
),
|
||||
Stream.succeed(LLMEvent.finish({ reason: "stop" })),
|
||||
)
|
||||
|
||||
yield* instrument(LLM.request({ model, prompt: "secret" }), source).pipe(
|
||||
Stream.runDrain,
|
||||
Effect.provideService(Tracer.Tracer, tracer),
|
||||
)
|
||||
|
||||
const span = spans.find((span) => span.name === "chat timing-model")
|
||||
expect(span?.attributes.get(ATTR_GEN_AI_RESPONSE_TIME_TO_FIRST_CHUNK)).toBe(0.25)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits first-chunk latency when request issuance is unavailable", () =>
|
||||
Effect.gen(function* () {
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
const model = OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
|
||||
.model({ id: "unknown-origin-model" })
|
||||
const source = Stream.fromIterable([LLMEvent.finish({ reason: "stop" })])
|
||||
|
||||
yield* instrument(LLM.request({ model, prompt: "secret" }), source).pipe(
|
||||
Stream.runDrain,
|
||||
Effect.provideService(Tracer.Tracer, tracer),
|
||||
)
|
||||
|
||||
const span = spans.find((span) => span.name === "chat unknown-origin-model")
|
||||
expect(span?.attributes.has(ATTR_GEN_AI_RESPONSE_TIME_TO_FIRST_CHUNK)).toBeFalse()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the model provider and route operation identity", () =>
|
||||
Effect.gen(function* () {
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
const model = OpenAIChat.route
|
||||
.with({ provider: "xai", endpoint: { baseURL: "https://api.x.ai/v1" } })
|
||||
.model({ id: "grok" })
|
||||
|
||||
yield* instrument(
|
||||
LLM.request({ model, prompt: "secret" }),
|
||||
Stream.fromIterable([LLMEvent.finish({ reason: "stop" })]),
|
||||
).pipe(Stream.runDrain, Effect.provideService(Tracer.Tracer, tracer))
|
||||
|
||||
const span = spans.find((span) => span.name === "chat grok")
|
||||
expect(span?.attributes.get(ATTR_GEN_AI_PROVIDER_NAME)).toBe("xai")
|
||||
expect(span?.attributes.get(ATTR_GEN_AI_OPERATION_NAME)).toBe("chat")
|
||||
|
||||
yield* instrument(
|
||||
LLM.request({ model: Gemini.route.model({ id: "gemini-test" }), prompt: "secret" }),
|
||||
Stream.fromIterable([LLMEvent.finish({ reason: "stop" })]),
|
||||
).pipe(Stream.runDrain, Effect.provideService(Tracer.Tracer, tracer))
|
||||
|
||||
const gemini = spans.find((span) => span.name === "generate_content gemini-test")
|
||||
expect(gemini?.attributes.get(ATTR_GEN_AI_PROVIDER_NAME)).toBe("google")
|
||||
expect(gemini?.attributes.get(ATTR_GEN_AI_OPERATION_NAME)).toBe("generate_content")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("finalizes duplicate terminal events once", () =>
|
||||
Effect.gen(function* () {
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
const model = OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
|
||||
.model({ id: "duplicate-terminal-model" })
|
||||
const usage = new Usage({ inputTokens: 5, outputTokens: 2 })
|
||||
const duplicateUsage = new Usage({ inputTokens: 99, outputTokens: 99 })
|
||||
|
||||
yield* instrument(
|
||||
LLM.request({ model, prompt: "secret" }),
|
||||
Stream.fromIterable([
|
||||
LLMEvent.stepFinish({ index: 0, reason: "stop", usage }),
|
||||
LLMEvent.finish({ reason: "stop", usage }),
|
||||
LLMEvent.finish({ reason: "length", usage: duplicateUsage }),
|
||||
]),
|
||||
).pipe(Stream.runDrain, Effect.provideService(Tracer.Tracer, tracer))
|
||||
|
||||
const span = spans.find((span) => span.name === "chat duplicate-terminal-model")
|
||||
expect(span?.attributes.get(ATTR_GEN_AI_RESPONSE_FINISH_REASONS)).toEqual(["stop"])
|
||||
expect(span?.attributes.get(ATTR_GEN_AI_USAGE_INPUT_TOKENS)).toBe(5)
|
||||
expect(span?.status._tag === "Ended" && span.status.exit._tag).toBe("Success")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("marks error finish reasons as provider failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
const model = OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
|
||||
.model({ id: "error-finish-model" })
|
||||
|
||||
yield* instrument(
|
||||
LLM.request({ model, prompt: "secret" }),
|
||||
Stream.succeed(LLMEvent.finish({ reason: "error" })),
|
||||
).pipe(Stream.runDrain, Effect.provideService(Tracer.Tracer, tracer))
|
||||
|
||||
const span = spans.find((span) => span.name === "chat error-finish-model")
|
||||
expect(span?.attributes.get(ATTR_ERROR_TYPE)).toBe("provider_error")
|
||||
expect(span?.attributes.get(ATTR_OPENCODE_ERROR_SOURCE)).toBe("provider")
|
||||
expect(span?.status._tag === "Ended" && span.status.exit._tag).toBe("Failure")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("propagates the transport span to provider requests", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const received: { traceparent?: string; b3?: string } = {}
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
received.traceparent = request.headers.get("traceparent") ?? undefined
|
||||
received.b3 = request.headers.get("b3") ?? undefined
|
||||
return new Response(
|
||||
sseEvents(deltaChunk({ role: "assistant", content: "Hello" }), deltaChunk({}, "stop")),
|
||||
{
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
},
|
||||
)
|
||||
},
|
||||
})
|
||||
return { received, server }
|
||||
}),
|
||||
({ received, server }) =>
|
||||
Effect.gen(function* () {
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
const model = OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: new URL("v1", server.url).toString() } })
|
||||
.model({ id: "gpt-4o-mini" })
|
||||
|
||||
yield* LLMClient.generate(LLM.request({ model, prompt: "secret" })).pipe(
|
||||
Effect.provide(runtimeLayer(FetchHttpClient.layer)),
|
||||
Effect.withSpan("invoke_agent build"),
|
||||
Effect.provideService(Tracer.Tracer, tracer),
|
||||
)
|
||||
|
||||
const http = spans.find((span) => span.attributes.get(ATTR_HTTP_REQUEST_METHOD) === "POST")
|
||||
const traceparent = received.traceparent?.split("-")
|
||||
expect(traceparent?.[1]).toBe(http?.traceId)
|
||||
expect(traceparent?.[2]).toBe(http?.spanId)
|
||||
expect(received.b3).toStartWith(`${http?.traceId}-${http?.spanId}-`)
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("marks structured provider failures with safe span errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
const model = OpenAIResponses.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
|
||||
.model({ id: "gpt-4o-mini" })
|
||||
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "secret" })).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents({ type: "error", code: "overloaded", message: "try later" }))),
|
||||
Effect.provideService(Tracer.Tracer, tracer),
|
||||
)
|
||||
|
||||
const span = spans.find((span) => span.name === "chat gpt-4o-mini")
|
||||
expect(response.finishReason).toBe("error")
|
||||
expect(span?.attributes.get(ATTR_ERROR_TYPE)).toBe("provider_error")
|
||||
expect(span?.attributes.get(ATTR_OPENCODE_ERROR_SOURCE)).toBe("provider")
|
||||
expect(span?.attributes.get(ATTR_OPENCODE_ERROR_STAGE)).toBe("response")
|
||||
expect(span?.attributes.has(ATTR_GEN_AI_RESPONSE_FINISH_REASONS)).toBeFalse()
|
||||
expect(span?.status._tag).toBe("Ended")
|
||||
expect(span?.status._tag === "Ended" && span.status.exit._tag).toBe("Failure")
|
||||
expect(spanFailure(span)?.message).toBe("provider_error")
|
||||
expect(spanFailure(span)?.message).not.toContain("try later")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("records request compilation failures only on the model span", () =>
|
||||
Effect.gen(function* () {
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
const model = OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
|
||||
.model({ id: "gpt-4o-mini" })
|
||||
|
||||
yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [Message.assistant({ type: "media", mediaType: "image/png", data: "aGVsbG8=" })],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse("")), Effect.flip, Effect.provideService(Tracer.Tracer, tracer))
|
||||
|
||||
const span = spans.find((span) => span.name === "chat gpt-4o-mini")
|
||||
expect(span?.attributes.get(ATTR_ERROR_TYPE)).toBe("InvalidRequest")
|
||||
expect(span?.attributes.get(ATTR_OPENCODE_ERROR_SOURCE)).toBe("request")
|
||||
expect(span?.attributes.get(ATTR_OPENCODE_ERROR_STAGE)).toBe("compile")
|
||||
expect(span?.status._tag === "Ended" && span.status.exit._tag).toBe("Failure")
|
||||
expect(spans.some((span) => span.name === "LLM.compile")).toBeFalse()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("marks HTTP failures and incomplete model streams", () =>
|
||||
Effect.gen(function* () {
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
const model = OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
|
||||
.model({ id: "gpt-4o-mini" })
|
||||
const request = LLM.request({ model, prompt: "secret" })
|
||||
|
||||
yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse("bad request", { status: 400 })),
|
||||
Effect.flip,
|
||||
Effect.provideService(Tracer.Tracer, tracer),
|
||||
)
|
||||
const failed = spans.find((span) => span.name === "chat gpt-4o-mini")
|
||||
expect(failed?.attributes.get(ATTR_ERROR_TYPE)).toBe("InvalidRequest")
|
||||
expect(failed?.attributes.get(ATTR_OPENCODE_ERROR_SOURCE)).toBe("provider")
|
||||
expect(failed?.attributes.get(ATTR_OPENCODE_PROVIDER_HTTP_STATUS_CODE)).toBe(400)
|
||||
expect(failed?.status._tag === "Ended" && failed.status.exit._tag).toBe("Failure")
|
||||
expect(spanFailure(failed)?.message).toBe("InvalidRequest")
|
||||
expect(spanFailure(failed)?.message).not.toContain("bad request")
|
||||
const failedHttp = spans.find((span) => span.attributes.get(ATTR_HTTP_REQUEST_METHOD) === "POST")
|
||||
expect(failedHttp?.attributes.get(ATTR_ERROR_TYPE)).toBe("InvalidRequest")
|
||||
expect(spanFailure(failedHttp)?.message).toBe("InvalidRequest")
|
||||
|
||||
spans.length = 0
|
||||
yield* LLMClient.stream(request).pipe(
|
||||
Stream.take(1),
|
||||
Stream.runDrain,
|
||||
Effect.provide(fixedResponse(sseEvents(deltaChunk({ role: "assistant", content: "Hello" })))),
|
||||
Effect.provideService(Tracer.Tracer, tracer),
|
||||
)
|
||||
const canceled = spans.find((span) => span.name === "chat gpt-4o-mini")
|
||||
expect(canceled?.attributes.get(ATTR_ERROR_TYPE)).toBe("incomplete_response")
|
||||
expect(canceled?.status._tag === "Ended" && canceled.status.exit._tag).toBe("Failure")
|
||||
const canceledHttp = spans.find((span) => span.attributes.get(ATTR_HTTP_REQUEST_METHOD) === "POST")
|
||||
expect(canceledHttp?.attributes.has(ATTR_ERROR_TYPE)).toBeFalse()
|
||||
expect(canceledHttp?.status._tag === "Ended" && canceledHttp.status.exit._tag).toBe("Success")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
function ancestorNames(span: Tracer.NativeSpan | undefined) {
|
||||
const names: string[] = []
|
||||
let current = span?.parent._tag === "Some" ? span.parent.value : undefined
|
||||
while (current?._tag === "Span") {
|
||||
names.push(current.name)
|
||||
current = current.parent._tag === "Some" ? current.parent.value : undefined
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
function spanFailure(span: Tracer.NativeSpan | undefined) {
|
||||
if (span?.status._tag !== "Ended" || span.status.exit._tag !== "Failure") return
|
||||
const failure = Cause.squash(span.status.exit.cause)
|
||||
return failure instanceof Error ? failure : undefined
|
||||
}
|
||||
@@ -84,8 +84,8 @@
|
||||
"@octokit/graphql": "9.0.2",
|
||||
"@octokit/rest": "catalog:",
|
||||
"@openauthjs/openauth": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/cli": "workspace:*",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/llm": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
|
||||
@@ -3,8 +3,6 @@ import { UI } from "@/cli/ui"
|
||||
import { errorMessage } from "@opencode-ai/tui/util/error"
|
||||
import { validateSession } from "../tui/validate-session"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { OpenCode } from "@opencode-ai/client/promise"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
|
||||
export const AttachCommand = cmd({
|
||||
command: "attach <url>",
|
||||
@@ -63,7 +61,9 @@ export const AttachCommand = cmd({
|
||||
return
|
||||
}
|
||||
|
||||
const headers = ServerAuth.headers({ password: args.password, username: args.username })
|
||||
const credentials = { password: args.password, username: args.username }
|
||||
const headers = ServerAuth.headers(credentials)
|
||||
const endpoint = ServerAuth.endpoint(args.url, credentials)
|
||||
const config = await TuiConfig.get()
|
||||
|
||||
try {
|
||||
@@ -84,9 +84,7 @@ export const AttachCommand = cmd({
|
||||
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
|
||||
await Effect.runPromise(
|
||||
run({
|
||||
// @ts-expect-error V1 does not consume the V2-only server input.
|
||||
client: createOpencodeClient({ baseUrl: args.url, headers, directory }),
|
||||
api: OpenCode.make({ baseUrl: args.url, headers }),
|
||||
server: { endpoint },
|
||||
config,
|
||||
pluginHost: createLegacyTuiPluginHost(),
|
||||
args: {
|
||||
|
||||
@@ -8,8 +8,6 @@ import { errorMessage } from "@opencode-ai/tui/util/error"
|
||||
import { withTimeout } from "@/util/timeout"
|
||||
import { withNetworkOptions, resolveNetworkOptionsNoConfig, hasArg } from "@/cli/network"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { OpenCode } from "@opencode-ai/client/promise"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { writeHeapSnapshot } from "v8"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { validateSession } from "../tui/validate-session"
|
||||
@@ -135,6 +133,7 @@ export const TuiThreadCommand = cmd({
|
||||
const external = hasArg("--port") || hasArg("--hostname") || network.mdns === true
|
||||
const headers = external ? ServerAuth.headers() : undefined
|
||||
const url = (await client.call("server", network)).url
|
||||
const endpoint = external ? ServerAuth.endpoint(url) : { url }
|
||||
|
||||
try {
|
||||
await validateSession({
|
||||
@@ -157,11 +156,9 @@ export const TuiThreadCommand = cmd({
|
||||
const { Effect } = await import("effect")
|
||||
const { run } = await import("../tui/layer")
|
||||
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
|
||||
await Effect.runPromise(
|
||||
await Effect.runPromise(
|
||||
run({
|
||||
// @ts-expect-error V1 does not consume the V2-only server input.
|
||||
client: createOpencodeClient({ baseUrl: url, headers, directory: cwd }),
|
||||
api: OpenCode.make({ baseUrl: url, headers }),
|
||||
server: { endpoint },
|
||||
async onSnapshot() {
|
||||
const tui = writeHeapSnapshot("tui.heapsnapshot")
|
||||
const server = await client.call("snapshot", undefined)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as ServerAuth from "./auth"
|
||||
|
||||
import { ConfigService } from "@/effect/config-service"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Config as EffectConfig, Context, Option, Redacted } from "effect"
|
||||
|
||||
@@ -46,3 +47,12 @@ export function headers(credentials?: Credentials) {
|
||||
if (!authorization) return undefined
|
||||
return { Authorization: authorization }
|
||||
}
|
||||
|
||||
export function endpoint(url: string, credentials?: Credentials): Service.Endpoint {
|
||||
const password = credentials?.password ?? Flag.OPENCODE_SERVER_PASSWORD
|
||||
const username = credentials?.username ?? Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
|
||||
return {
|
||||
url,
|
||||
auth: password ? { type: "basic", username, password } : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,10 @@ describe("ServerAuth", () => {
|
||||
|
||||
expect(ServerAuth.header()).toBeUndefined()
|
||||
expect(ServerAuth.headers()).toBeUndefined()
|
||||
expect(ServerAuth.endpoint("http://localhost:4096")).toEqual({
|
||||
url: "http://localhost:4096",
|
||||
auth: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
test("defaults to the opencode username", () => {
|
||||
@@ -47,6 +51,10 @@ describe("ServerAuth", () => {
|
||||
expect(ServerAuth.headers({ password: "cli-secret", username: "bob" })).toEqual({
|
||||
Authorization: `Basic ${Buffer.from("bob:cli-secret").toString("base64")}`,
|
||||
})
|
||||
expect(ServerAuth.endpoint("https://example.test", { password: "cli-secret", username: "bob" })).toEqual({
|
||||
url: "https://example.test",
|
||||
auth: { type: "basic", username: "bob", password: "cli-secret" },
|
||||
})
|
||||
})
|
||||
|
||||
test("validates decoded credentials against effect config", () => {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { OpenCode } from "@opencode-ai/client/effect"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { createEmbeddedRoutes } from "@opencode-ai/server/routes"
|
||||
import { Context, Effect, Layer, ManagedRuntime } from "effect"
|
||||
import { FetchHttpClient, HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { Context, Effect, Layer, ManagedRuntime, Scope } from "effect"
|
||||
import { FetchHttpClient, HttpEffect, HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
|
||||
|
||||
type Services = Layer.Success<ReturnType<typeof createEmbeddedRoutes>>
|
||||
|
||||
export const create = Effect.fn("OpenCode.create")(function* () {
|
||||
const runtime = yield* Effect.acquireRelease(
|
||||
@@ -12,7 +14,9 @@ export const create = Effect.fn("OpenCode.create")(function* () {
|
||||
const context = yield* runtime.contextEffect
|
||||
const plugins = Context.get(context, SdkPlugins.Service)
|
||||
const router = Context.get(context, HttpRouter.HttpRouter)
|
||||
const handler = HttpEffect.toWebHandler(router.asHttpEffect())
|
||||
const handler = HttpEffect.toWebHandlerWith<Services, HttpServerRequest.HttpServerRequest | Scope.Scope>(context)(
|
||||
router.asHttpEffect(),
|
||||
)
|
||||
const fetch = Object.assign((input: RequestInfo | URL, init?: RequestInit) => handler(new Request(input, init)), {
|
||||
preconnect: () => undefined,
|
||||
}) satisfies typeof globalThis.fetch
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Effect, Layer, Schema } from "effect"
|
||||
import { HttpRouter, HttpServerRequest } from "effect/unstable/http"
|
||||
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||
import { requestRef, type LocationServices } from "../location"
|
||||
import { ServerObservability } from "../observability"
|
||||
|
||||
export class FormLocationMiddleware extends HttpApiMiddleware.Service<
|
||||
FormLocationMiddleware,
|
||||
@@ -47,28 +48,27 @@ export const formLocationLayer = Layer.effect(
|
||||
}),
|
||||
),
|
||||
)
|
||||
const row = yield* db
|
||||
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row) {
|
||||
return yield* new SessionNotFoundError({
|
||||
sessionID,
|
||||
message: `Session not found: ${sessionID}`,
|
||||
const location = yield* Effect.gen(function* () {
|
||||
const row = yield* db
|
||||
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row) {
|
||||
return yield* new SessionNotFoundError({
|
||||
sessionID,
|
||||
message: `Session not found: ${sessionID}`,
|
||||
})
|
||||
}
|
||||
return Location.Ref.make({
|
||||
directory: AbsolutePath.make(row.directory),
|
||||
workspaceID: row.workspaceID ? WorkspaceV2.ID.make(row.workspaceID) : undefined,
|
||||
})
|
||||
}
|
||||
}).pipe(Effect.tapCause((cause) => ServerObservability.locationFailure(cause, sessionID, "resolve")))
|
||||
|
||||
return yield* effect.pipe(
|
||||
Effect.provide(
|
||||
locations.get(
|
||||
Location.Ref.make({
|
||||
directory: AbsolutePath.make(row.directory),
|
||||
workspaceID: row.workspaceID ? WorkspaceV2.ID.make(row.workspaceID) : undefined,
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.provide(ServerObservability.locationLayer(locations.get(location), sessionID)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Effect, Layer, Schema } from "effect"
|
||||
import { HttpRouter } from "effect/unstable/http"
|
||||
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
|
||||
import { ServerObservability } from "../observability"
|
||||
import type { LocationServices } from "../location"
|
||||
|
||||
export class SessionLocationMiddleware extends HttpApiMiddleware.Service<
|
||||
@@ -39,28 +40,27 @@ export const sessionLocationLayer = Layer.effect(
|
||||
}),
|
||||
),
|
||||
)
|
||||
const row = yield* db
|
||||
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row)
|
||||
return yield* new SessionNotFoundError({
|
||||
sessionID,
|
||||
message: `Session not found: ${sessionID}`,
|
||||
const location = yield* Effect.gen(function* () {
|
||||
const row = yield* db
|
||||
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row)
|
||||
return yield* new SessionNotFoundError({
|
||||
sessionID,
|
||||
message: `Session not found: ${sessionID}`,
|
||||
})
|
||||
return Location.Ref.make({
|
||||
directory: AbsolutePath.make(row.directory),
|
||||
workspaceID: row.workspaceID ? WorkspaceV2.ID.make(row.workspaceID) : undefined,
|
||||
})
|
||||
|
||||
return yield* effect.pipe(
|
||||
Effect.provide(
|
||||
locations.get(
|
||||
Location.Ref.make({
|
||||
directory: AbsolutePath.make(row.directory),
|
||||
workspaceID: row.workspaceID ? WorkspaceV2.ID.make(row.workspaceID) : undefined,
|
||||
}),
|
||||
),
|
||||
),
|
||||
}).pipe(
|
||||
Effect.tapCause((cause) => ServerObservability.locationFailure(cause, sessionID, "resolve")),
|
||||
)
|
||||
|
||||
return yield* effect.pipe(Effect.provide(ServerObservability.locationLayer(locations.get(location), sessionID)))
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
export * as ServerObservability from "./observability"
|
||||
|
||||
import { Cause, Effect, Layer, Option } from "effect"
|
||||
import { HttpMiddleware } from "effect/unstable/http"
|
||||
|
||||
export const httpTracingDisabled = Layer.succeed(HttpMiddleware.TracerDisabledWhen, () => true)
|
||||
|
||||
export function locationLayer<A, E, R>(layer: Layer.Layer<A, E, R>, sessionID: string) {
|
||||
return layer.pipe(Layer.tapCause((cause) => locationFailure(cause, sessionID, "load")))
|
||||
}
|
||||
|
||||
export function locationFailure(cause: Cause.Cause<unknown>, sessionID: string, phase: "resolve" | "load") {
|
||||
if (Cause.hasInterruptsOnly(cause)) return Effect.void
|
||||
const error = Option.getOrUndefined(Cause.findErrorOption(cause))
|
||||
const log = error && typeof error === "object" && "_tag" in error && error._tag === "SessionNotFoundError"
|
||||
? Effect.logWarning
|
||||
: Effect.logError
|
||||
return log("Failed to resolve Session location", cause).pipe(
|
||||
Effect.annotateLogs({
|
||||
operation: "session.location",
|
||||
phase,
|
||||
sessionID,
|
||||
errorType: errorType(cause),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function errorType(cause: Cause.Cause<unknown>) {
|
||||
const error = Option.getOrUndefined(Cause.findErrorOption(cause))
|
||||
if (error && typeof error === "object" && "_tag" in error && typeof error._tag === "string") return error._tag
|
||||
const failure = Cause.squash(cause)
|
||||
return failure instanceof Error ? failure.name : "unknown"
|
||||
}
|
||||
@@ -56,7 +56,9 @@ function bind(hostname: string, port: number, password: string) {
|
||||
return ServerInfo.connectionURLs(`http://${host}:${address.port}`, hostname)
|
||||
}).pipe(
|
||||
Layer.flatMap((context) =>
|
||||
HttpServer.serve(Context.get(context, HttpRouter.HttpRouter).asHttpEffect(), HttpMiddleware.logger),
|
||||
HttpServer.serve(Context.get(context, HttpRouter.HttpRouter).asHttpEffect(), HttpMiddleware.logger).pipe(
|
||||
Layer.provide(Layer.succeedContext(context)),
|
||||
),
|
||||
),
|
||||
Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })),
|
||||
),
|
||||
|
||||
@@ -30,6 +30,7 @@ import { PtyEnvironment } from "./pty-environment"
|
||||
import { layer } from "./location"
|
||||
import { formLocationLayer } from "./middleware/form-location"
|
||||
import { sessionLocationLayer } from "./middleware/session-location"
|
||||
import { ServerObservability } from "./observability"
|
||||
import { ServerInfo } from "./server-info"
|
||||
|
||||
const applicationServices = LayerNode.group([
|
||||
@@ -90,6 +91,7 @@ function makeRoutes<AuthError, AuthServices>(
|
||||
: AppNodeBuilder.build(applicationServices, replacements)
|
||||
|
||||
return serviceLayer.pipe(
|
||||
Layer.provideMerge(Observability.layer),
|
||||
Layer.flatMap((context) => {
|
||||
const services = Layer.succeedContext(context)
|
||||
const requestServices = Layer.merge(
|
||||
@@ -104,7 +106,7 @@ function makeRoutes<AuthError, AuthServices>(
|
||||
Layer.provide(authorizationLayer),
|
||||
Layer.provide(schemaErrorLayer),
|
||||
Layer.provide(auth),
|
||||
Layer.provide(Observability.layer),
|
||||
Layer.merge(ServerObservability.httpTracingDisabled),
|
||||
HttpRouter.provideRequest(requestServices),
|
||||
Layer.provideMerge(services),
|
||||
Layer.provideMerge(HttpRouter.layer),
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { NodeServices } from "@effect/platform-node"
|
||||
import { Context, Effect, Layer, References } from "effect"
|
||||
import { HttpMiddleware, HttpServer, HttpServerRequest } from "effect/unstable/http"
|
||||
|
||||
test("route construction retains the server tracing policy", async () => {
|
||||
const database = process.env.OPENCODE_DB
|
||||
const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT
|
||||
delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT
|
||||
process.env.OPENCODE_DB = ":memory:"
|
||||
|
||||
try {
|
||||
const { createEmbeddedRoutes } = await import("../src/routes")
|
||||
await Effect.gen(function* () {
|
||||
const context = yield* Layer.build(
|
||||
createEmbeddedRoutes().pipe(
|
||||
Layer.provide(HttpServer.layerServices),
|
||||
Layer.provide(NodeServices.layer),
|
||||
),
|
||||
)
|
||||
const request = HttpServerRequest.fromWeb(new Request("http://opencode.local/api/session"))
|
||||
|
||||
expect(Context.get(context, References.TracerEnabled)).toBeFalse()
|
||||
expect(Context.get(context, HttpMiddleware.TracerDisabledWhen)(request)).toBeTrue()
|
||||
}).pipe(Effect.scoped, Effect.runPromise)
|
||||
} finally {
|
||||
if (database === undefined) delete process.env.OPENCODE_DB
|
||||
else process.env.OPENCODE_DB = database
|
||||
if (endpoint === undefined) delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT
|
||||
else process.env.OTEL_EXPORTER_OTLP_ENDPOINT = endpoint
|
||||
}
|
||||
})
|
||||
@@ -176,6 +176,7 @@ export default defineConfig({
|
||||
"config",
|
||||
"providers",
|
||||
"network",
|
||||
"observability",
|
||||
"enterprise",
|
||||
"troubleshooting",
|
||||
{
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
---
|
||||
title: OpenTelemetry
|
||||
description: Export OpenCode logs and traces to an OpenTelemetry backend such as Dash0.
|
||||
---
|
||||
|
||||
OpenCode can export application logs and traces through OTLP over HTTP. Export is enabled when `OTEL_EXPORTER_OTLP_ENDPOINT` is present in the environment at process startup.
|
||||
|
||||
---
|
||||
|
||||
## Signal support
|
||||
|
||||
| Signal | Support | Details |
|
||||
| ------ | ------- | --------------------------------------------------------------------------------------------------- |
|
||||
| Logs | Yes | OpenCode application logs are exported automatically. The default minimum level is `INFO`. |
|
||||
| Traces | Yes | OpenCode exports application spans and OpenTelemetry GenAI spans for V2 agent and model operations. |
|
||||
|
||||
OpenCode appends `/v1/logs` and `/v1/traces` to the configured endpoint. Set a base OTLP/HTTP endpoint without a signal path and without a trailing slash.
|
||||
|
||||
:::note
|
||||
OpenCode supports the shared `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_HEADERS`, and `OTEL_RESOURCE_ATTRIBUTES` variables. Signal-specific endpoint variables and OTLP/gRPC are not currently supported.
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## Dash0
|
||||
|
||||
Create an authorization token with ingest permissions in Dash0, then set the following variables in the environment that starts OpenCode:
|
||||
|
||||
```bash
|
||||
export DASH0_AUTH_TOKEN="api_key"
|
||||
export OTEL_EXPORTER_OTLP_ENDPOINT="https://ingress.us-west-2.aws.dash0.com"
|
||||
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer ${DASH0_AUTH_TOKEN}"
|
||||
export OTEL_RESOURCE_ATTRIBUTES="deployment.environment.name=development"
|
||||
|
||||
opencode
|
||||
```
|
||||
|
||||
Use the endpoint for your Dash0 region if it differs from the example. OpenCode sends telemetry directly to:
|
||||
|
||||
```text
|
||||
https://ingress.us-west-2.aws.dash0.com/v1/logs
|
||||
https://ingress.us-west-2.aws.dash0.com/v1/traces
|
||||
```
|
||||
|
||||
To route telemetry to a specific [Dash0 dataset](https://www.dash0.com/docs/dash0/miscellaneous/glossary/datasets), add its header to the comma-separated header value:
|
||||
|
||||
```bash
|
||||
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer ${DASH0_AUTH_TOKEN},Dash0-Dataset=production"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### AI model spans
|
||||
|
||||
The V2 Session runner emits an `invoke_agent <agent>` span around each agent turn, from initial input promotion until the Session would become idle. Each `chat <model>` client span is one model step beneath that turn, and `execute_tool <name>` spans cover local tool execution. Provider HTTP response streams and WebSocket connections are client spans beneath their model calls. Spans include safe OpenTelemetry GenAI attributes for the agent, configured provider identity, requested model, generation settings, response timing, finish reason, token usage, cache usage, reasoning usage, conversation ID, tool identity, and transport endpoint. First-chunk latency is measured from request issuance to the first provider transport chunk or WebSocket message.
|
||||
|
||||
Agent spans record input promotion, provider retry decisions, hosted-tool activity, and compaction lifecycle as span events. Subagent tool spans identify the child Session and target agent. Foreground child agents remain nested, while background child traces link to the spawning tool span and include `opencode.session.parent.id` for searchable correlation.
|
||||
|
||||
Each new turn also links to the previous turn observed for that Session in the current process. This process-local chain lets linked-trace navigation walk backward through recent conversation turns without keeping one long-lived trace open or adding durable turn state.
|
||||
|
||||
Failed spans use existing typed error categories, error source and stage, HTTP status, transport kind, retry decision, and provider request ID attributes when available. Built-in provider stream errors preserve structured provider codes without inferring retry policy from message text. Raw provider bodies, exception messages, prompt content, and tool output are excluded from spans.
|
||||
|
||||
Model and tool spans include `opencode.agent.step.index` and `opencode.agent.step.trigger`. Trigger values distinguish calls caused by new `input`, a `tool_result`, a provider `retry`, `compaction`, or explicit `resume`; promoted inputs also include their `steer` or `queue` delivery mode.
|
||||
|
||||
OpenCode disables Effect tracing by default and enables it only at explicit GenAI operation boundaries. Unrelated spans such as startup, configuration, request lowering, and database operations are not recorded.
|
||||
|
||||
Prompt admission and control-plane HTTP requests are not traced. Admission, Session placement, and execution lifecycle failures emit sanitized structured logs with their Session, operation or phase, and stable error type. Provider HTTP and WebSocket requests do not receive trace propagation headers.
|
||||
|
||||
Prompt content, model output, system instructions, tool definitions, tool arguments, headers, and credentials are not recorded on these spans.
|
||||
|
||||
The `experimental.openTelemetry` configuration option only controls AI SDK telemetry on the legacy Session implementation. V2 GenAI spans are exported whenever OTLP tracing is configured.
|
||||
|
||||
### Navigate conversations
|
||||
|
||||
Each agent turn is exported as a separate trace.
|
||||
|
||||
1. In Dash0, filter by `gen_ai.conversation.id = <session-id>` and `gen_ai.operation.name = invoke_agent`, then sort by start time.
|
||||
2. List turn traces with the Dash0 CLI:
|
||||
|
||||
```bash
|
||||
dash0 spans query \
|
||||
--dataset production \
|
||||
--from now-4h \
|
||||
--filter "gen_ai.conversation.id is <session-id>" \
|
||||
--filter "gen_ai.operation.name is invoke_agent" \
|
||||
--column timestamp \
|
||||
--column "span name" \
|
||||
--column "trace id" \
|
||||
--column "span links"
|
||||
```
|
||||
|
||||
3. Follow links from the newest turn:
|
||||
|
||||
```bash
|
||||
dash0 traces get <trace-id> \
|
||||
--dataset production \
|
||||
--from now-4h \
|
||||
--follow-span-links
|
||||
```
|
||||
|
||||
4. Use **Links to** for the previous turn and **Linked from** for the next. The oldest observed turn has no `previous_turn` link.
|
||||
|
||||
Foreground subagents are child spans in the spawning turn. Background subagents use linked traces with `opencode.link.type=subagent`; turn links use `opencode.link.type=previous_turn`. Links are process-local, so use `gen_ai.conversation.id` across restarts.
|
||||
|
||||
---
|
||||
|
||||
### Resource attributes
|
||||
|
||||
Every exported signal has these resource attributes:
|
||||
|
||||
| Attribute | Value |
|
||||
| ----------------------------- | ---------------------------------------------------------------------------- |
|
||||
| `service.name` | `opencode` |
|
||||
| `service.version` | The OpenCode version |
|
||||
| `deployment.environment.name` | `OTEL_RESOURCE_ATTRIBUTES` value, or the OpenCode release channel by default |
|
||||
| `opencode.client` | The client type, such as `cli` |
|
||||
| `opencode.run` | A unique ID for the current process |
|
||||
| `service.instance.id` | The same process ID as `opencode.run` |
|
||||
|
||||
Add more comma-separated attributes with `OTEL_RESOURCE_ATTRIBUTES`. Percent-encode commas and equals signs inside names or values.
|
||||
|
||||
```bash
|
||||
export OTEL_RESOURCE_ATTRIBUTES="deployment.environment.name=development,service.namespace=developer-tools,team.name=platform"
|
||||
```
|
||||
|
||||
`service.name`, `service.version`, `opencode.client`, `opencode.run`, and `service.instance.id` are set by OpenCode and cannot be overridden.
|
||||
|
||||
---
|
||||
|
||||
### Verify
|
||||
|
||||
1. Start OpenCode with the environment variables set.
|
||||
2. Run a prompt and keep the process open for a few seconds so batched telemetry can be exported.
|
||||
3. In Dash0, select a time range that includes the test.
|
||||
4. Search logs and traces for `service.name = opencode`.
|
||||
5. Use `opencode.run` to correlate records from one OpenCode process.
|
||||
|
||||
Logs emitted inside an active span include the trace and span IDs, allowing Dash0 to correlate those log records with the trace.
|
||||
|
||||
---
|
||||
|
||||
### Troubleshoot
|
||||
|
||||
- Confirm the endpoint is the base regional URL and does not end in `/v1/logs`, `/v1/traces`, or `/`.
|
||||
- Confirm the token has ingest permissions and the `Authorization` value starts with `Bearer `.
|
||||
- Set the environment variables before OpenCode starts. Restart a running OpenCode process after changing them.
|
||||
- Set `OPENCODE_LOG_LEVEL=DEBUG` to export debug logs while investigating. This can substantially increase volume and may expose more sensitive details.
|
||||
- `experimental.openTelemetry` only affects the legacy Session implementation; V2 GenAI spans do not require it.
|
||||
- Check the local OpenCode log for exporter errors. See [Troubleshooting](/docs/troubleshooting#logs).
|
||||
- Configure proxy and certificate settings as described in [Network](/docs/network) when direct HTTPS access to Dash0 is unavailable.
|
||||
|
||||
Short-lived commands can exit before all batched spans are sent. Prefer verifying with the TUI or a long-running `opencode serve` process.
|
||||
Reference in New Issue
Block a user