diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts index 612528f3ba8..b6417ee9837 100644 --- a/packages/core/src/location-layer.ts +++ b/packages/core/src/location-layer.ts @@ -111,6 +111,9 @@ export class LocationServiceMap extends LayerMap.Service()(" LLMClient.layer.pipe(Layer.provide(RequestExecutor.defaultLayer)), FetchHttpClient.layer, ToolOutputStore.defaultCleanupLayer, - ApplicationTools.layer, ], }) {} + +export namespace LocationServiceMap { + export const defaultLayer = LocationServiceMap.layer.pipe(Layer.provide(ApplicationTools.layer)) +} diff --git a/packages/core/src/public/opencode.ts b/packages/core/src/public/opencode.ts index f65dd11af95..90f117315f3 100644 --- a/packages/core/src/public/opencode.ts +++ b/packages/core/src/public/opencode.ts @@ -15,6 +15,11 @@ import { ApplicationTools } from "../tool/application-tools" import { Session } from "./session" import { Tool } from "./tool" +export interface HostConfig { + /** Tool names that this host cannot service. They are omitted from prompts and rejected at execution. */ + readonly unavailableTools?: ReadonlyArray +} + export interface Interface { readonly sessions: Session.Interface readonly tools: Tool.Service @@ -32,7 +37,6 @@ class SessionModelValidation extends Context.Service< } >()("@opencode/public/OpenCode/SessionModelValidation") {} -const LocationServicesLayer = LocationServiceMap.layer const SessionModelValidationLayer = Layer.effect( SessionModelValidation, Effect.gen(function* () { @@ -77,54 +81,59 @@ const SessionsLayer = Layer.merge( Layer.orDie, ), SessionModelValidationLayer, -).pipe(Layer.provide(LocationServicesLayer)) -const ApplicationToolsLayer = ApplicationTools.layer - +) // TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence. -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const sessions = yield* SessionV2.Service - const tools = yield* ApplicationTools.Service - const validation = yield* SessionModelValidation - return Service.of({ - tools: { attach: tools.attach }, - sessions: { - create: (input) => - sessions.create({ - id: input.id, - agent: input.agent, - model: input.model, - location: input.location, +export const layerWithHostConfig = (config: HostConfig) => { + const applicationTools = ApplicationTools.layerWithUnavailable(new Set(config.unavailableTools ?? [])) + const locations = LocationServiceMap.layer.pipe(Layer.provide(applicationTools)) + const sessions = SessionsLayer.pipe(Layer.provide(locations)) + return Layer.effect( + Service, + Effect.gen(function* () { + const sessions = yield* SessionV2.Service + const tools = yield* ApplicationTools.Service + const validation = yield* SessionModelValidation + return Service.of({ + tools: { attach: tools.attach }, + sessions: { + create: (input) => + sessions.create({ + id: input.id, + agent: input.agent, + model: input.model, + location: input.location, + }), + get: sessions.get, + list: sessions.list, + switchModel: Effect.fn("OpenCode.sessions.switchModel")(function* (input) { + const session = yield* sessions.get(input.sessionID) + yield* validation.validate({ ...input, location: session.location }) + yield* sessions.switchModel(input) }), - get: sessions.get, - list: sessions.list, - switchModel: Effect.fn("OpenCode.sessions.switchModel")(function* (input) { - const session = yield* sessions.get(input.sessionID) - yield* validation.validate({ ...input, location: session.location }) - yield* sessions.switchModel(input) - }), - interrupt: sessions.interrupt, - prompt: (input) => - sessions.prompt({ - id: input.id, - sessionID: input.sessionID, - prompt: input.prompt, - delivery: input.delivery, - }), - messages: (input) => - sessions.messages({ - sessionID: input.sessionID, - limit: input.limit, - order: input.order, - cursor: input.cursor, - }), - message: (input) => sessions.message({ sessionID: input.sessionID, messageID: input.messageID }), - context: sessions.context, - events: (input) => sessions.events({ sessionID: input.sessionID, after: input.after }), - }, - }) - }), -).pipe(Layer.provide(Layer.merge(ApplicationToolsLayer, SessionsLayer))) + interrupt: sessions.interrupt, + prompt: (input) => + sessions.prompt({ + id: input.id, + sessionID: input.sessionID, + prompt: input.prompt, + delivery: input.delivery, + }), + messages: (input) => + sessions.messages({ + sessionID: input.sessionID, + limit: input.limit, + order: input.order, + cursor: input.cursor, + }), + message: (input) => sessions.message({ sessionID: input.sessionID, messageID: input.messageID }), + context: sessions.context, + events: (input) => sessions.events({ sessionID: input.sessionID, after: input.after }), + }, + }) + }), + ).pipe(Layer.provide(Layer.merge(applicationTools, sessions))) +} + +export const layer = layerWithHostConfig({}) // TODO: Add OpenCode.create(...) as the Promise facade over the same native API semantics. diff --git a/packages/core/src/tool/application-tools.ts b/packages/core/src/tool/application-tools.ts index 97111e9c335..c00cd121e1b 100644 --- a/packages/core/src/tool/application-tools.ts +++ b/packages/core/src/tool/application-tools.ts @@ -16,36 +16,41 @@ type Editor = { export interface Interface { readonly attach: (tools: Readonly>) => Effect.Effect readonly entries: () => ReadonlyMap + readonly isAvailable: (name: string) => boolean } export class Service extends Context.Service()("@opencode/ApplicationTools") {} enableMapSet() -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const state = State.create({ - initial: () => ({ entries: new Map() }), - editor: (draft) => ({ - set: (name, tool) => { - draft.entries.set( - name, - castDraft(tool) as typeof draft.entries extends Map ? Value : never, - ) - }, - }), - }) +export const layerWithUnavailable = (unavailable: ReadonlySet) => + Layer.effect( + Service, + Effect.gen(function* () { + const state = State.create({ + initial: () => ({ entries: new Map() }), + editor: (draft) => ({ + set: (name, tool) => { + draft.entries.set( + name, + castDraft(tool) as typeof draft.entries extends Map ? Value : never, + ) + }, + }), + }) - return Service.of({ - attach: Effect.fn("ApplicationTools.attach")(function* (tools) { - const entries = Object.entries(tools) - const transform = yield* state.transform() - yield* transform((editor) => { - for (const [name, tool] of entries) editor.set(name, tool) - }) - }), - entries: () => state.get().entries, - }) - }), -) + return Service.of({ + attach: Effect.fn("ApplicationTools.attach")(function* (tools) { + const entries = Object.entries(tools) + const transform = yield* state.transform() + yield* transform((editor) => { + for (const [name, tool] of entries) editor.set(name, tool) + }) + }), + entries: () => state.get().entries, + isAvailable: (name) => !unavailable.has(name), + }) + }), + ) + +export const layer = layerWithUnavailable(new Set()) diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index 106b2448411..8d01e2ee95b 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -115,15 +115,20 @@ export const layer = Layer.effect( }) const definitions = Effect.fn("ToolRegistry.definitions")(function* () { - const tools = new Map(Array.from(state.get().entries, ([name, entry]) => [name, entry.tool] as const)) + const tools = new Map( + Array.from(state.get().entries, ([name, entry]) => [name, entry.tool] as const).filter(([name]) => + applications.isAvailable(name), + ), + ) // Location tools own their names. Application tools fill otherwise-unclaimed names. for (const [name, tool] of applications.entries()) { - if (!tools.has(name)) tools.set(name, tool.definition) + if (applications.isAvailable(name) && !tools.has(name)) tools.set(name, tool.definition) } return Tool.toDefinitions(Object.fromEntries(tools)) }) const entry = (name: string): Entry | undefined => { + if (!applications.isAvailable(name)) return const local = state.get().entries.get(name) if (local !== undefined) return local const tool = applications.entries().get(name) diff --git a/packages/core/test/application-tools.test.ts b/packages/core/test/application-tools.test.ts index 21ea96dec79..88056c9520e 100644 --- a/packages/core/test/application-tools.test.ts +++ b/packages/core/test/application-tools.test.ts @@ -18,6 +18,13 @@ const registry = ToolRegistry.layer.pipe( Layer.provide(ToolOutputStore.defaultLayer), ) const it = testEffect(Layer.mergeAll(applications, registry)) +const unavailableApplications = ApplicationTools.layerWithUnavailable(new Set(["question"])) +const unavailableRegistry = ToolRegistry.layer.pipe( + Layer.provide(permission), + Layer.provide(unavailableApplications), + Layer.provide(ToolOutputStore.defaultLayer), +) +const unavailableIt = testEffect(Layer.mergeAll(unavailableApplications, unavailableRegistry)) const sessionID = SessionV2.ID.make("ses_application_tool") const contextual = (contexts: Tool.Context[]) => @@ -186,4 +193,20 @@ describe("ApplicationTools", () => { expect(applicationContexts).toEqual([]) }), ) + + unavailableIt.effect("omits and rejects tools the host cannot service", () => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const transform = yield* registry.transform() + yield* transform((editor) => editor.set("question", { tool: contextual([]).definition })) + + expect(yield* registry.definitions()).toEqual([]) + expect( + yield* registry.execute({ + sessionID, + call: { type: "tool-call", id: "call-stale-question", name: "question", input: { query: "Continue?" } }, + }), + ).toEqual({ type: "error", value: "Unknown tool: question" }) + }), + ) }) diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index e42d9859c51..0013227a7c9 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -27,6 +27,7 @@ const it = testEffect( Layer.merge( applicationTools, LocationServiceMap.layer.pipe( + Layer.provide(applicationTools), Layer.provide( Layer.mergeAll( Project.defaultLayer, diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 3631935b027..76dcc95e4d8 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -1867,6 +1867,53 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("settles a stale unavailable question call and continues without a pending request", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const question = yield* QuestionV2.Service + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Do not wait for a question" }), resume: false }) + + requests.length = 0 + responses = [ + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolCall({ id: "call-stale-question", name: "question", input: { questions: [] } }), + LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), + LLMEvent.finish({ reason: "tool-calls" }), + ], + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.textStart({ id: "text-after-question" }), + LLMEvent.textDelta({ id: "text-after-question", text: "Continued" }), + LLMEvent.textEnd({ id: "text-after-question" }), + LLMEvent.stepFinish({ index: 0, reason: "stop" }), + LLMEvent.finish({ reason: "stop" }), + ], + ] + + yield* session.resume(sessionID) + + expect(requests).toHaveLength(2) + expect(requests[0]?.tools.map((tool) => tool.name)).not.toContain("question") + expect(yield* question.list()).toEqual([]) + expect(yield* session.context(sessionID)).toMatchObject([ + { type: "user", text: "Do not wait for a question" }, + { + type: "assistant", + content: [ + { + type: "tool", + id: "call-stale-question", + state: { status: "error", error: { message: "Unknown tool: question" } }, + }, + ], + }, + { type: "assistant", content: [{ type: "text", id: "text-after-question", text: "Continued" }] }, + ]) + }), + ) + it.effect("reloads a model switch before a tool-driven continuation turn", () => Effect.gen(function* () { yield* setup diff --git a/packages/opencode/src/cli/cmd/debug/file.ts b/packages/opencode/src/cli/cmd/debug/file.ts index 173264671b6..24da52f53b5 100644 --- a/packages/opencode/src/cli/cmd/debug/file.ts +++ b/packages/opencode/src/cli/cmd/debug/file.ts @@ -10,7 +10,7 @@ import { cmd } from "../cmd" const filesystem = (effect: Effect.Effect) => effect.pipe( Effect.provide(LocationServiceMap.get({ directory: AbsolutePath.make(process.cwd()) })), - Effect.provide(LocationServiceMap.layer), + Effect.provide(LocationServiceMap.defaultLayer), ) const FileSearchCommand = effectCmd({ diff --git a/packages/opencode/src/cli/cmd/debug/v2.ts b/packages/opencode/src/cli/cmd/debug/v2.ts index aab7018982e..af0ba7337ce 100644 --- a/packages/opencode/src/cli/cmd/debug/v2.ts +++ b/packages/opencode/src/cli/cmd/debug/v2.ts @@ -41,6 +41,6 @@ export const V2Command = effectCmd({ directory: AbsolutePath.make(process.cwd()), }), ), - Effect.provide(LocationServiceMap.layer), + Effect.provide(LocationServiceMap.defaultLayer), ), }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts index 331fc789e30..fd69d3a4045 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts @@ -91,4 +91,4 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl .handle("content", content) .handle("status", status) }), -).pipe(Layer.provide(LocationServiceMap.layer)) +).pipe(Layer.provide(LocationServiceMap.defaultLayer)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts index d8f9e55a06e..0366568ce0a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts @@ -150,7 +150,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler .handle("remove", remove) .handle("connectToken", connectToken) }), -).pipe(Layer.provide(LocationServiceMap.layer)) +).pipe(Layer.provide(LocationServiceMap.defaultLayer)) export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-connect", (handlers) => Effect.gen(function* () { @@ -255,4 +255,4 @@ export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-conne }), ) }), -).pipe(Layer.provide(LocationServiceMap.layer)) +).pipe(Layer.provide(LocationServiceMap.defaultLayer)) diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index be1444a66b7..e3f155b9292 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -25,7 +25,7 @@ import { SessionStore } from "@opencode-ai/core/session/store" const routedSessions = SessionV2.layer.pipe( Layer.provide(SessionProjector.layer), Layer.provide(SessionExecutionLocal.layer), - Layer.provide(LocationServiceMap.layer), + Layer.provide(LocationServiceMap.defaultLayer), Layer.provide(SessionStore.layer), Layer.provide(EventV2.layer), Layer.provide(Database.defaultLayer), @@ -51,7 +51,7 @@ export const v2Handlers = Layer.mergeAll( sessionQuestionHandlers, ).pipe( Layer.provide(v2LocationLayer), - Layer.provide(LocationServiceMap.layer), + Layer.provide(LocationServiceMap.defaultLayer), Layer.provide(PermissionSaved.layer), Layer.provide(routedSessions), ) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index c92f5b26ff2..a5a75c639eb 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -22,7 +22,7 @@ export function createRoutes(password?: string) { ? ServerAuth.Config.layer({ username: "opencode", password: Option.some(password) }) : ServerAuth.Config.defaultLayer, ), - Layer.provide(LocationServiceMap.layer), + Layer.provide(LocationServiceMap.defaultLayer), Layer.provide(PermissionSaved.layer), Layer.provide(SessionV2.defaultLayer), Layer.provide(Database.defaultLayer),