fix(effect): add effect bridge for callback contexts (#22504)

This commit is contained in:
Kit Langton 2026-04-15 11:22:34 -04:00 committed by GitHub
commit f1751401aa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 480 additions and 380 deletions

View file

@ -510,6 +510,7 @@
"effect": "catalog:", "effect": "catalog:",
}, },
"devDependencies": { "devDependencies": {
"@typescript/native-preview": "catalog:",
"typescript": "catalog:", "typescript": "catalog:",
}, },
}, },

View file

@ -1,6 +1,6 @@
import z from "zod" import z from "zod"
import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect" import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect"
import { EffectLogger } from "@/effect/logger" import { EffectBridge } from "@/effect/bridge"
import { Log } from "../util/log" import { Log } from "../util/log"
import { BusEvent } from "./bus-event" import { BusEvent } from "./bus-event"
import { GlobalBus } from "./global" import { GlobalBus } from "./global"
@ -128,6 +128,7 @@ export namespace Bus {
function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) { function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) {
return Effect.gen(function* () { return Effect.gen(function* () {
log.info("subscribing", { type }) log.info("subscribing", { type })
const bridge = yield* EffectBridge.make()
const scope = yield* Scope.make() const scope = yield* Scope.make()
const subscription = yield* Scope.provide(scope)(PubSub.subscribe(pubsub)) const subscription = yield* Scope.provide(scope)(PubSub.subscribe(pubsub))
@ -147,7 +148,7 @@ export namespace Bus {
return () => { return () => {
log.info("unsubscribing", { type }) log.info("unsubscribing", { type })
Effect.runFork(Scope.close(scope, Exit.void).pipe(Effect.provide(EffectLogger.layer))) bridge.fork(Scope.close(scope, Exit.void))
} }
}) })
} }

View file

@ -1,9 +1,9 @@
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { EffectBridge } from "@/effect/bridge"
import type { InstanceContext } from "@/project/instance" import type { InstanceContext } from "@/project/instance"
import { SessionID, MessageID } from "@/session/schema" import { SessionID, MessageID } from "@/session/schema"
import { Effect, Layer, Context } from "effect" import { Effect, Layer, Context } from "effect"
import { EffectLogger } from "@/effect/logger"
import z from "zod" import z from "zod"
import { Config } from "../config/config" import { Config } from "../config/config"
import { MCP } from "../mcp" import { MCP } from "../mcp"
@ -82,6 +82,7 @@ export namespace Command {
const init = Effect.fn("Command.state")(function* (ctx: InstanceContext) { const init = Effect.fn("Command.state")(function* (ctx: InstanceContext) {
const cfg = yield* config.get() const cfg = yield* config.get()
const bridge = yield* EffectBridge.make()
const commands: Record<string, Info> = {} const commands: Record<string, Info> = {}
commands[Default.INIT] = { commands[Default.INIT] = {
@ -125,7 +126,7 @@ export namespace Command {
source: "mcp", source: "mcp",
description: prompt.description, description: prompt.description,
get template() { get template() {
return Effect.runPromise( return bridge.promise(
mcp mcp
.getPrompt( .getPrompt(
prompt.client, prompt.client,
@ -141,7 +142,6 @@ export namespace Command {
.map((message) => (message.content.type === "text" ? message.content.text : "")) .map((message) => (message.content.type === "text" ? message.content.text : ""))
.join("\n") || "", .join("\n") || "",
), ),
Effect.provide(EffectLogger.layer),
), ),
) )
}, },

View file

@ -12,6 +12,10 @@ export const WorkspaceContext = {
return context.provide({ workspaceID: input.workspaceID as string }, () => input.fn()) return context.provide({ workspaceID: input.workspaceID as string }, () => input.fn())
}, },
restore<R>(workspaceID: string, fn: () => R): R {
return context.provide({ workspaceID }, fn)
},
get workspaceID() { get workspaceID() {
try { try {
return context.use().workspaceID return context.use().workspaceID

View file

@ -0,0 +1,49 @@
import { Effect, Fiber } from "effect"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { Instance, type InstanceContext } from "@/project/instance"
import { LocalContext } from "@/util/local-context"
import { InstanceRef, WorkspaceRef } from "./instance-ref"
import { attachWith } from "./run-service"
export namespace EffectBridge {
export interface Shape {
readonly promise: <A, E, R>(effect: Effect.Effect<A, E, R>) => Promise<A>
readonly fork: <A, E, R>(effect: Effect.Effect<A, E, R>) => Fiber.Fiber<A, E>
}
function restore<R>(instance: InstanceContext | undefined, workspace: string | undefined, fn: () => R): R {
if (instance && workspace !== undefined) {
return WorkspaceContext.restore(workspace, () => Instance.restore(instance, fn))
}
if (instance) return Instance.restore(instance, fn)
if (workspace !== undefined) return WorkspaceContext.restore(workspace, fn)
return fn()
}
export function make(): Effect.Effect<Shape> {
return Effect.gen(function* () {
const ctx = yield* Effect.context()
const value = yield* InstanceRef
const instance =
value ??
(() => {
try {
return Instance.current
} catch (err) {
if (!(err instanceof LocalContext.NotFound)) throw err
}
})()
const workspace = (yield* WorkspaceRef) ?? WorkspaceContext.workspaceID
const attach = <A, E, R>(effect: Effect.Effect<A, E, R>) => attachWith(effect, { instance, workspace })
const wrap = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
attach(effect).pipe(Effect.provide(ctx)) as Effect.Effect<A, E, never>
return {
promise: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
restore(instance, workspace, () => Effect.runPromise(wrap(effect))),
fork: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
restore(instance, workspace, () => Effect.runFork(wrap(effect))),
} satisfies Shape
})
}
}

View file

@ -5,14 +5,31 @@ import { LocalContext } from "@/util/local-context"
import { InstanceRef, WorkspaceRef } from "./instance-ref" import { InstanceRef, WorkspaceRef } from "./instance-ref"
import { Observability } from "./observability" import { Observability } from "./observability"
import { WorkspaceContext } from "@/control-plane/workspace-context" import { WorkspaceContext } from "@/control-plane/workspace-context"
import type { InstanceContext } from "@/project/instance"
export const memoMap = Layer.makeMemoMapUnsafe() export const memoMap = Layer.makeMemoMapUnsafe()
type Refs = {
instance?: InstanceContext
workspace?: string
}
export function attachWith<A, E, R>(effect: Effect.Effect<A, E, R>, refs: Refs): Effect.Effect<A, E, R> {
if (!refs.instance && !refs.workspace) return effect
if (!refs.instance) return effect.pipe(Effect.provideService(WorkspaceRef, refs.workspace))
if (!refs.workspace) return effect.pipe(Effect.provideService(InstanceRef, refs.instance))
return effect.pipe(
Effect.provideService(InstanceRef, refs.instance),
Effect.provideService(WorkspaceRef, refs.workspace),
)
}
export function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> { export function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
try { try {
const ctx = Instance.current return attachWith(effect, {
const workspaceID = WorkspaceContext.workspaceID instance: Instance.current,
return effect.pipe(Effect.provideService(InstanceRef, ctx), Effect.provideService(WorkspaceRef, workspaceID)) workspace: WorkspaceContext.workspaceID,
})
} catch (err) { } catch (err) {
if (!(err instanceof LocalContext.NotFound)) throw err if (!(err instanceof LocalContext.NotFound)) throw err
} }

View file

@ -25,7 +25,7 @@ import { Bus } from "@/bus"
import { TuiEvent } from "@/cli/cmd/tui/event" import { TuiEvent } from "@/cli/cmd/tui/event"
import open from "open" import open from "open"
import { Effect, Exit, Layer, Option, Context, Stream } from "effect" import { Effect, Exit, Layer, Option, Context, Stream } from "effect"
import { EffectLogger } from "@/effect/logger" import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
@ -471,25 +471,24 @@ export namespace MCP {
Effect.catch(() => Effect.succeed([] as number[])), Effect.catch(() => Effect.succeed([] as number[])),
) )
function watch(s: State, name: string, client: MCPClient, timeout?: number) { function watch(s: State, name: string, client: MCPClient, bridge: EffectBridge.Shape, timeout?: number) {
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => { client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
log.info("tools list changed notification received", { server: name }) log.info("tools list changed notification received", { server: name })
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
const listed = await Effect.runPromise(defs(name, client, timeout).pipe(Effect.provide(EffectLogger.layer))) const listed = await bridge.promise(defs(name, client, timeout))
if (!listed) return if (!listed) return
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
s.defs[name] = listed s.defs[name] = listed
await Effect.runPromise( await bridge.promise(bus.publish(ToolsChanged, { server: name }).pipe(Effect.ignore))
bus.publish(ToolsChanged, { server: name }).pipe(Effect.ignore, Effect.provide(EffectLogger.layer)),
)
}) })
} }
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("MCP.state")(function* () { Effect.fn("MCP.state")(function* () {
const cfg = yield* cfgSvc.get() const cfg = yield* cfgSvc.get()
const bridge = yield* EffectBridge.make()
const config = cfg.mcp ?? {} const config = cfg.mcp ?? {}
const s: State = { const s: State = {
status: {}, status: {},
@ -518,7 +517,7 @@ export namespace MCP {
if (result.mcpClient) { if (result.mcpClient) {
s.clients[key] = result.mcpClient s.clients[key] = result.mcpClient
s.defs[key] = result.defs! s.defs[key] = result.defs!
watch(s, key, result.mcpClient, mcp.timeout) watch(s, key, result.mcpClient, bridge, mcp.timeout)
} }
}), }),
{ concurrency: "unbounded" }, { concurrency: "unbounded" },
@ -565,11 +564,12 @@ export namespace MCP {
listed: MCPToolDef[], listed: MCPToolDef[],
timeout?: number, timeout?: number,
) { ) {
const bridge = yield* EffectBridge.make()
yield* closeClient(s, name) yield* closeClient(s, name)
s.status[name] = { status: "connected" } s.status[name] = { status: "connected" }
s.clients[name] = client s.clients[name] = client
s.defs[name] = listed s.defs[name] = listed
watch(s, name, client, timeout) watch(s, name, client, bridge, timeout)
return s.status[name] return s.status[name]
}) })

View file

@ -18,7 +18,7 @@ import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
import { PoeAuthPlugin } from "opencode-poe-auth" import { PoeAuthPlugin } from "opencode-poe-auth"
import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare" import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
import { Effect, Layer, Context, Stream } from "effect" import { Effect, Layer, Context, Stream } from "effect"
import { EffectLogger } from "@/effect/logger" import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { errorMessage } from "@/util/error" import { errorMessage } from "@/util/error"
import { PluginLoader } from "./loader" import { PluginLoader } from "./loader"
@ -90,14 +90,6 @@ export namespace Plugin {
return result return result
} }
function publishPluginError(bus: Bus.Interface, message: string) {
Effect.runFork(
bus
.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
.pipe(Effect.provide(EffectLogger.layer)),
)
}
async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: Hooks[]) { async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: Hooks[]) {
const plugin = readV1Plugin(load.mod, load.spec, "server", "detect") const plugin = readV1Plugin(load.mod, load.spec, "server", "detect")
if (plugin) { if (plugin) {
@ -120,6 +112,11 @@ export namespace Plugin {
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("Plugin.state")(function* (ctx) { Effect.fn("Plugin.state")(function* (ctx) {
const hooks: Hooks[] = [] const hooks: Hooks[] = []
const bridge = yield* EffectBridge.make()
function publishPluginError(message: string) {
bridge.fork(bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }))
}
const { Server } = yield* Effect.promise(() => import("../server/server")) const { Server } = yield* Effect.promise(() => import("../server/server"))
@ -187,24 +184,24 @@ export namespace Plugin {
if (stage === "install") { if (stage === "install") {
const parsed = parsePluginSpecifier(spec) const parsed = parsePluginSpecifier(spec)
log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: message }) log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: message })
publishPluginError(bus, `Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`) publishPluginError(`Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`)
return return
} }
if (stage === "compatibility") { if (stage === "compatibility") {
log.warn("plugin incompatible", { path: spec, error: message }) log.warn("plugin incompatible", { path: spec, error: message })
publishPluginError(bus, `Plugin ${spec} skipped: ${message}`) publishPluginError(`Plugin ${spec} skipped: ${message}`)
return return
} }
if (stage === "entry") { if (stage === "entry") {
log.error("failed to resolve plugin server entry", { path: spec, error: message }) log.error("failed to resolve plugin server entry", { path: spec, error: message })
publishPluginError(bus, `Failed to load plugin ${spec}: ${message}`) publishPluginError(`Failed to load plugin ${spec}: ${message}`)
return return
} }
log.error("failed to load plugin", { path: spec, target: resolved?.entry, error: message }) log.error("failed to load plugin", { path: spec, target: resolved?.entry, error: message })
publishPluginError(bus, `Failed to load plugin ${spec}: ${message}`) publishPluginError(`Failed to load plugin ${spec}: ${message}`)
}, },
}, },
}), }),

View file

@ -19,7 +19,7 @@ import { iife } from "@/util/iife"
import { Global } from "../global" import { Global } from "../global"
import path from "path" import path from "path"
import { Effect, Layer, Context } from "effect" import { Effect, Layer, Context } from "effect"
import { EffectLogger } from "@/effect/logger" import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
@ -1043,6 +1043,7 @@ export namespace Provider {
const state = yield* InstanceState.make<State>(() => const state = yield* InstanceState.make<State>(() =>
Effect.gen(function* () { Effect.gen(function* () {
using _ = log.time("state") using _ = log.time("state")
const bridge = yield* EffectBridge.make()
const cfg = yield* config.get() const cfg = yield* config.get()
const modelsDev = yield* Effect.promise(() => ModelsDev.get()) const modelsDev = yield* Effect.promise(() => ModelsDev.get())
const database = mapValues(modelsDev, fromModelsDevProvider) const database = mapValues(modelsDev, fromModelsDevProvider)
@ -1223,8 +1224,7 @@ export namespace Provider {
const options = yield* Effect.promise(() => const options = yield* Effect.promise(() =>
plugin.auth!.loader!( plugin.auth!.loader!(
() => () => bridge.promise(auth.get(providerID).pipe(Effect.orDie)) as any,
Effect.runPromise(auth.get(providerID).pipe(Effect.orDie, Effect.provide(EffectLogger.layer))) as any,
database[plugin.auth!.provider], database[plugin.auth!.provider],
), ),
) )

View file

@ -10,7 +10,7 @@ import { Shell } from "@/shell/shell"
import { Plugin } from "@/plugin" import { Plugin } from "@/plugin"
import { PtyID } from "./schema" import { PtyID } from "./schema"
import { Effect, Layer, Context } from "effect" import { Effect, Layer, Context } from "effect"
import { EffectLogger } from "@/effect/logger" import { EffectBridge } from "@/effect/bridge"
export namespace Pty { export namespace Pty {
const log = Log.create({ service: "pty" }) const log = Log.create({ service: "pty" })
@ -173,6 +173,7 @@ export namespace Pty {
const create = Effect.fn("Pty.create")(function* (input: CreateInput) { const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
const s = yield* InstanceState.get(state) const s = yield* InstanceState.get(state)
const bridge = yield* EffectBridge.make()
const id = PtyID.ascending() const id = PtyID.ascending()
const command = input.command || Shell.preferred() const command = input.command || Shell.preferred()
const args = input.args || [] const args = input.args || []
@ -256,8 +257,8 @@ export namespace Pty {
if (session.info.status === "exited") return if (session.info.status === "exited") return
log.info("session exited", { id, exitCode }) log.info("session exited", { id, exitCode })
session.info.status = "exited" session.info.status = "exited"
Effect.runFork(bus.publish(Event.Exited, { id, exitCode }).pipe(Effect.provide(EffectLogger.layer))) bridge.fork(bus.publish(Event.Exited, { id, exitCode }))
Effect.runFork(remove(id).pipe(Effect.provide(EffectLogger.layer))) bridge.fork(remove(id))
}), }),
) )
yield* bus.publish(Event.Created, { info }) yield* bus.publish(Event.Created, { info })

View file

@ -20,13 +20,12 @@ import { Wildcard } from "@/util/wildcard"
import { SessionID } from "@/session/schema" import { SessionID } from "@/session/schema"
import { Auth } from "@/auth" import { Auth } from "@/auth"
import { Installation } from "@/installation" import { Installation } from "@/installation"
import { makeRuntime } from "@/effect/run-service" import { EffectBridge } from "@/effect/bridge"
import * as Option from "effect/Option" import * as Option from "effect/Option"
import * as OtelTracer from "@effect/opentelemetry/Tracer" import * as OtelTracer from "@effect/opentelemetry/Tracer"
export namespace LLM { export namespace LLM {
const log = Log.create({ service: "llm" }) const log = Log.create({ service: "llm" })
const perms = makeRuntime(Permission.Service, Permission.defaultLayer)
export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
type Result = Awaited<ReturnType<typeof streamText>> type Result = Awaited<ReturnType<typeof streamText>>
@ -57,369 +56,371 @@ export namespace LLM {
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM") {} export class Service extends Context.Service<Service, Interface>()("@opencode/LLM") {}
export const layer: Layer.Layer<Service, never, Auth.Service | Config.Service | Provider.Service | Plugin.Service> = const live: Layer.Layer<
Layer.effect( Service,
Service, never,
Effect.gen(function* () { Auth.Service | Config.Service | Provider.Service | Plugin.Service | Permission.Service
const auth = yield* Auth.Service > = Layer.effect(
const config = yield* Config.Service Service,
const provider = yield* Provider.Service Effect.gen(function* () {
const plugin = yield* Plugin.Service const auth = yield* Auth.Service
const config = yield* Config.Service
const provider = yield* Provider.Service
const plugin = yield* Plugin.Service
const perm = yield* Permission.Service
const run = Effect.fn("LLM.run")(function* (input: StreamRequest) { const run = Effect.fn("LLM.run")(function* (input: StreamRequest) {
const l = log const l = log
.clone() .clone()
.tag("providerID", input.model.providerID) .tag("providerID", input.model.providerID)
.tag("modelID", input.model.id) .tag("modelID", input.model.id)
.tag("sessionID", input.sessionID) .tag("sessionID", input.sessionID)
.tag("small", (input.small ?? false).toString()) .tag("small", (input.small ?? false).toString())
.tag("agent", input.agent.name) .tag("agent", input.agent.name)
.tag("mode", input.agent.mode) .tag("mode", input.agent.mode)
l.info("stream", { l.info("stream", {
modelID: input.model.id, modelID: input.model.id,
providerID: input.model.providerID, providerID: input.model.providerID,
})
const [language, cfg, item, info] = yield* Effect.all(
[
provider.getLanguage(input.model),
config.get(),
provider.getProvider(input.model.providerID),
auth.get(input.model.providerID),
],
{ concurrency: "unbounded" },
)
// TODO: move this to a proper hook
const isOpenaiOauth = item.id === "openai" && info?.type === "oauth"
const system: string[] = []
system.push(
[
// use agent prompt otherwise provider prompt
...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
// any custom prompt passed into this call
...input.system,
// any custom prompt from last user message
...(input.user.system ? [input.user.system] : []),
]
.filter((x) => x)
.join("\n"),
)
const header = system[0]
yield* plugin.trigger(
"experimental.chat.system.transform",
{ sessionID: input.sessionID, model: input.model },
{ system },
)
// rejoin to maintain 2-part structure for caching if header unchanged
if (system.length > 2 && system[0] === header) {
const rest = system.slice(1)
system.length = 0
system.push(header, rest.join("\n"))
}
const variant =
!input.small && input.model.variants && input.user.model.variant
? input.model.variants[input.user.model.variant]
: {}
const base = input.small
? ProviderTransform.smallOptions(input.model)
: ProviderTransform.options({
model: input.model,
sessionID: input.sessionID,
providerOptions: item.options,
})
const options: Record<string, any> = pipe(
base,
mergeDeep(input.model.options),
mergeDeep(input.agent.options),
mergeDeep(variant),
)
if (isOpenaiOauth) {
options.instructions = system.join("\n")
}
const isWorkflow = language instanceof GitLabWorkflowLanguageModel
const messages = isOpenaiOauth
? input.messages
: isWorkflow
? input.messages
: [
...system.map(
(x): ModelMessage => ({
role: "system",
content: x,
}),
),
...input.messages,
]
const params = yield* plugin.trigger(
"chat.params",
{
sessionID: input.sessionID,
agent: input.agent.name,
model: input.model,
provider: item,
message: input.user,
},
{
temperature: input.model.capabilities.temperature
? (input.agent.temperature ?? ProviderTransform.temperature(input.model))
: undefined,
topP: input.agent.topP ?? ProviderTransform.topP(input.model),
topK: ProviderTransform.topK(input.model),
maxOutputTokens: ProviderTransform.maxOutputTokens(input.model),
options,
},
)
const { headers } = yield* plugin.trigger(
"chat.headers",
{
sessionID: input.sessionID,
agent: input.agent.name,
model: input.model,
provider: item,
message: input.user,
},
{
headers: {},
},
)
const tools = resolveTools(input)
// LiteLLM and some Anthropic proxies require the tools parameter to be present
// when message history contains tool calls, even if no tools are being used.
// Add a dummy tool that is never called to satisfy this validation.
// This is enabled for:
// 1. Providers with "litellm" in their ID or API ID (auto-detected)
// 2. Providers with explicit "litellmProxy: true" option (opt-in for custom gateways)
const isLiteLLMProxy =
item.options?.["litellmProxy"] === true ||
input.model.providerID.toLowerCase().includes("litellm") ||
input.model.api.id.toLowerCase().includes("litellm")
// LiteLLM/Bedrock rejects requests where the message history contains tool
// calls but no tools param is present. When there are no active tools (e.g.
// during compaction), inject a stub tool to satisfy the validation requirement.
// The stub description explicitly tells the model not to call it.
if (
(isLiteLLMProxy || input.model.providerID.includes("github-copilot")) &&
Object.keys(tools).length === 0 &&
hasToolCalls(input.messages)
) {
tools["_noop"] = tool({
description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",
inputSchema: jsonSchema({
type: "object",
properties: {
reason: { type: "string", description: "Unused" },
},
}),
execute: async () => ({ output: "", title: "", metadata: {} }),
})
}
// Wire up toolExecutor for DWS workflow models so that tool calls
// from the workflow service are executed via opencode's tool system
// and results sent back over the WebSocket.
if (language instanceof GitLabWorkflowLanguageModel) {
const workflowModel = language as GitLabWorkflowLanguageModel & {
sessionID?: string
sessionPreapprovedTools?: string[]
approvalHandler?: (approvalTools: { name: string; args: string }[]) => Promise<{ approved: boolean }>
}
workflowModel.sessionID = input.sessionID
workflowModel.systemPrompt = system.join("\n")
workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
const t = tools[toolName]
if (!t || !t.execute) {
return { result: "", error: `Unknown tool: ${toolName}` }
}
try {
const result = await t.execute!(JSON.parse(argsJson), {
toolCallId: _requestID,
messages: input.messages,
abortSignal: input.abort,
})
const output = typeof result === "string" ? result : (result?.output ?? JSON.stringify(result))
return {
result: output,
metadata: typeof result === "object" ? result?.metadata : undefined,
title: typeof result === "object" ? result?.title : undefined,
}
} catch (e: any) {
return { result: "", error: e.message ?? String(e) }
}
}
const ruleset = Permission.merge(input.agent.permission ?? [], input.permission ?? [])
workflowModel.sessionPreapprovedTools = Object.keys(tools).filter((name) => {
const match = ruleset.findLast((rule) => Wildcard.match(name, rule.permission))
return !match || match.action !== "ask"
})
const approvedToolsForSession = new Set<string>()
workflowModel.approvalHandler = Instance.bind(async (approvalTools) => {
const uniqueNames = [...new Set(approvalTools.map((t: { name: string }) => t.name))] as string[]
// Auto-approve tools that were already approved in this session
// (prevents infinite approval loops for server-side MCP tools)
if (uniqueNames.every((name) => approvedToolsForSession.has(name))) {
return { approved: true }
}
const id = PermissionID.ascending()
let reply: Permission.Reply | undefined
let unsub: (() => void) | undefined
try {
unsub = Bus.subscribe(Permission.Event.Replied, (evt) => {
if (evt.properties.requestID === id) reply = evt.properties.reply
})
const toolPatterns = approvalTools.map((t: { name: string; args: string }) => {
try {
const parsed = JSON.parse(t.args) as Record<string, unknown>
const title = (parsed?.title ?? parsed?.name ?? "") as string
return title ? `${t.name}: ${title}` : t.name
} catch {
return t.name
}
})
const uniquePatterns = [...new Set(toolPatterns)] as string[]
await perms.runPromise((svc) =>
svc.ask({
id,
sessionID: SessionID.make(input.sessionID),
permission: "workflow_tool_approval",
patterns: uniquePatterns,
metadata: { tools: approvalTools },
always: uniquePatterns,
ruleset: [],
}),
)
for (const name of uniqueNames) approvedToolsForSession.add(name)
workflowModel.sessionPreapprovedTools = [
...(workflowModel.sessionPreapprovedTools ?? []),
...uniqueNames,
]
return { approved: true }
} catch {
return { approved: false }
} finally {
unsub?.()
}
})
}
const tracer = cfg.experimental?.openTelemetry
? Option.getOrUndefined(yield* Effect.serviceOption(OtelTracer.OtelTracer))
: undefined
return streamText({
onError(error) {
l.error("stream error", {
error,
})
},
async experimental_repairToolCall(failed) {
const lower = failed.toolCall.toolName.toLowerCase()
if (lower !== failed.toolCall.toolName && tools[lower]) {
l.info("repairing tool call", {
tool: failed.toolCall.toolName,
repaired: lower,
})
return {
...failed.toolCall,
toolName: lower,
}
}
return {
...failed.toolCall,
input: JSON.stringify({
tool: failed.toolCall.toolName,
error: failed.error.message,
}),
toolName: "invalid",
}
},
temperature: params.temperature,
topP: params.topP,
topK: params.topK,
providerOptions: ProviderTransform.providerOptions(input.model, params.options),
activeTools: Object.keys(tools).filter((x) => x !== "invalid"),
tools,
toolChoice: input.toolChoice,
maxOutputTokens: params.maxOutputTokens,
abortSignal: input.abort,
headers: {
...(input.model.providerID.startsWith("opencode")
? {
"x-opencode-project": Instance.project.id,
"x-opencode-session": input.sessionID,
"x-opencode-request": input.user.id,
"x-opencode-client": Flag.OPENCODE_CLIENT,
}
: {
"x-session-affinity": input.sessionID,
...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),
"User-Agent": `opencode/${Installation.VERSION}`,
}),
...input.model.headers,
...headers,
},
maxRetries: input.retries ?? 0,
messages,
model: wrapLanguageModel({
model: language,
middleware: [
{
specificationVersion: "v3" as const,
async transformParams(args) {
if (args.type === "stream") {
// @ts-expect-error
args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)
}
return args.params
},
},
],
}),
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
functionId: "session.llm",
tracer,
metadata: {
userId: cfg.username ?? "unknown",
sessionId: input.sessionID,
},
},
})
}) })
const stream: Interface["stream"] = (input) => const [language, cfg, item, info] = yield* Effect.all(
Stream.scoped( [
Stream.unwrap( provider.getLanguage(input.model),
Effect.gen(function* () { config.get(),
const ctrl = yield* Effect.acquireRelease( provider.getProvider(input.model.providerID),
Effect.sync(() => new AbortController()), auth.get(input.model.providerID),
(ctrl) => Effect.sync(() => ctrl.abort()), ],
) { concurrency: "unbounded" },
)
const result = yield* run({ ...input, abort: ctrl.signal }) // TODO: move this to a proper hook
const isOpenaiOauth = item.id === "openai" && info?.type === "oauth"
return Stream.fromAsyncIterable(result.fullStream, (e) => const system: string[] = []
e instanceof Error ? e : new Error(String(e)), system.push(
) [
// use agent prompt otherwise provider prompt
...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
// any custom prompt passed into this call
...input.system,
// any custom prompt from last user message
...(input.user.system ? [input.user.system] : []),
]
.filter((x) => x)
.join("\n"),
)
const header = system[0]
yield* plugin.trigger(
"experimental.chat.system.transform",
{ sessionID: input.sessionID, model: input.model },
{ system },
)
// rejoin to maintain 2-part structure for caching if header unchanged
if (system.length > 2 && system[0] === header) {
const rest = system.slice(1)
system.length = 0
system.push(header, rest.join("\n"))
}
const variant =
!input.small && input.model.variants && input.user.model.variant
? input.model.variants[input.user.model.variant]
: {}
const base = input.small
? ProviderTransform.smallOptions(input.model)
: ProviderTransform.options({
model: input.model,
sessionID: input.sessionID,
providerOptions: item.options,
})
const options: Record<string, any> = pipe(
base,
mergeDeep(input.model.options),
mergeDeep(input.agent.options),
mergeDeep(variant),
)
if (isOpenaiOauth) {
options.instructions = system.join("\n")
}
const isWorkflow = language instanceof GitLabWorkflowLanguageModel
const messages = isOpenaiOauth
? input.messages
: isWorkflow
? input.messages
: [
...system.map(
(x): ModelMessage => ({
role: "system",
content: x,
}),
),
...input.messages,
]
const params = yield* plugin.trigger(
"chat.params",
{
sessionID: input.sessionID,
agent: input.agent.name,
model: input.model,
provider: item,
message: input.user,
},
{
temperature: input.model.capabilities.temperature
? (input.agent.temperature ?? ProviderTransform.temperature(input.model))
: undefined,
topP: input.agent.topP ?? ProviderTransform.topP(input.model),
topK: ProviderTransform.topK(input.model),
maxOutputTokens: ProviderTransform.maxOutputTokens(input.model),
options,
},
)
const { headers } = yield* plugin.trigger(
"chat.headers",
{
sessionID: input.sessionID,
agent: input.agent.name,
model: input.model,
provider: item,
message: input.user,
},
{
headers: {},
},
)
const tools = resolveTools(input)
// LiteLLM and some Anthropic proxies require the tools parameter to be present
// when message history contains tool calls, even if no tools are being used.
// Add a dummy tool that is never called to satisfy this validation.
// This is enabled for:
// 1. Providers with "litellm" in their ID or API ID (auto-detected)
// 2. Providers with explicit "litellmProxy: true" option (opt-in for custom gateways)
const isLiteLLMProxy =
item.options?.["litellmProxy"] === true ||
input.model.providerID.toLowerCase().includes("litellm") ||
input.model.api.id.toLowerCase().includes("litellm")
// LiteLLM/Bedrock rejects requests where the message history contains tool
// calls but no tools param is present. When there are no active tools (e.g.
// during compaction), inject a stub tool to satisfy the validation requirement.
// The stub description explicitly tells the model not to call it.
if (
(isLiteLLMProxy || input.model.providerID.includes("github-copilot")) &&
Object.keys(tools).length === 0 &&
hasToolCalls(input.messages)
) {
tools["_noop"] = tool({
description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",
inputSchema: jsonSchema({
type: "object",
properties: {
reason: { type: "string", description: "Unused" },
},
}),
execute: async () => ({ output: "", title: "", metadata: {} }),
})
}
// Wire up toolExecutor for DWS workflow models so that tool calls
// from the workflow service are executed via opencode's tool system
// and results sent back over the WebSocket.
if (language instanceof GitLabWorkflowLanguageModel) {
const workflowModel = language as GitLabWorkflowLanguageModel & {
sessionID?: string
sessionPreapprovedTools?: string[]
approvalHandler?: (approvalTools: { name: string; args: string }[]) => Promise<{ approved: boolean }>
}
workflowModel.sessionID = input.sessionID
workflowModel.systemPrompt = system.join("\n")
workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
const t = tools[toolName]
if (!t || !t.execute) {
return { result: "", error: `Unknown tool: ${toolName}` }
}
try {
const result = await t.execute!(JSON.parse(argsJson), {
toolCallId: _requestID,
messages: input.messages,
abortSignal: input.abort,
})
const output = typeof result === "string" ? result : (result?.output ?? JSON.stringify(result))
return {
result: output,
metadata: typeof result === "object" ? result?.metadata : undefined,
title: typeof result === "object" ? result?.title : undefined,
}
} catch (e: any) {
return { result: "", error: e.message ?? String(e) }
}
}
const ruleset = Permission.merge(input.agent.permission ?? [], input.permission ?? [])
workflowModel.sessionPreapprovedTools = Object.keys(tools).filter((name) => {
const match = ruleset.findLast((rule) => Wildcard.match(name, rule.permission))
return !match || match.action !== "ask"
})
const bridge = yield* EffectBridge.make()
const approvedToolsForSession = new Set<string>()
workflowModel.approvalHandler = Instance.bind(async (approvalTools) => {
const uniqueNames = [...new Set(approvalTools.map((t: { name: string }) => t.name))] as string[]
// Auto-approve tools that were already approved in this session
// (prevents infinite approval loops for server-side MCP tools)
if (uniqueNames.every((name) => approvedToolsForSession.has(name))) {
return { approved: true }
}
const id = PermissionID.ascending()
let reply: Permission.Reply | undefined
let unsub: (() => void) | undefined
try {
unsub = Bus.subscribe(Permission.Event.Replied, (evt) => {
if (evt.properties.requestID === id) reply = evt.properties.reply
})
const toolPatterns = approvalTools.map((t: { name: string; args: string }) => {
try {
const parsed = JSON.parse(t.args) as Record<string, unknown>
const title = (parsed?.title ?? parsed?.name ?? "") as string
return title ? `${t.name}: ${title}` : t.name
} catch {
return t.name
}
})
const uniquePatterns = [...new Set(toolPatterns)] as string[]
await bridge.promise(
perm.ask({
id,
sessionID: SessionID.make(input.sessionID),
permission: "workflow_tool_approval",
patterns: uniquePatterns,
metadata: { tools: approvalTools },
always: uniquePatterns,
ruleset: [],
}),
)
for (const name of uniqueNames) approvedToolsForSession.add(name)
workflowModel.sessionPreapprovedTools = [...(workflowModel.sessionPreapprovedTools ?? []), ...uniqueNames]
return { approved: true }
} catch {
return { approved: false }
} finally {
unsub?.()
}
})
}
const tracer = cfg.experimental?.openTelemetry
? Option.getOrUndefined(yield* Effect.serviceOption(OtelTracer.OtelTracer))
: undefined
return streamText({
onError(error) {
l.error("stream error", {
error,
})
},
async experimental_repairToolCall(failed) {
const lower = failed.toolCall.toolName.toLowerCase()
if (lower !== failed.toolCall.toolName && tools[lower]) {
l.info("repairing tool call", {
tool: failed.toolCall.toolName,
repaired: lower,
})
return {
...failed.toolCall,
toolName: lower,
}
}
return {
...failed.toolCall,
input: JSON.stringify({
tool: failed.toolCall.toolName,
error: failed.error.message,
}), }),
), toolName: "invalid",
) }
},
temperature: params.temperature,
topP: params.topP,
topK: params.topK,
providerOptions: ProviderTransform.providerOptions(input.model, params.options),
activeTools: Object.keys(tools).filter((x) => x !== "invalid"),
tools,
toolChoice: input.toolChoice,
maxOutputTokens: params.maxOutputTokens,
abortSignal: input.abort,
headers: {
...(input.model.providerID.startsWith("opencode")
? {
"x-opencode-project": Instance.project.id,
"x-opencode-session": input.sessionID,
"x-opencode-request": input.user.id,
"x-opencode-client": Flag.OPENCODE_CLIENT,
}
: {
"x-session-affinity": input.sessionID,
...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),
"User-Agent": `opencode/${Installation.VERSION}`,
}),
...input.model.headers,
...headers,
},
maxRetries: input.retries ?? 0,
messages,
model: wrapLanguageModel({
model: language,
middleware: [
{
specificationVersion: "v3" as const,
async transformParams(args) {
if (args.type === "stream") {
// @ts-expect-error
args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)
}
return args.params
},
},
],
}),
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
functionId: "session.llm",
tracer,
metadata: {
userId: cfg.username ?? "unknown",
sessionId: input.sessionID,
},
},
})
})
return Service.of({ stream }) const stream: Interface["stream"] = (input) =>
}), Stream.scoped(
) Stream.unwrap(
Effect.gen(function* () {
const ctrl = yield* Effect.acquireRelease(
Effect.sync(() => new AbortController()),
(ctrl) => Effect.sync(() => ctrl.abort()),
)
const result = yield* run({ ...input, abort: ctrl.signal })
return Stream.fromAsyncIterable(result.fullStream, (e) => (e instanceof Error ? e : new Error(String(e))))
}),
),
)
return Service.of({ stream })
}),
)
export const layer = live.pipe(Layer.provide(Permission.defaultLayer))
export const defaultLayer = Layer.suspend(() => export const defaultLayer = Layer.suspend(() =>
layer.pipe( layer.pipe(

View file

@ -48,6 +48,7 @@ import { EffectLogger } from "@/effect/logger"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { TaskTool, type TaskPromptOps } from "@/tool/task" import { TaskTool, type TaskPromptOps } from "@/tool/task"
import { SessionRunState } from "./run-state" import { SessionRunState } from "./run-state"
import { EffectBridge } from "@/effect/bridge"
// @ts-ignore // @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false globalThis.AI_SDK_LOG_WARNINGS = false
@ -105,11 +106,7 @@ export namespace SessionPrompt {
const sys = yield* SystemPrompt.Service const sys = yield* SystemPrompt.Service
const llm = yield* LLM.Service const llm = yield* LLM.Service
const runner = Effect.fn("SessionPrompt.runner")(function* () { const runner = Effect.fn("SessionPrompt.runner")(function* () {
const ctx = yield* Effect.context() return yield* EffectBridge.make()
return {
promise: <A, E>(effect: Effect.Effect<A, E>) => Effect.runPromiseWith(ctx)(effect),
fork: <A, E>(effect: Effect.Effect<A, E>) => Effect.runForkWith(ctx)(effect),
}
}) })
const ops = Effect.fn("SessionPrompt.ops")(function* () { const ops = Effect.fn("SessionPrompt.ops")(function* () {
const run = yield* runner() const run = yield* runner()

View file

@ -1,6 +1,7 @@
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { Context, Effect, Layer, Logger } from "effect" import { Context, Effect, Layer, Logger } from "effect"
import { AppRuntime } from "../../src/effect/app-runtime" import { AppRuntime } from "../../src/effect/app-runtime"
import { EffectBridge } from "../../src/effect/bridge"
import { InstanceRef } from "../../src/effect/instance-ref" import { InstanceRef } from "../../src/effect/instance-ref"
import { EffectLogger } from "../../src/effect/logger" import { EffectLogger } from "../../src/effect/logger"
import { makeRuntime } from "../../src/effect/run-service" import { makeRuntime } from "../../src/effect/run-service"
@ -59,3 +60,33 @@ test("AppRuntime attaches InstanceRef from ALS", async () => {
expect(dir).toBe(tmp.path) expect(dir).toBe(tmp.path)
}) })
test("EffectBridge preserves logger and instance context across async boundaries", async () => {
await using tmp = await tmpdir({ git: true })
const result = await Instance.provide({
directory: tmp.path,
fn: () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const bridge = yield* EffectBridge.make()
return yield* Effect.promise(() =>
Promise.resolve().then(() =>
bridge.promise(
Effect.gen(function* () {
return {
directory: (yield* InstanceRef)?.directory,
...check(yield* Effect.service(Logger.CurrentLoggers)),
}
}),
),
),
)
}),
),
})
expect(result.directory).toBe(tmp.path)
expect(result.effectLogger).toBe(true)
expect(result.defaultLogger).toBe(false)
})

View file

@ -17,10 +17,11 @@
"dist" "dist"
], ],
"scripts": { "scripts": {
"typecheck": "tsc --noEmit", "typecheck": "tsgo --noEmit",
"build": "tsc" "build": "tsc"
}, },
"devDependencies": { "devDependencies": {
"@typescript/native-preview": "catalog:",
"typescript": "catalog:" "typescript": "catalog:"
}, },
"dependencies": { "dependencies": {