Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<version>`, so it no longer goes stale on every version bump.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"finite": "completed",
"reply": "finite-7f3a"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"finite": "completed",
"reply": "finite-7f3a"
}
88 changes: 88 additions & 0 deletions packages/harness/test/tools.empty.live.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
2 changes: 1 addition & 1 deletion packages/plugin/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
17 changes: 14 additions & 3 deletions packages/plugin/src/tool.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string, unknown> =>
Schema.toStandardJSONSchemaV1(schema)["~standard"].jsonSchema.input({ target: "draft-07" });
export const toProviderJsonSchema = (schema: Schema.Top): Record<string, unknown> => {
// 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
Expand Down
Loading