feat(core): add embedded v2 session runtime and tool foundation (#30632)

This commit is contained in:
Kit Langton
2026-06-03 23:02:17 -04:00
committed by GitHub
parent c35267776a
commit 76ee87ead8
215 changed files with 31344 additions and 3278 deletions
@@ -4,10 +4,10 @@ import { ProviderTransform } from "@/provider/transform"
import { errorMessage } from "@/util/error"
import { isRecord } from "@/util/record"
import { asSchema, type ModelMessage, type Tool } from "ai"
import { Effect } from "effect"
import { Cause, Effect, FiberSet, Queue } from "effect"
import * as Stream from "effect/Stream"
import { FetchHttpClient } from "effect/unstable/http"
import { tool as nativeTool, ToolFailure, type JsonSchema, type LLMEvent } from "@opencode-ai/llm"
import { LLMRequest, Tool as NativeTool, ToolFailure, ToolRuntime, toDefinitions, type JsonSchema, type LLMEvent } from "@opencode-ai/llm"
import type { LLMClientShape } from "@opencode-ai/llm/route"
import { LLMNative } from "./native-request"
@@ -78,8 +78,8 @@ export function stream(input: StreamInput): StreamResult {
// OpenAI's official wire field names, so this is identity, not translation
// — if a field ever needs to differ between the two surfaces, the
// translation belongs here, not split across both packages.
const stream = input.llmClient.stream({
request: LLMNative.request({
const tools = nativeTools(input.tools, input)
const request = LLMNative.request({
model: input.model,
apiKey: current.apiKey,
baseURL: current.baseURL,
@@ -91,9 +91,45 @@ export function stream(input: StreamInput): StreamResult {
maxOutputTokens: input.maxOutputTokens,
providerOptions: ProviderTransform.providerOptions(input.model, input.providerOptions ?? {}),
headers: { ...providerHeaders(input.provider.options.headers), ...input.headers },
}),
tools: nativeTools(input.tools, input),
})
const stream = Stream.scoped(
Stream.unwrap(
Effect.gen(function* () {
const settlements = yield* FiberSet.make<void>()
const results = yield* Queue.unbounded<LLMEvent, Cause.Done>()
const provider = input.llmClient
.stream(
LLMRequest.update(request, {
tools: [...request.tools, ...toDefinitions(tools)],
}),
)
.pipe(
Stream.flatMap((event) =>
event.type !== "tool-call" || event.providerExecuted
? Stream.make(event)
: Stream.make(event).pipe(
Stream.concat(
Stream.fromEffectDrain(
ToolRuntime.dispatch(tools, event).pipe(
Effect.flatMap((dispatched) => Queue.offerAll(results, dispatched.events)),
Effect.catchCause((cause) => Queue.failCause(results, cause)),
Effect.asVoid,
FiberSet.run(settlements, { startImmediately: true }),
),
),
),
),
),
Stream.concat(
Stream.fromEffectDrain(
FiberSet.awaitEmpty(settlements).pipe(Effect.andThen(Queue.end(results)), Effect.asVoid),
),
),
)
return provider.pipe(Stream.concat(Stream.fromQueue(results)))
}),
),
)
return {
...current,
@@ -128,7 +164,7 @@ export function nativeTools(tools: Record<string, Tool>, input: Pick<StreamInput
name,
// Tool execution remains opencode-owned. The native runtime only adapts
// the @opencode-ai/llm tool call back into the AI SDK Tool.execute shape.
nativeTool({
NativeTool.make({
description: item.description ?? "",
jsonSchema: nativeSchema(item.inputSchema),
execute: (args: unknown, ctx) =>
+179 -31
View File
@@ -29,7 +29,9 @@ import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import * as DateTime from "effect/DateTime"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Usage, type LLMEvent } from "@opencode-ai/llm"
import { toolFileSourceFromUri, Usage, type LLMEvent } from "@opencode-ai/llm"
import { ToolOutput } from "@opencode-ai/core/tool-output"
import type { EventV2 } from "@opencode-ai/core/event"
const DOOM_LOOP_THRESHOLD = 3
const log = Log.create({ service: "session.processor" })
@@ -65,11 +67,13 @@ export interface Interface {
}
type ToolCall = {
assistantMessageID?: EventV2.ID
partID: SessionV1.ToolPart["id"]
messageID: SessionV1.ToolPart["messageID"]
sessionID: SessionV1.ToolPart["sessionID"]
done: Deferred.Deferred<void>
inputEnded: boolean
raw: string
}
interface ProcessorContext extends Input {
@@ -79,7 +83,9 @@ interface ProcessorContext extends Input {
blocked: boolean
needsCompaction: boolean
currentText: SessionV1.TextPart | undefined
currentTextID: string | undefined
reasoningMap: Record<string, SessionV1.ReasoningPart>
v2AssistantMessageID: EventV2.ID | undefined
}
type StreamEvent = LLMEvent
@@ -119,7 +125,9 @@ export const layer = Layer.effect(
blocked: false,
needsCompaction: false,
currentText: undefined,
currentTextID: undefined,
reasoningMap: {},
v2AssistantMessageID: undefined,
}
let aborted = false
const slog = log.clone().tag("session.id", input.sessionID).tag("messageID", input.assistantMessage.id)
@@ -136,6 +144,32 @@ export const layer = Layer.effect(
if (done) yield* Deferred.succeed(done, undefined).pipe(Effect.ignore)
})
const ensureV2AssistantMessage = Effect.fn("SessionProcessor.ensureV2AssistantMessage")(function* () {
if (ctx.v2AssistantMessageID) return ctx.v2AssistantMessageID
ctx.v2AssistantMessageID = (yield* events.publish(SessionEvent.Step.Started, {
sessionID: ctx.sessionID,
agent: input.assistantMessage.agent,
model: {
id: ModelV2.ID.make(ctx.model.id),
providerID: ProviderV2.ID.make(ctx.model.providerID),
variant: ModelV2.VariantID.make(input.assistantMessage.variant ?? "default"),
},
snapshot: ctx.snapshot,
timestamp: DateTime.makeUnsafe(Date.now()),
})).id
return ctx.v2AssistantMessageID
})
const requireV2AssistantMessage = (toolCall?: ToolCall) =>
toolCall?.assistantMessageID === undefined
? Effect.die("V2 tool settlement has no owning assistant message")
: Effect.succeed(toolCall.assistantMessageID)
const currentV2AssistantMessage = () =>
ctx.v2AssistantMessageID === undefined
? Effect.die("V2 step settlement has no owning assistant message")
: Effect.succeed(ctx.v2AssistantMessageID)
const readToolCall = Effect.fn("SessionProcessor.readToolCall")(function* (toolCallID: string) {
const call = ctx.toolcalls[toolCallID]
if (!call) return undefined
@@ -220,6 +254,7 @@ export const layer = Layer.effect(
sessionID: ctx.sessionID,
reasoningID,
text: ctx.reasoningMap[reasoningID].text,
providerMetadata: ctx.reasoningMap[reasoningID].metadata,
timestamp: DateTime.makeUnsafe(Date.now()),
})
}
@@ -230,6 +265,27 @@ export const layer = Layer.effect(
delete ctx.reasoningMap[reasoningID]
})
const flushV2Fragments = Effect.fn("SessionProcessor.flushV2Fragments")(function* () {
if (!flags.experimentalEventSystem) return
if (!ctx.assistantMessage.summary && ctx.currentText && ctx.currentTextID) {
yield* events.publish(SessionEvent.Text.Ended, {
sessionID: ctx.sessionID,
textID: ctx.currentTextID,
text: ctx.currentText.text,
timestamp: DateTime.makeUnsafe(Date.now()),
})
}
yield* Effect.forEach(Object.entries(ctx.reasoningMap), ([reasoningID, part]) =>
events.publish(SessionEvent.Reasoning.Ended, {
sessionID: ctx.sessionID,
reasoningID,
text: part.text,
providerMetadata: part.metadata,
timestamp: DateTime.makeUnsafe(Date.now()),
}),
)
})
const ensureToolCall = Effect.fn("SessionProcessor.ensureToolCall")(function* (input: {
id: string
name: string
@@ -251,9 +307,11 @@ export const layer = Layer.effect(
return { call: ctx.toolcalls[input.id], part }
}
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
const assistantMessageID = flags.experimentalEventSystem ? yield* ensureV2AssistantMessage() : undefined
if (assistantMessageID) {
yield* events.publish(SessionEvent.Tool.Input.Started, {
sessionID: ctx.sessionID,
assistantMessageID,
callID: input.id,
name: input.name,
timestamp: DateTime.makeUnsafe(Date.now()),
@@ -270,11 +328,13 @@ export const layer = Layer.effect(
metadata: input.providerExecuted ? { providerExecuted: true } : undefined,
} satisfies SessionV1.ToolPart)
ctx.toolcalls[input.id] = {
assistantMessageID,
done: yield* Deferred.make<void>(),
partID: part.id,
messageID: part.messageID,
sessionID: part.sessionID,
inputEnded: false,
raw: "",
}
return { call: ctx.toolcalls[input.id], part }
})
@@ -311,6 +371,7 @@ export const layer = Layer.effect(
yield* events.publish(SessionEvent.Reasoning.Started, {
sessionID: ctx.sessionID,
reasoningID: value.id,
providerMetadata: value.providerMetadata,
timestamp: DateTime.makeUnsafe(Date.now()),
})
}
@@ -331,6 +392,14 @@ export const layer = Layer.effect(
if (!(value.id in ctx.reasoningMap)) return
ctx.reasoningMap[value.id].text += value.text
if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
if (flags.experimentalEventSystem) {
yield* events.publish(SessionEvent.Reasoning.Delta, {
sessionID: ctx.sessionID,
reasoningID: value.id,
delta: value.text,
timestamp: DateTime.makeUnsafe(Date.now()),
})
}
yield* session.updatePartDelta({
sessionID: ctx.reasoningMap[value.id].sessionID,
messageID: ctx.reasoningMap[value.id].messageID,
@@ -355,18 +424,32 @@ export const layer = Layer.effect(
return
case "tool-input-delta":
// AI SDK emits a final `tool-call` with the parsed `input`; accumulating
// delta fragments into `state.raw` is redundant work for no current consumer.
{
const toolCall = yield* ensureToolCall(value)
const assistantMessageID = flags.experimentalEventSystem ? yield* requireV2AssistantMessage(toolCall.call) : undefined
if (assistantMessageID) {
yield* events.publish(SessionEvent.Tool.Input.Delta, {
sessionID: ctx.sessionID,
assistantMessageID,
callID: value.id,
delta: value.text,
timestamp: DateTime.makeUnsafe(Date.now()),
})
}
ctx.toolcalls[value.id] = { ...toolCall.call, raw: toolCall.call.raw + value.text }
}
return
case "tool-input-end": {
const toolCall = yield* ensureToolCall(value)
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
const assistantMessageID = yield* requireV2AssistantMessage(toolCall.call)
yield* events.publish(SessionEvent.Tool.Input.Ended, {
sessionID: ctx.sessionID,
assistantMessageID,
callID: value.id,
text: "",
text: toolCall.call.raw,
timestamp: DateTime.makeUnsafe(Date.now()),
})
}
@@ -383,18 +466,22 @@ export const layer = Layer.effect(
if (!toolCall.call.inputEnded) {
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
const assistantMessageID = yield* requireV2AssistantMessage(toolCall.call)
yield* events.publish(SessionEvent.Tool.Input.Ended, {
sessionID: ctx.sessionID,
assistantMessageID,
callID: value.id,
text: "",
text: toolCall.call.raw,
timestamp: DateTime.makeUnsafe(Date.now()),
})
}
}
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
const assistantMessageID = yield* requireV2AssistantMessage(toolCall.call)
yield* events.publish(SessionEvent.Tool.Called, {
sessionID: ctx.sessionID,
assistantMessageID,
callID: value.id,
tool: value.name,
input,
@@ -453,6 +540,27 @@ export const layer = Layer.effect(
case "tool-result": {
const toolCall = yield* readToolCall(value.id)
if (!toolCall && value.result.type === "error") return
if (value.result.type === "error") {
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
const assistantMessageID = yield* requireV2AssistantMessage(toolCall?.call)
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: ctx.sessionID,
assistantMessageID,
callID: value.id,
error: { type: "unknown", message: errorMessage(value.result.value) },
result: value.result,
provider: {
executed: value.providerExecuted === true || toolCall?.part.metadata?.providerExecuted === true,
...(value.providerMetadata ? { metadata: value.providerMetadata } : {}),
},
timestamp: DateTime.makeUnsafe(Date.now()),
})
}
yield* failToolCall(value.id, value.result.value)
return
}
const rawOutput = toolResultOutput(value)
const normalized = yield* Effect.forEach(rawOutput.attachments ?? [], (attachment) =>
attachment.mime.startsWith("image/")
@@ -477,24 +585,42 @@ export const layer = Layer.effect(
}
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
yield* events.publish(SessionEvent.Tool.Success, {
const assistantMessageID = yield* requireV2AssistantMessage(toolCall?.call)
const content = [
ToolOutput.text({ type: "text", text: output.output }),
...(output.attachments?.map((item: SessionV1.FilePart) =>
ToolOutput.file({ type: "file", source: toolFileSourceFromUri(item.url), mime: item.mime, name: item.filename }),
) ?? []),
]
const unsupported = content.find((item) => item.type === "file" && item.source.type !== "data")
if (unsupported?.type === "file") {
const error = new Error(`Tool attachment source "${unsupported.source.type}" must be materialized before durable V2 settlement`)
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: ctx.sessionID,
assistantMessageID,
callID: value.id,
error: {
type: "unknown",
message: error.message,
},
provider: {
executed: value.providerExecuted === true || toolCall?.part.metadata?.providerExecuted === true,
...(value.providerMetadata ? { metadata: value.providerMetadata } : {}),
},
timestamp: DateTime.makeUnsafe(Date.now()),
})
yield* failToolCall(value.id, error)
return
} else yield* events.publish(SessionEvent.Tool.Success, {
sessionID: ctx.sessionID,
assistantMessageID,
callID: value.id,
structured: output.metadata,
content: [
{
type: "text",
text: output.output,
},
...(output.attachments?.map((item: SessionV1.FilePart) => ({
type: "file" as const,
uri: item.url,
mime: item.mime,
name: item.filename,
})) ?? []),
],
content,
result: value.result,
provider: {
executed: value.providerExecuted === true || toolCall?.part.metadata?.providerExecuted === true,
...(value.providerMetadata ? { metadata: value.providerMetadata } : {}),
},
timestamp: DateTime.makeUnsafe(Date.now()),
})
@@ -507,8 +633,10 @@ export const layer = Layer.effect(
const toolCall = yield* readToolCall(value.id)
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
const assistantMessageID = yield* requireV2AssistantMessage(toolCall?.call)
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: ctx.sessionID,
assistantMessageID,
callID: value.id,
error: {
type: "unknown",
@@ -516,6 +644,7 @@ export const layer = Layer.effect(
},
provider: {
executed: toolCall?.part.metadata?.providerExecuted === true,
...(value.providerMetadata ? { metadata: value.providerMetadata } : {}),
},
timestamp: DateTime.makeUnsafe(Date.now()),
})
@@ -532,17 +661,7 @@ export const layer = Layer.effect(
if (!ctx.assistantMessage.summary) {
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
yield* events.publish(SessionEvent.Step.Started, {
sessionID: ctx.sessionID,
agent: input.assistantMessage.agent,
model: {
id: ModelV2.ID.make(ctx.model.id),
providerID: ProviderV2.ID.make(ctx.model.providerID),
variant: ModelV2.VariantID.make(input.assistantMessage.variant ?? "default"),
},
snapshot: ctx.snapshot,
timestamp: DateTime.makeUnsafe(Date.now()),
})
yield* ensureV2AssistantMessage()
}
}
yield* session.updatePart({
@@ -567,12 +686,14 @@ export const layer = Layer.effect(
if (flags.experimentalEventSystem) {
yield* events.publish(SessionEvent.Step.Ended, {
sessionID: ctx.sessionID,
assistantMessageID: yield* currentV2AssistantMessage(),
finish: value.reason,
cost: usage.cost,
tokens: usage.tokens,
snapshot: completedSnapshot,
timestamp: DateTime.makeUnsafe(Date.now()),
})
ctx.v2AssistantMessageID = undefined
}
}
ctx.assistantMessage.finish = value.reason
@@ -625,6 +746,7 @@ export const layer = Layer.effect(
yield* events.publish(SessionEvent.Text.Started, {
sessionID: ctx.sessionID,
timestamp: DateTime.makeUnsafe(Date.now()),
textID: value.id,
})
}
}
@@ -637,6 +759,7 @@ export const layer = Layer.effect(
time: { start: Date.now() },
metadata: value.providerMetadata,
}
ctx.currentTextID = value.id
yield* session.updatePart(ctx.currentText)
return
@@ -644,6 +767,14 @@ export const layer = Layer.effect(
if (!ctx.currentText) return
ctx.currentText.text += value.text
if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata
if (flags.experimentalEventSystem) {
yield* events.publish(SessionEvent.Text.Delta, {
sessionID: ctx.sessionID,
textID: value.id,
delta: value.text,
timestamp: DateTime.makeUnsafe(Date.now()),
})
}
yield* session.updatePartDelta({
sessionID: ctx.currentText.sessionID,
messageID: ctx.currentText.messageID,
@@ -673,6 +804,7 @@ export const layer = Layer.effect(
sessionID: ctx.sessionID,
text: ctx.currentText.text,
timestamp: DateTime.makeUnsafe(Date.now()),
textID: value.id,
})
}
}
@@ -683,6 +815,7 @@ export const layer = Layer.effect(
if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata
yield* session.updatePart(ctx.currentText)
ctx.currentText = undefined
ctx.currentTextID = undefined
return
case "finish":
@@ -711,6 +844,7 @@ export const layer = Layer.effect(
ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end }
yield* session.updatePart(ctx.currentText)
ctx.currentText = undefined
ctx.currentTextID = undefined
}
for (const part of Object.values(ctx.reasoningMap)) {
@@ -732,6 +866,16 @@ export const layer = Layer.effect(
const match = yield* readToolCall(toolCallID)
if (!match) continue
const part = match.part
if (flags.experimentalEventSystem && match.call.assistantMessageID) {
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: ctx.sessionID,
assistantMessageID: match.call.assistantMessageID,
callID: toolCallID,
error: { type: "unknown", message: "Tool execution aborted" },
provider: { executed: part.metadata?.providerExecuted === true },
timestamp: DateTime.makeUnsafe(Date.now()),
})
}
const end = Date.now()
const metadata = "metadata" in part.state && isRecord(part.state.metadata) ? part.state.metadata : {}
yield* session.updatePart({
@@ -753,6 +897,7 @@ export const layer = Layer.effect(
const halt = Effect.fn("SessionProcessor.halt")(function* (e: unknown) {
slog.error("process", { error: errorMessage(e), stack: e instanceof Error ? e.stack : undefined })
const error = parse(e)
yield* flushV2Fragments()
if (SessionV1.ContextOverflowError.isInstance(error)) {
ctx.needsCompaction = true
yield* events.publish(Session.Event.Error, { sessionID: ctx.sessionID, error })
@@ -763,6 +908,7 @@ export const layer = Layer.effect(
if (flags.experimentalEventSystem) {
yield* events.publish(SessionEvent.Step.Failed, {
sessionID: ctx.sessionID,
assistantMessageID: yield* ensureV2AssistantMessage(),
error: {
type: "unknown",
message: errorMessage(e),
@@ -787,6 +933,7 @@ export const layer = Layer.effect(
return yield* Effect.gen(function* () {
yield* Effect.gen(function* () {
ctx.currentText = undefined
ctx.currentTextID = undefined
ctx.reasoningMap = {}
yield* status.set(ctx.sessionID, { type: "busy" })
const stream = llm.stream(streamInput)
@@ -826,7 +973,8 @@ export const layer = Layer.effect(
timestamp: DateTime.makeUnsafe(Date.now()),
})
: Effect.void
return event.pipe(
return flushV2Fragments().pipe(
Effect.andThen(event),
Effect.andThen(
status.set(ctx.sessionID, {
type: "retry",
+4 -3
View File
@@ -53,7 +53,7 @@ import { Database } from "@opencode-ai/core/database/database"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AgentAttachment, FileAttachment, ReferenceAttachment, Source } from "@opencode-ai/core/session/prompt"
import { AgentAttachment, FileAttachment, Prompt, ReferenceAttachment, Source } from "@opencode-ai/core/session/prompt"
import { Reference } from "@/reference/reference"
import * as DateTime from "effect/DateTime"
import { eq } from "drizzle-orm"
@@ -1191,12 +1191,13 @@ export const layer = Layer.effect(
yield* events.publish(SessionEvent.Prompted, {
sessionID: input.sessionID,
timestamp: DateTime.makeUnsafe(info.time.created),
prompt: {
delivery: "steer",
prompt: new Prompt({
text: nextPrompt.text.join("\n"),
files: nextPrompt.files,
agents: nextPrompt.agents,
references: nextPrompt.references,
},
}),
})
}
for (const text of nextPrompt.synthetic) {