Skip to content
Open
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
66 changes: 66 additions & 0 deletions plugins/workflows/src/json-value.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { JsonValue } from "./types.js";

export function assertJsonValue(
value: unknown,
path = "result",
ancestors = new WeakSet<object>(),
): 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<string, unknown>;
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;
}
61 changes: 1 addition & 60 deletions plugins/workflows/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type QuickJSHandle,
type QuickJSRuntime,
} from "quickjs-emscripten-core";
import { assertJsonValue } from "./json-value.js";
import type {
ExecuteWorkflowScriptArgs,
JsonValue,
Expand Down Expand Up @@ -43,66 +44,6 @@ function isAborted(signal: AbortSignal | undefined): boolean {
return signal?.aborted === true;
}

function assertJsonValue(
value: unknown,
path = "result",
ancestors = new WeakSet<object>(),
): 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<string, unknown>;
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) {
Expand Down
26 changes: 26 additions & 0 deletions plugins/workflows/src/server-harness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
29 changes: 22 additions & 7 deletions plugins/workflows/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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).",
)
Expand All @@ -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();

Expand Down Expand Up @@ -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}"}`;
Expand All @@ -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);
},
Expand Down