Просмотр исходного кода

fix(core): omit unavailable host tools

Kit Langton 2 месяцев назад
Родитель
Сommit
7d79daa2ca

+ 4 - 1
packages/core/src/location-layer.ts

@@ -111,6 +111,9 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
     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))
+}

+ 57 - 48
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<string>
+}
+
 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,
-          }),
-        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,
+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)
           }),
-        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.

+ 32 - 27
packages/core/src/tool/application-tools.ts

@@ -16,36 +16,41 @@ type Editor = {
 export interface Interface {
   readonly attach: (tools: Readonly<Record<string, NativeTool.Any>>) => Effect.Effect<void, never, Scope.Scope>
   readonly entries: () => ReadonlyMap<string, NativeTool.Any>
+  readonly isAvailable: (name: string) => boolean
 }
 
 export class Service extends Context.Service<Service, Interface>()("@opencode/ApplicationTools") {}
 
 enableMapSet()
 
-export const layer = Layer.effect(
-  Service,
-  Effect.gen(function* () {
-    const state = State.create<Data, Editor>({
-      initial: () => ({ entries: new Map() }),
-      editor: (draft) => ({
-        set: (name, tool) => {
-          draft.entries.set(
-            name,
-            castDraft(tool) as typeof draft.entries extends Map<string, infer Value> ? 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,
-    })
-  }),
-)
+export const layerWithUnavailable = (unavailable: ReadonlySet<string>) =>
+  Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const state = State.create<Data, Editor>({
+        initial: () => ({ entries: new Map() }),
+        editor: (draft) => ({
+          set: (name, tool) => {
+            draft.entries.set(
+              name,
+              castDraft(tool) as typeof draft.entries extends Map<string, infer Value> ? 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,
+        isAvailable: (name) => !unavailable.has(name),
+      })
+    }),
+  )
+
+export const layer = layerWithUnavailable(new Set())

+ 7 - 2
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)

+ 23 - 0
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" })
+    }),
+  )
 })

+ 1 - 0
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,

+ 47 - 0
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

+ 1 - 1
packages/opencode/src/cli/cmd/debug/file.ts

@@ -10,7 +10,7 @@ import { cmd } from "../cmd"
 const filesystem = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
   effect.pipe(
     Effect.provide(LocationServiceMap.get({ directory: AbsolutePath.make(process.cwd()) })),
-    Effect.provide(LocationServiceMap.layer),
+    Effect.provide(LocationServiceMap.defaultLayer),
   )
 
 const FileSearchCommand = effectCmd({

+ 1 - 1
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),
     ),
 })

+ 1 - 1
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))

+ 2 - 2
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))

+ 2 - 2
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),
 )

+ 1 - 1
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),