diff --git a/packages/codemode/src/tool-schema.ts b/packages/codemode/src/tool-schema.ts index 16213fa8eeb2..5b8f3e2e75e2 100644 --- a/packages/codemode/src/tool-schema.ts +++ b/packages/codemode/src/tool-schema.ts @@ -283,12 +283,34 @@ export const outputTypeScript = (definition: Definition, pretty = false): ? toTypeScript(definition.output, true, pretty) : jsonSchemaToTypeScript(definition.output, pretty) +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +/** Parses a JSON string standing in for a declared object/array (e.g. `args: "{}"`); every other value is untouched. */ +const decodeJsonString = (value: unknown, schema: JsonSchema | undefined): unknown => { + if (typeof value !== "string" || (schema?.type !== "object" && schema?.type !== "array")) return value + try { + const parsed: unknown = JSON.parse(value) + if (schema.type === "array" ? Array.isArray(parsed) : isRecord(parsed)) return parsed + } catch { + // not JSON: keep the original string + } + return value +} + /** - * Decodes tool input before `run` is invoked. Effect Schemas validate (throwing on failure); - * JSON-Schema-described inputs pass through unvalidated (render-only). + * Decodes tool input before `run` is invoked. Effect Schemas validate (throwing on failure); JSON-Schema + * inputs pass through unvalidated (render-only), after decoding JSON strings standing in for declared objects/arrays. */ -export const decodeInput = (definition: Definition, value: unknown): unknown => - isEffectSchema(definition.input) ? Schema.decodeUnknownSync(definition.input)(value) : value +export const decodeInput = (definition: Definition, value: unknown): unknown => { + if (isEffectSchema(definition.input)) return Schema.decodeUnknownSync(definition.input)(value) + const decoded = decodeJsonString(value, definition.input) + const properties = definition.input.properties + if (properties === undefined || !isRecord(decoded)) return decoded + return Object.fromEntries( + Object.entries(decoded).map(([key, entry]) => [key, decodeJsonString(entry, properties[key])]), + ) +} /** * Decodes a tool result before it is exposed to the program. Effect Schemas validate and diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts index 221b5e07dfe1..773db85a94f4 100644 --- a/packages/codemode/test/codemode.test.ts +++ b/packages/codemode/test/codemode.test.ts @@ -437,6 +437,38 @@ describe("CodeMode schema flexibility", () => { expect(observed).toStrictEqual([{ id: 42 }]) }) + test("decodes JSON-string input where the schema declares an object or array", async () => { + const observed: Array = [] + const call = Tool.make({ + description: "Invoke a proxied tool", + input: { + type: "object", + properties: { + args: { type: "object" }, + tags: { type: "array", items: { type: "string" } }, + body: { type: "string" }, + broken: { type: "object" }, + }, + }, + run: (input) => + Effect.sync(() => { + observed.push(input) + return { ok: true } + }), + }) + const runtime = CodeMode.make({ tools: { proxy: { call } } }) + + await Effect.runPromise( + runtime.execute(`return await tools.proxy.call({ args: "{}", tags: '["a"]', body: "{}", broken: "not json" })`), + ) + await Effect.runPromise(runtime.execute(`return await tools.proxy.call('{"args": {"port": 3000}}')`)) + + expect(observed).toStrictEqual([ + { args: {}, tags: ["a"], body: "{}", broken: "not json" }, + { args: { port: 3000 } }, + ]) + }) + test("renders JSON Schema outputs and $defs references", async () => { const lookup = Tool.make({ description: "Look up a user",