diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d621ad2..605f5bee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ 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" } }`. ### Internal - The `codework tui` E2E artifact now records the CLI version as ``, so it no longer goes stale on every version bump. 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/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", 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