From ae0c106ed40b87eecbc108dcee6a26dd61bc5cdf Mon Sep 17 00:00:00 2001 From: MoerAI Date: Mon, 18 May 2026 19:22:33 +0900 Subject: [PATCH 1/3] fix(shared,delegate-task,claude-code-agent-loader): guard model parsers against non-string input (fixes #4145) After the 4.2.0 unified-dispatch refactor (a42f894f / df198d8b / fee515c5 / 989ab717 / dd3fecaf / 1bbe065c / 12bd6580), at least one caller in the new prompt-async-gate path forwards a FallbackModelObject (or some other non-string shape) into parsers that statically claim 'model: string'. The downstream .trim() call then throws 'model.trim is not a function', which rejects the session.processor promise and surfaces as 'Aborted process' + UI 'interrupted'. The issue (#4145) reports this aborts 90% of subagent dispatches across every provider on 4.2.0 + opencode 1.15.4. This patch adds a 'typeof x !== "string"' runtime guard at the four parser entrypoints called from the dispatch path: - src/shared/fallback-chain-from-models.ts :: parseVariantFromModel, parseFallbackModelEntry - src/tools/delegate-task/model-string-parser.ts :: parseVariantFromModelID, parseModelString - src/shared/model-string-parser.ts (duplicate file with same API) :: parseVariantFromModelID, parseModelString - src/features/claude-code-agent-loader/claude-model-mapper.ts :: mapClaudeModelString Each parser now returns undefined / { modelID: "" } for non-string input instead of throwing. This unblocks subagent dispatch and leaves the underlying caller bug for a follow-up. Regression coverage: three new tests in src/shared/fallback-chain-from-models.test.ts pin the non-string behavior (object, null/undefined, number). Existing 38 tests still pass. Total: 41/41 green, typecheck clean. --- .../claude-model-mapper.ts | 1 + src/shared/fallback-chain-from-models.test.ts | 62 +++++++++++++++++++ src/shared/fallback-chain-from-models.ts | 4 ++ src/shared/model-string-parser.ts | 4 ++ .../delegate-task/model-string-parser.ts | 4 ++ 5 files changed, 75 insertions(+) diff --git a/src/features/claude-code-agent-loader/claude-model-mapper.ts b/src/features/claude-code-agent-loader/claude-model-mapper.ts index 7737de5f7..f0c0f9a86 100644 --- a/src/features/claude-code-agent-loader/claude-model-mapper.ts +++ b/src/features/claude-code-agent-loader/claude-model-mapper.ts @@ -11,6 +11,7 @@ const CLAUDE_CODE_ALIAS_MAP = new Map([ function mapClaudeModelString(model: string | undefined): string | undefined { if (!model) return undefined + if (typeof model !== "string") return undefined const trimmed = model.trim() if (trimmed.length === 0) return undefined diff --git a/src/shared/fallback-chain-from-models.test.ts b/src/shared/fallback-chain-from-models.test.ts index 16690d12c..94c6851ee 100644 --- a/src/shared/fallback-chain-from-models.test.ts +++ b/src/shared/fallback-chain-from-models.test.ts @@ -395,3 +395,65 @@ describe("findMostSpecificFallbackEntry", () => { }) }) }) + +// Regression: type-guard against non-string model field (issue #4145). +// Crash signature was `model.trim is not a function` aborting session.processor +// for every provider when a caller forwarded a FallbackModelObject where a +// plain string was expected. +describe("parseFallbackModelEntry: non-string input (issue #4145)", () => { + test("returns undefined when caller forwards an object instead of a string", () => { + //#given + const wrong = { model: "anthropic/claude-opus-4-7", variant: "high" } as unknown as string + + //#when + const parsed = parseFallbackModelEntry(wrong, "anthropic") + + //#then: must not throw, must reject the malformed entry + expect(parsed).toBeUndefined() + }) + + test("returns undefined for null and undefined", () => { + //#given + const nullInput = null as unknown as string + const undefinedInput = undefined as unknown as string + + //#when / #then + expect(parseFallbackModelEntry(nullInput, "anthropic")).toBeUndefined() + expect(parseFallbackModelEntry(undefinedInput, "anthropic")).toBeUndefined() + }) + + test("parseFallbackModelObjectEntry returns undefined when nested model field is non-string", () => { + //#given: FallbackModelObject whose .model was somehow forwarded as a nested + //object instead of a flat string (issue #4145 reproduction). + const malformedObject = { + model: ({ model: "claude-opus-4-7" } as unknown) as string, + variant: "high", + } + + //#when + const parsed = parseFallbackModelObjectEntry(malformedObject, "anthropic") + + //#then: must not throw, must reject the malformed entry + expect(parsed).toBeUndefined() + }) + + test("buildFallbackChainFromModels skips entries whose model is a number", () => { + //#given + const fallbackModels = [ + "openai/gpt-5.5", + (42 as unknown) as string, + ] + + //#when + const chain = buildFallbackChainFromModels(fallbackModels, "openai") + + //#then: only the valid string survives, the number is dropped without crashing + expect(chain).toEqual([ + { + providers: ["openai"], + model: "gpt-5.5", + variant: undefined, + }, + ]) + }) +}) diff --git a/src/shared/fallback-chain-from-models.ts b/src/shared/fallback-chain-from-models.ts index 12e615d76..248250e22 100644 --- a/src/shared/fallback-chain-from-models.ts +++ b/src/shared/fallback-chain-from-models.ts @@ -4,6 +4,9 @@ import { normalizeFallbackModels } from "./model-resolver" import { KNOWN_VARIANTS } from "./known-variants" function parseVariantFromModel(rawModel: string): { modelID: string; variant?: string } { + if (typeof rawModel !== "string") { + return { modelID: "" } + } const trimmedModel = rawModel.trim() if (!trimmedModel) { return { modelID: "" } @@ -33,6 +36,7 @@ export function parseFallbackModelEntry( contextProviderID: string | undefined, defaultProviderID = "opencode", ): FallbackEntry | undefined { + if (typeof model !== "string") return undefined const trimmed = model.trim() if (!trimmed) return undefined diff --git a/src/shared/model-string-parser.ts b/src/shared/model-string-parser.ts index 220bbd880..b10cd989d 100644 --- a/src/shared/model-string-parser.ts +++ b/src/shared/model-string-parser.ts @@ -11,6 +11,9 @@ const KNOWN_VARIANTS = new Set([ ]) export function parseVariantFromModelID(rawModelID: string): { modelID: string; variant?: string } { + if (typeof rawModelID !== "string") { + return { modelID: "" } + } const trimmedModelID = rawModelID.trim() if (!trimmedModelID) { return { modelID: "" } @@ -38,6 +41,7 @@ export function parseVariantFromModelID(rawModelID: string): { modelID: string; export function parseModelString( model: string, ): { providerID: string; modelID: string; variant?: string } | undefined { + if (typeof model !== "string") return undefined const trimmedModel = model.trim() if (!trimmedModel) return undefined diff --git a/src/tools/delegate-task/model-string-parser.ts b/src/tools/delegate-task/model-string-parser.ts index 820bb3cc3..a932ccd50 100644 --- a/src/tools/delegate-task/model-string-parser.ts +++ b/src/tools/delegate-task/model-string-parser.ts @@ -11,6 +11,9 @@ const KNOWN_VARIANTS = new Set([ ]) export function parseVariantFromModelID(rawModelID: string): { modelID: string; variant?: string } { + if (typeof rawModelID !== "string") { + return { modelID: "" } + } const trimmedModelID = rawModelID.trim() if (!trimmedModelID) { return { modelID: "" } @@ -38,6 +41,7 @@ export function parseVariantFromModelID(rawModelID: string): { modelID: string; export function parseModelString( model: string, ): { providerID: string; modelID: string; variant?: string } | undefined { + if (typeof model !== "string") return undefined const trimmedModel = model.trim() if (!trimmedModel) return undefined From ed44466f331e465e21025dd6d97fcc47de98573e Mon Sep 17 00:00:00 2001 From: MoerAI Date: Mon, 18 May 2026 19:49:02 +0900 Subject: [PATCH 2/3] fix(plugin): wire tool.definition handler so todo-description-override actually fires (fixes #3705) The bundled createTodoDescriptionOverrideHook returns { 'tool.definition': fn }, but plugin-interface.ts never exposes 'tool.definition' as an OpenCode hook handler. Result: the hook is constructed by createToolGuardHooks (line 132-134 of src/plugin/hooks/create-tool-guard-hooks.ts) but the function is never invoked, so todowrite keeps using OpenCode's core 7 KB description instead of the 1.4 KB TODOWRITE_DESCRIPTION. User-defined plugins under ~/.config/opencode/plugin/*.js use the same hook contract and work fine, confirming the contract itself is functional in opencode 1.14.28+. Fix: add src/plugin/tool-definition.ts (createToolDefinitionHandler) that forwards the OpenCode 'tool.definition' input/output pair into hooks.todoDescriptionOverride. Wire it into plugin-interface.ts alongside tool.execute.before/after. Regression coverage: src/plugin/tool-definition.test.ts covers (a) todowrite override applied, (b) other tools left untouched, (c) null hook is a no-op. --- src/plugin-interface.ts | 5 +++ src/plugin/tool-definition.test.ts | 63 ++++++++++++++++++++++++++++++ src/plugin/tool-definition.ts | 16 ++++++++ 3 files changed, 84 insertions(+) create mode 100644 src/plugin/tool-definition.test.ts create mode 100644 src/plugin/tool-definition.ts diff --git a/src/plugin-interface.ts b/src/plugin-interface.ts index 5bcc0c364..9aa982808 100644 --- a/src/plugin-interface.ts +++ b/src/plugin-interface.ts @@ -8,6 +8,7 @@ import { createCommandExecuteBeforeHandler } from "./plugin/command-execute-befo import { createMessagesTransformHandler } from "./plugin/messages-transform" import { createSystemTransformHandler } from "./plugin/system-transform" import { createEventHandler } from "./plugin/event" +import { createToolDefinitionHandler } from "./plugin/tool-definition" import { createToolExecuteAfterHandler } from "./plugin/tool-execute-after" import { createToolExecuteBeforeHandler } from "./plugin/tool-execute-before" @@ -70,6 +71,10 @@ export function createPluginInterface(args: { hooks, }), + "tool.definition": createToolDefinitionHandler({ + hooks, + }), + "tool.execute.before": createToolExecuteBeforeHandler({ ctx, hooks, diff --git a/src/plugin/tool-definition.test.ts b/src/plugin/tool-definition.test.ts new file mode 100644 index 000000000..23c5167c6 --- /dev/null +++ b/src/plugin/tool-definition.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from "bun:test" +import { createToolDefinitionHandler } from "./tool-definition" +import { createTodoDescriptionOverrideHook } from "../hooks/todo-description-override/hook" +import { TODOWRITE_DESCRIPTION } from "../hooks/todo-description-override/description" +import type { CreatedHooks } from "../create-hooks" + +function buildHooks(overrides: Partial = {}): CreatedHooks { + return overrides as CreatedHooks +} + +describe("createToolDefinitionHandler (regression for #3705)", () => { + describe("#given todoDescriptionOverride hook is registered", () => { + describe("#when the tool.definition handler runs for the todowrite tool", () => { + it("#then forwards to the hook and rewrites the description", async () => { + //#given + const handler = createToolDefinitionHandler({ + hooks: buildHooks({ todoDescriptionOverride: createTodoDescriptionOverrideHook() }), + }) + const output = { description: "opencode core default", parameters: {} } + + //#when + await handler({ toolID: "todowrite" }, output) + + //#then + expect(output.description).toBe(TODOWRITE_DESCRIPTION) + }) + }) + + describe("#when the tool.definition handler runs for any other tool", () => { + it("#then leaves the description untouched", async () => { + //#given + const handler = createToolDefinitionHandler({ + hooks: buildHooks({ todoDescriptionOverride: createTodoDescriptionOverrideHook() }), + }) + const output = { description: "bash native description", parameters: {} } + + //#when + await handler({ toolID: "bash" }, output) + + //#then + expect(output.description).toBe("bash native description") + }) + }) + }) + + describe("#given todoDescriptionOverride hook is disabled (null)", () => { + describe("#when the tool.definition handler runs for todowrite", () => { + it("#then is a no-op", async () => { + //#given + const handler = createToolDefinitionHandler({ + hooks: buildHooks({ todoDescriptionOverride: null }), + }) + const output = { description: "opencode default kept", parameters: {} } + + //#when + await handler({ toolID: "todowrite" }, output) + + //#then + expect(output.description).toBe("opencode default kept") + }) + }) + }) +}) diff --git a/src/plugin/tool-definition.ts b/src/plugin/tool-definition.ts new file mode 100644 index 000000000..59f794eff --- /dev/null +++ b/src/plugin/tool-definition.ts @@ -0,0 +1,16 @@ +import type { CreatedHooks } from "../create-hooks" + +export function createToolDefinitionHandler(args: { + hooks: CreatedHooks +}): ( + input: { toolID: string }, + output: { description: string; parameters: unknown }, +) => Promise { + const { hooks } = args + return async (input, output) => { + const overrideHook = hooks.todoDescriptionOverride + if (overrideHook) { + await overrideHook["tool.definition"](input, output) + } + } +} From 81ce512705ef27f9b54840e9c175dbf8fffe16c0 Mon Sep 17 00:00:00 2001 From: MoerAI Date: Tue, 19 May 2026 10:13:52 +0900 Subject: [PATCH 3/3] fix(agents): declare multimodal-looker tool allowlist in prompt to prevent death loop on small VL models (fixes #4116) The multimodal-looker prompt described what to do but never told the model which tools are available. Smaller VL models (e.g. Qwen3-VL-8B) would try to call non-existent tools and enter an infinite loop emitting: Model tried to call unavailable tool 'invalid'. Available tools: call_omo_agent, read. Add a single sentence at the top of the prompt that explicitly enumerates the only allowed tools ('read' and 'call_omo_agent') and forbids calling any other tool. This matches the runtime allowlist enforced by createAgentToolAllowlist(["read"]). Regression test asserts the prompt contains the available-tools enumeration so future prompt rewrites don't regress. --- src/agents/multimodal-looker.test.ts | 27 +++++++++++++++++++++++++++ src/agents/multimodal-looker.ts | 2 ++ 2 files changed, 29 insertions(+) create mode 100644 src/agents/multimodal-looker.test.ts diff --git a/src/agents/multimodal-looker.test.ts b/src/agents/multimodal-looker.test.ts new file mode 100644 index 000000000..f2f282644 --- /dev/null +++ b/src/agents/multimodal-looker.test.ts @@ -0,0 +1,27 @@ +import { describe, test, expect } from "bun:test" +import { createMultimodalLookerAgent } from "./multimodal-looker" + +describe("createMultimodalLookerAgent", () => { + test("prompt explicitly enumerates the agent's available tools to prevent death loop on small VL models", () => { + // given + const agent = createMultimodalLookerAgent("openai/gpt-5-nano") + + // when + const prompt = typeof agent.prompt === "string" ? agent.prompt : "" + + // then + expect(prompt).toMatch(/available tools/i) + expect(prompt).toContain("read") + }) + + test("prompt instructs the agent never to call other tools", () => { + // given + const agent = createMultimodalLookerAgent("openai/gpt-5-nano") + + // when + const prompt = typeof agent.prompt === "string" ? agent.prompt : "" + + // then + expect(prompt.toLowerCase()).toContain("never") + }) +}) diff --git a/src/agents/multimodal-looker.ts b/src/agents/multimodal-looker.ts index 2d89e422b..2a4b80431 100644 --- a/src/agents/multimodal-looker.ts +++ b/src/agents/multimodal-looker.ts @@ -23,6 +23,8 @@ export function createMultimodalLookerAgent(model: string): AgentConfig { ...restrictions, prompt: `You interpret media files that cannot be read as plain text. +Your only available tools are 'read' and 'call_omo_agent'. Always use 'read' to load the file first, then analyze the returned content. Never attempt to call any other tool. + Your job: examine the attached file and extract ONLY what was requested. When to use you: