diff --git a/plugins/workflows/src/json-value.ts b/plugins/workflows/src/json-value.ts new file mode 100644 index 0000000000..28cce969b9 --- /dev/null +++ b/plugins/workflows/src/json-value.ts @@ -0,0 +1,66 @@ +import type { JsonValue } from "./types.js"; + +export function assertJsonValue( + value: unknown, + path = "result", + ancestors = new WeakSet(), +): asserts value is JsonValue { + if ( + value === null || + typeof value === "string" || + typeof value === "boolean" + ) { + return; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + throw new Error(`${path} contains a non-finite number`); + } + return; + } + if (Array.isArray(value)) { + if (ancestors.has(value)) throw new Error(`${path} contains a cycle`); + ancestors.add(value); + for (let index = 0; index < value.length; index += 1) { + if (!(index in value)) throw new Error(`${path} contains a sparse array`); + assertJsonValue(value[index], `${path}[${index}]`, ancestors); + } + ancestors.delete(value); + return; + } + if (typeof value === "object") { + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new Error(`${path} must contain only plain objects and arrays`); + } + if (ancestors.has(value)) throw new Error(`${path} contains a cycle`); + ancestors.add(value); + if (Object.getOwnPropertySymbols(value).length > 0) { + throw new Error(`${path} contains symbol properties`); + } + const object = value as Record; + for (const key of Object.getOwnPropertyNames(object)) { + if (key === "__proto__" || key === "constructor" || key === "prototype") { + throw new Error( + `${path} contains forbidden key ${JSON.stringify(key)}`, + ); + } + const descriptor = Object.getOwnPropertyDescriptor(object, key); + if (descriptor === undefined || !("value" in descriptor)) { + throw new Error(`${path}.${key} must be a data property`); + } + if (!descriptor.enumerable) { + throw new Error(`${path}.${key} must be enumerable`); + } + assertJsonValue(descriptor.value, `${path}.${key}`, ancestors); + } + ancestors.delete(value); + return; + } + throw new Error(`${path} is not JSON-compatible`); +} + +export function toJsonValue(value: unknown, path: string): JsonValue { + assertJsonValue(value, path); + return value; +} diff --git a/plugins/workflows/src/runtime.ts b/plugins/workflows/src/runtime.ts index c9e5e9aa3d..8b8b14a9ac 100644 --- a/plugins/workflows/src/runtime.ts +++ b/plugins/workflows/src/runtime.ts @@ -6,6 +6,7 @@ import { type QuickJSHandle, type QuickJSRuntime, } from "quickjs-emscripten-core"; +import { assertJsonValue } from "./json-value.js"; import type { ExecuteWorkflowScriptArgs, JsonValue, @@ -43,66 +44,6 @@ function isAborted(signal: AbortSignal | undefined): boolean { return signal?.aborted === true; } -function assertJsonValue( - value: unknown, - path = "result", - ancestors = new WeakSet(), -): asserts value is JsonValue { - if ( - value === null || - typeof value === "string" || - typeof value === "boolean" - ) { - return; - } - if (typeof value === "number") { - if (!Number.isFinite(value)) { - throw new Error(`${path} contains a non-finite number`); - } - return; - } - if (Array.isArray(value)) { - if (ancestors.has(value)) throw new Error(`${path} contains a cycle`); - ancestors.add(value); - for (let index = 0; index < value.length; index += 1) { - if (!(index in value)) throw new Error(`${path} contains a sparse array`); - assertJsonValue(value[index], `${path}[${index}]`, ancestors); - } - ancestors.delete(value); - return; - } - if (typeof value === "object") { - const prototype = Object.getPrototypeOf(value); - if (prototype !== Object.prototype && prototype !== null) { - throw new Error(`${path} must contain only plain objects and arrays`); - } - if (ancestors.has(value)) throw new Error(`${path} contains a cycle`); - ancestors.add(value); - if (Object.getOwnPropertySymbols(value).length > 0) { - throw new Error(`${path} contains symbol properties`); - } - const object = value as Record; - for (const key of Object.getOwnPropertyNames(object)) { - if (key === "__proto__" || key === "constructor" || key === "prototype") { - throw new Error( - `${path} contains forbidden key ${JSON.stringify(key)}`, - ); - } - const descriptor = Object.getOwnPropertyDescriptor(object, key); - if (descriptor === undefined || !("value" in descriptor)) { - throw new Error(`${path}.${key} must be a data property`); - } - if (!descriptor.enumerable) { - throw new Error(`${path}.${key} must be enumerable`); - } - assertJsonValue(descriptor.value, `${path}.${key}`, ancestors); - } - ancestors.delete(value); - return; - } - throw new Error(`${path} is not JSON-compatible`); -} - function parseWorkflowReference(value: JsonValue): WorkflowReference { if (typeof value === "string") { if (value.length === 0) { diff --git a/plugins/workflows/src/server-harness.test.ts b/plugins/workflows/src/server-harness.test.ts index 690dfe0263..e0a307c452 100644 --- a/plugins/workflows/src/server-harness.test.ts +++ b/plugins/workflows/src/server-harness.test.ts @@ -61,6 +61,32 @@ describe("workflows plugin", () => { await harness.setSettings({ maxActiveRuns: "5" }); }); + it("registers tool schemas without recursive $refs", async () => { + const { bb, harness } = createFakePluginHost({ + pluginId: "workflows", + agentSkillIds: ["workflows"], + }); + hosts.push(harness); + await plugin(bb); + + const tools = harness.registrations.agentTools; + expect(tools.map((tool) => tool.name)).toEqual( + expect.arrayContaining(["bb_workflow_run", "bb_workflow_result"]), + ); + // A self-referential $ref makes some providers reject the whole tool list + // before the turn starts, so no tool may ship one. + for (const tool of tools) { + const schema = JSON.stringify(tool.inputSchema); + expect(schema, `tool ${tool.name}`).not.toContain("$ref"); + expect(schema, `tool ${tool.name}`).not.toContain("$defs"); + } + + const run = tools.find((tool) => tool.name === "bb_workflow_run"); + expect( + run?.parse({ name: "demo", args: { nested: [1, { deep: null }] } }), + ).toMatchObject({ ok: true }); + }); + it("runs a structured workflow asynchronously and notifies its origin", async () => { let childCount = 0; const { bb, harness } = createFakePluginHost({ diff --git a/plugins/workflows/src/server.ts b/plugins/workflows/src/server.ts index cbd2768935..ec496dec18 100644 --- a/plugins/workflows/src/server.ts +++ b/plugins/workflows/src/server.ts @@ -2,12 +2,14 @@ import type { BbPluginApi, PluginAgentToolResult } from "@get-bb/plugin-sdk"; import { z } from "zod"; import { registerWorkflowCli } from "./cli.js"; import { migrations } from "./data.js"; +import { toJsonValue } from "./json-value.js"; import { executeWorkflowScript } from "./runtime.js"; import { createWorkflowService } from "./service.js"; import { DEFAULT_WORKFLOW_SETTINGS, registerWorkflowSettings, } from "./settings.js"; +import type { JsonValue } from "./types.js"; import { prepareWorkflowSource } from "./workflow-input.js"; import { workflowUiRpcContract } from "./ui-contract.js"; import { buildWorkflowRunView } from "./ui-view.js"; @@ -44,11 +46,16 @@ const sourceInputFields = { ) .optional(), } as const; +// zod 4's `z.json()` compiles to a self-referential `$defs` entry, and some +// model providers reject an entire tool list that contains a recursive `$ref` +// before the turn starts. Declare freeform JSON as `unknown` so the wire schema +// stays flat, then narrow with `toJsonValue` at each call site. +const freeformJson = z.unknown(); + const runInputSchema = z .object({ ...sourceInputFields, - args: z - .json() + args: freeformJson .describe( "Optional input value exposed to the script as the global `args`, verbatim. Pass arrays/objects as actual JSON values, NOT as a JSON-encoded string — a stringified list breaks `args.filter`/`args.map` in the script. Use for parameterized named workflows (e.g. a research question).", ) @@ -65,9 +72,9 @@ const runInputSchema = z .strict(); const resultInputSchema = z .object({ - value: z - .json() - .describe("The final value matching the requested JSON Schema."), + value: freeformJson.describe( + "The final value matching the requested JSON Schema.", + ), }) .strict(); @@ -147,7 +154,7 @@ export default async function plugin(bb: BbPluginApi) { projectId: ctx.projectId, originThreadId: ctx.threadId, source: prepared.source, - args: input.args, + args: toJsonValue(input.args, "args"), resumedFromRunId: input.resumeRunId, }); const previewDirective = `::workflow-preview{run="${run.id}"}`; @@ -171,7 +178,15 @@ export default async function plugin(bb: BbPluginApi) { 'Use this tool to return your final response in the requested structured format. You MUST call this tool exactly once at the end of your response with {"value": ...} to provide the structured output.', parameters: resultInputSchema, async execute({ value }, ctx) { - const result = await service.submitStructuredResult(ctx.threadId, value); + let parsed: JsonValue; + try { + parsed = toJsonValue(value, "value"); + } catch (error) { + return errorResult( + error instanceof Error ? error.message : String(error), + ); + } + const result = await service.submitStructuredResult(ctx.threadId, parsed); if (result.ok) return jsonResult({ accepted: true }); return errorResult(result.error); },