diff --git a/src/plugin/normalize-tool-arg-schemas.test.ts b/src/plugin/normalize-tool-arg-schemas.test.ts
new file mode 100644
index 0000000000..27f1489958
--- /dev/null
+++ b/src/plugin/normalize-tool-arg-schemas.test.ts
@@ -0,0 +1,97 @@
+///
+
+import { afterEach, describe, expect, it } from "bun:test"
+import { cpSync, mkdtempSync, rmSync } from "node:fs"
+import { tmpdir } from "node:os"
+import { dirname, join } from "node:path"
+import { pathToFileURL } from "node:url"
+import { tool } from "@opencode-ai/plugin"
+import { normalizeToolArgSchemas } from "./normalize-tool-arg-schemas"
+
+const tempDirectories: string[] = []
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null
+}
+
+function getNestedRecord(record: Record, key: string): Record | undefined {
+ const value = record[key]
+ return isRecord(value) ? value : undefined
+}
+
+async function loadSeparateHostZodModule(): Promise {
+ const pluginPackageDirectory = dirname(Bun.resolveSync("@opencode-ai/plugin/package.json", import.meta.dir))
+ const sourceZodDirectory = join(pluginPackageDirectory, "node_modules", "zod")
+ const tempDirectory = mkdtempSync(join(tmpdir(), "omo-host-zod-"))
+ const copiedZodDirectory = join(tempDirectory, "zod")
+
+ cpSync(sourceZodDirectory, copiedZodDirectory, { recursive: true })
+ tempDirectories.push(tempDirectory)
+
+ return await import(pathToFileURL(join(copiedZodDirectory, "index.js")).href)
+}
+
+function serializeWithHostZod(
+ hostZod: typeof import("zod"),
+ args: Record,
+): Record {
+ return hostZod.z.toJSONSchema(Reflect.apply(hostZod.z.object, hostZod.z, [args]))
+}
+
+describe("normalizeToolArgSchemas", () => {
+ afterEach(() => {
+ for (const tempDirectory of tempDirectories.splice(0)) {
+ rmSync(tempDirectory, { recursive: true, force: true })
+ }
+ })
+
+ it("preserves nested descriptions and metadata across zod instances", async () => {
+ // given
+ const hostZod = await loadSeparateHostZodModule()
+ const toolDefinition = tool({
+ description: "Search tool",
+ args: {
+ filters: tool.schema
+ .object({
+ query: tool.schema
+ .string()
+ .describe("Free-text search query")
+ .meta({ title: "Query", examples: ["issue 2314"] }),
+ })
+ .describe("Filter options")
+ .meta({ title: "Filters" }),
+ },
+ async execute(): Promise {
+ return "ok"
+ },
+ })
+
+ // when
+ const beforeSchema = serializeWithHostZod(hostZod, toolDefinition.args)
+ const beforeProperties = getNestedRecord(beforeSchema, "properties")
+ const beforeFilters = beforeProperties ? getNestedRecord(beforeProperties, "filters") : undefined
+ const beforeFilterProperties = beforeFilters ? getNestedRecord(beforeFilters, "properties") : undefined
+ const beforeQuery = beforeFilterProperties ? getNestedRecord(beforeFilterProperties, "query") : undefined
+
+ normalizeToolArgSchemas(toolDefinition)
+
+ const afterSchema = serializeWithHostZod(hostZod, toolDefinition.args)
+ const afterProperties = getNestedRecord(afterSchema, "properties")
+ const afterFilters = afterProperties ? getNestedRecord(afterProperties, "filters") : undefined
+ const afterFilterProperties = afterFilters ? getNestedRecord(afterFilters, "properties") : undefined
+ const afterQuery = afterFilterProperties ? getNestedRecord(afterFilterProperties, "query") : undefined
+
+ // then
+ expect(beforeFilters?.description).toBeUndefined()
+ expect(beforeFilters?.title).toBeUndefined()
+ expect(beforeQuery?.description).toBeUndefined()
+ expect(beforeQuery?.title).toBeUndefined()
+ expect(beforeQuery?.examples).toBeUndefined()
+
+ expect(afterFilters?.description).toBe("Filter options")
+ expect(afterFilters?.title).toBe("Filters")
+ expect(afterQuery?.description).toBe("Free-text search query")
+ expect(afterQuery?.title).toBe("Query")
+ expect(afterQuery?.examples).toEqual(["issue 2314"])
+ })
+})
diff --git a/src/plugin/normalize-tool-arg-schemas.ts b/src/plugin/normalize-tool-arg-schemas.ts
new file mode 100644
index 0000000000..4e8182b637
--- /dev/null
+++ b/src/plugin/normalize-tool-arg-schemas.ts
@@ -0,0 +1,42 @@
+import { tool } from "@opencode-ai/plugin"
+import type { ToolDefinition } from "@opencode-ai/plugin"
+
+type ToolArgSchema = ToolDefinition["args"][string]
+
+type SchemaWithJsonSchemaOverride = ToolArgSchema & {
+ _zod: ToolArgSchema["_zod"] & {
+ toJSONSchema?: () => unknown
+ }
+}
+
+function stripRootJsonSchemaFields(jsonSchema: Record): Record {
+ const { $schema: _schema, ...rest } = jsonSchema
+ return rest
+}
+
+function attachJsonSchemaOverride(schema: SchemaWithJsonSchemaOverride): void {
+ if (schema._zod.toJSONSchema) {
+ return
+ }
+
+ schema._zod.toJSONSchema = (): Record => {
+ const originalOverride = schema._zod.toJSONSchema
+ delete schema._zod.toJSONSchema
+
+ try {
+ return stripRootJsonSchemaFields(tool.schema.toJSONSchema(schema))
+ } finally {
+ schema._zod.toJSONSchema = originalOverride
+ }
+ }
+}
+
+export function normalizeToolArgSchemas>(
+ toolDefinition: TDefinition,
+): TDefinition {
+ for (const schema of Object.values(toolDefinition.args)) {
+ attachJsonSchemaOverride(schema)
+ }
+
+ return toolDefinition
+}
diff --git a/src/plugin/tool-registry.ts b/src/plugin/tool-registry.ts
index 3b441c1970..a08b9bd33a 100644
--- a/src/plugin/tool-registry.ts
+++ b/src/plugin/tool-registry.ts
@@ -32,6 +32,7 @@ import { log } from "../shared"
import type { Managers } from "../create-managers"
import type { SkillContext } from "./skill-context"
+import { normalizeToolArgSchemas } from "./normalize-tool-arg-schemas"
export type ToolRegistryResult = {
filteredTools: ToolsRecord
@@ -139,6 +140,10 @@ export function createToolRegistry(args: {
...hashlineToolsRecord,
}
+ for (const toolDefinition of Object.values(allTools)) {
+ normalizeToolArgSchemas(toolDefinition)
+ }
+
const filteredTools = filterDisabledTools(allTools, pluginConfig.disabled_tools)
return {