From a91c811d4cf3408576dd38d6833da1f9457730a7 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Tue, 13 Oct 2026 14:47:43 +0530 Subject: [PATCH 1/2] fix(plugin): emit an object schema for empty-struct tool parameters Effect reads Schema.Struct({}) as TS {} and emits { not: { type: "null" } }, which OpenAI and Anthropic reject. Emit an empty object schema instead, with the same additionalProperties as non-empty structs. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + .../tools.empty.live.anthropic.json | 4 + .../tools.empty.live.openai.json | 4 + .../harness/test/tools.empty.live.test.ts | 88 +++++++++++++++++++ packages/plugin/src/tool.ts | 17 +++- 5 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 packages/harness/test/__artifacts__/tools.empty.live.anthropic.json create mode 100644 packages/harness/test/__artifacts__/tools.empty.live.openai.json create mode 100644 packages/harness/test/tools.empty.live.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ccc752d1..033dd336 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ This file is the canonical source for unreleased changes and published release n ## [Unreleased] +### Fixed + +- `@codeworksh/plugin`: Fixed tools whose parameters are `Schema.Struct({})` being rejected by OpenAI and Anthropic. Their JSON Schema is now `{ "type": "object", "properties": {} }` instead of `{ "not": { "type": "null" } }`. + ## [@codeworksh/cli@0.2.0] - 2026-10-12 ### Added diff --git a/packages/harness/test/__artifacts__/tools.empty.live.anthropic.json b/packages/harness/test/__artifacts__/tools.empty.live.anthropic.json new file mode 100644 index 00000000..b2bfb81f --- /dev/null +++ b/packages/harness/test/__artifacts__/tools.empty.live.anthropic.json @@ -0,0 +1,4 @@ +{ + "finite": "completed", + "reply": "finite-7f3a" +} diff --git a/packages/harness/test/__artifacts__/tools.empty.live.openai.json b/packages/harness/test/__artifacts__/tools.empty.live.openai.json new file mode 100644 index 00000000..b2bfb81f --- /dev/null +++ b/packages/harness/test/__artifacts__/tools.empty.live.openai.json @@ -0,0 +1,4 @@ +{ + "finite": "completed", + "reply": "finite-7f3a" +} diff --git a/packages/harness/test/tools.empty.live.test.ts b/packages/harness/test/tools.empty.live.test.ts new file mode 100644 index 00000000..eb19068f --- /dev/null +++ b/packages/harness/test/tools.empty.live.test.ts @@ -0,0 +1,88 @@ +import { Effect, Schema } from "effect"; +import fs from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vite-plus/test"; +import { ContextCodec } from "../src/context/codec.ts"; +import { Harness } from "../src/effect/harness.ts"; +import { Session } from "../src/effect/session.ts"; +import { define } from "../src/plugin/plugin.ts"; +import * as Tool from "../src/tool/tool.ts"; +import { tmpdir } from "./fixtures/tempdir.ts"; +import { available, LIVE } from "./utils/live.ts"; + +/** + * A tool whose parameters are `Schema.Struct({})` must reach the provider as an object + * schema. The token is only known to the tool, so a right reply means the call went through. + */ + +const TOKEN = "finite-7f3a"; + +const finitePlugin = define({ + id: "acme.tool.finite", + kind: "tool", + setup(ctx) { + ctx.plugin.tools.add( + Tool.register( + Tool.make({ + name: "finite", + description: "Return the secret token. Takes no arguments.", + promptSnippet: "Return the secret token", + parameters: Schema.Struct({}), + success: Schema.String, + encodeContent: (value) => [{ type: "text", text: value }], + handler: () => Effect.succeed(TOKEN), + }), + ), + ); + }, +}); + +// Direct provider APIs only. +const direct = LIVE.filter((live) => live.provider === "openai" || live.provider === "anthropic"); + +describe.each(direct)("parameterless tool against live $name", (live) => { + it.skipIf(!available(live))( + "calls a tool whose parameters are an empty struct", + async () => { + await using temp = await tmpdir(); + const root = await fs.realpath(temp.path); + const work = join(root, "work"); + await fs.mkdir(work, { recursive: true }); + + const { calls, reply } = await Effect.gen(function* () { + const session = yield* Session.create({ directory: work, model: { provider: live.provider, id: live.id } }); + yield* session.run("Call the finite tool, then reply with exactly the token it returned and nothing else."); + const path = yield* session.path(); + const messages = yield* Effect.forEach(path, ContextCodec.decodeMessage); + const parts = messages.flatMap((message) => message.parts); + const last = messages.findLast((message) => message.parts.some((part) => part.type === "text")); + return { + calls: parts.flatMap((part) => (part.type === "toolCall" ? [part] : [])), + reply: (last?.parts ?? []).flatMap((part) => (part.type === "text" ? [part.text] : [])).join(""), + }; + }).pipe( + Effect.provide( + Harness.layer({ + home: join(root, "home"), + hostCwd: root, + database: ":memory:", + plugins: [finitePlugin], + }), + ), + Effect.scoped, + Effect.timeout("120 seconds"), + Effect.runPromise, + ); + + const finite = calls.filter((call) => call.name === "finite").map((call) => call.status); + expect(finite).toContain("completed"); + const result = { finite: "completed", reply: reply.trim().replace(/^`|`$/g, "") }; + expect(result.reply).toBe(TOKEN); + + await expect(`${JSON.stringify(result, null, "\t")}\n`).toMatchFileSnapshot( + `./__artifacts__/tools.empty.live.${live.name}.json`, + ); + }, + 180_000, + ); +}); diff --git a/packages/plugin/src/tool.ts b/packages/plugin/src/tool.ts index 4b786224..a1b4f437 100644 --- a/packages/plugin/src/tool.ts +++ b/packages/plugin/src/tool.ts @@ -1,5 +1,5 @@ import type { Message } from "@codeworksh/aikit"; -import { Effect, type Layer, Schema } from "effect"; +import { Effect, type Layer, Schema, SchemaAST } from "effect"; import type { TSchema } from "typebox"; import { ToolExecutionError } from "./tool/error.ts"; import type { ToolProgress } from "./tool/progress.ts"; @@ -238,8 +238,19 @@ export const provide = < * single boundary that crosses into the provider wire; if a provider rejects a * construct, fix it here. Draft-07 inlines the root (no top-level `$ref`). */ -export const toProviderJsonSchema = (schema: Schema.Top): Record => - Schema.toStandardJSONSchemaV1(schema)["~standard"].jsonSchema.input({ target: "draft-07" }); +export const toProviderJsonSchema = (schema: Schema.Top): Record => { + // Effect reads `Struct({})` as TS `{}` (any non-null) and emits `{ not: { type: "null" } }`; + // providers require an object, so emit one shaped like a non-empty struct's. + const encoded = SchemaAST.toEncoded(schema.ast); + if ( + SchemaAST.isObjects(encoded) && + encoded.propertySignatures.length === 0 && + encoded.indexSignatures.length === 0 + ) { + return { type: "object", properties: {}, additionalProperties: true }; + } + return Schema.toStandardJSONSchemaV1(schema)["~standard"].jsonSchema.input({ target: "draft-07" }); +}; /** * Build the aikit `Message.Tool` wire view from a definition. The derived JSON From 5f2cf6601481bca1a55348e98b4b0150b0fcaf2e Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Tue, 13 Oct 2026 14:47:46 +0530 Subject: [PATCH 2/2] release: @codeworksh/plugin@0.2.1 --- packages/plugin/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 56efa8c4..ecd61b7b 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@codeworksh/plugin", - "version": "0.2.0", + "version": "0.2.1", "description": "SDK for building CodeWork plugins — the plugin, tool, and sandbox contracts shared with @codeworksh/harness.", "keywords": [ "agent",