diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 23cbfa049d..3da43acd8c 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -315,10 +315,25 @@ const layer = Layer.effect( } yield* publish(event) if (event.type !== "tool-call" || event.providerExecuted) return - if (!toolMaterialization || !advertisedTools.has(event.name)) { + if (!toolMaterialization) { yield* serialized( publisher.failUnsettledTools({ type: "tool.execution", + message: "Tools are disabled after the maximum agent steps", + }), + ) + return + } + // A request hook hid this registered tool from the current request. Fail only + // this call durably and continue so the model can react, instead of executing + // a tool that was not advertised. Unregistered tools flow through settle, which + // durably fails them as unknown. + if (!advertisedTools.has(event.name) && availableTools.has(event.name)) { + needsContinuation = true + yield* publish( + LLMEvent.toolError({ + id: event.id, + name: event.name, message: `Tool is not available for this request: ${event.name}`, }), ) diff --git a/packages/core/test/lib/tool.ts b/packages/core/test/lib/tool.ts index 3692e5476e..c09d1e6296 100644 --- a/packages/core/test/lib/tool.ts +++ b/packages/core/test/lib/tool.ts @@ -6,6 +6,7 @@ import { Tool } from "@opencode-ai/core/tool/tool" import { Tools } from "@opencode-ai/core/tool/tools" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { Effect, type Scope } from "effect" +import { host } from "../plugin/host" export const toolIdentity = { agent: AgentV2.ID.make("build"), @@ -48,7 +49,7 @@ export const registerToolPlugin = (plugin: { }): Effect.Effect => Effect.gen(function* () { const tools = yield* Tools.Service - const context: Pick = { + const context = host({ tool: { transform: (callback) => Effect.gen(function* () { @@ -71,8 +72,8 @@ export const registerToolPlugin = (plugin: { }), hook: () => Effect.die("registerToolPlugin does not support tool hooks"), }, - } - yield* plugin.effect(context as PluginContext) + }) + yield* plugin.effect(context) }) export const settleTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput, model = testModel) => diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 6b7ba5654c..c538d1151f 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -290,6 +290,7 @@ describe("LocationServiceMap", () => { "edit", "glob", "grep", + "patch", "question", "read", "shell", @@ -307,6 +308,7 @@ describe("LocationServiceMap", () => { "edit", "glob", "grep", + "patch", "question", "read", "shell", diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index c5dc96c384..659ec40f86 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -83,7 +83,9 @@ export function host(overrides: Overrides = {}): PluginContext { prompt: () => Effect.die("unused session.prompt"), command: () => Effect.die("unused session.command"), interrupt: () => Effect.die("unused session.interrupt"), - hook: () => Effect.die("unused session.hook"), + // Plugins register session hooks during setup, so a bare host accepts the + // registration; the callback only runs when a test triggers the request pipeline. + hook: () => Effect.succeed({ dispose: Effect.void }), }, } } diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index 9f7bf8c7b9..92dc0e833b 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -107,6 +107,11 @@ const withSubagent = (location: Location.Ref) => const locations = yield* LocationServiceMap.Service yield* AgentV2.Service.use((agents) => agents.transform((draft) => { + // The caller identity used by executeTool; subagent permission asserts against it. + draft.update(toolIdentity.agent, (agent) => { + agent.mode = "primary" + agent.permissions.push({ action: "*", resource: "*", effect: "allow" }) + }) draft.update(AgentV2.ID.make("reviewer"), (agent) => { agent.mode = "subagent" agent.model = childModel