import { Type, type Static, type TObject, type TProperties, type TSchema } from "typebox"; import { Parse, ParseError } from "typebox/value"; import { parseJson } from "../state/json.js"; const noExtraProperties = { additionalProperties: false } as const; const offsetSchema = Type.Integer({ minimum: 0, description: "Workflow list offset" }); const workflowSchema = Type.String({ description: "Discovered workflow name or workflow file path; required when action is start", }); const inputSchema = Type.Unknown({ description: "Ordinary checkpoint answer for answer; protected human decisions require a human channel; optional structured workflow input for start", }); const runIdSchema = Type.String({ description: "Run id; required for restart and optional for status, cancel, settings, and follow-ups", }); const requestIdSchema = Type.String({ minLength: 1, description: "Exact durable request id from the step contract or checkpoint; required for answer, update, and submit", }); const updateSchema = Type.Object( { type: Type.String(), key: Type.String(), data: Type.Record(Type.String(), Type.Unknown()), }, noExtraProperties, ); const outputSchema = Type.Unknown({ description: "Step output matching the expected shape; required when action is submit", }); export const WorkflowActionSchemas = { list: Type.Object( { action: Type.Literal("list"), offset: Type.Optional(offsetSchema) }, noExtraProperties, ), start: Type.Object( { action: Type.Literal("start"), workflow: workflowSchema, input: Type.Optional(inputSchema), }, noExtraProperties, ), restart: Type.Object( { action: Type.Literal("restart"), runId: runIdSchema, expectedRevision: Type.Integer({ minimum: 0, description: "Use runRevision from the terminal run view, not its presentation revision. Restart only when the user explicitly requests a fresh run.", }), }, noExtraProperties, ), "change-settings": Type.Object( { action: Type.Literal("change-settings"), runId: Type.Optional(runIdSchema), scopeId: Type.Optional(Type.String()), expectedChangeNumber: Type.Optional(Type.Integer({ minimum: 0 })), patch: Type.Unknown(), }, noExtraProperties, ), "queue-follow-up": Type.Object( { action: Type.Literal("queue-follow-up"), runId: Type.Optional(runIdSchema), prompt: Type.String({ minLength: 1 }), }, noExtraProperties, ), "remove-follow-up": Type.Object( { action: Type.Literal("remove-follow-up"), runId: Type.Optional(runIdSchema), followUpId: Type.String(), }, noExtraProperties, ), status: Type.Object( { action: Type.Literal("status"), runId: Type.Optional(runIdSchema) }, noExtraProperties, ), pause: Type.Object({ action: Type.Literal("pause") }, noExtraProperties), resume: Type.Object({ action: Type.Literal("resume") }, noExtraProperties), cancel: Type.Object( { action: Type.Literal("cancel"), runId: Type.Optional(runIdSchema) }, noExtraProperties, ), answer: Type.Object( { action: Type.Literal("answer"), requestId: requestIdSchema, input: inputSchema, }, noExtraProperties, ), update: Type.Object( { action: Type.Literal("update"), requestId: requestIdSchema, update: updateSchema, }, noExtraProperties, ), submit: Type.Object( { action: Type.Literal("submit"), requestId: requestIdSchema, output: outputSchema, }, noExtraProperties, ), } as const; const WorkflowSubmissionActionSchemas = { update: WorkflowActionSchemas.update, submit: WorkflowActionSchemas.submit, } as const; type SchemaValue> = Schemas[keyof Schemas]; export type WorkflowToolInput = Static>; export type WorkflowSubmissionInput = Static>; export const WorkflowToolParameters = providerObjectSchema(Object.values(WorkflowActionSchemas)); export const WorkflowSubmissionToolParameters = providerObjectSchema( Object.values(WorkflowSubmissionActionSchemas), ); type ToolInputParser = (value: unknown) => Output; const workflowInputParsers = { list: (value) => parseToolInput(WorkflowActionSchemas.list, value, "workflow"), start: (value) => parseToolInput(WorkflowActionSchemas.start, value, "workflow"), restart: (value) => parseToolInput(WorkflowActionSchemas.restart, value, "workflow"), "change-settings": (value) => parseToolInput(WorkflowActionSchemas["change-settings"], value, "workflow"), "queue-follow-up": (value) => parseToolInput(WorkflowActionSchemas["queue-follow-up"], value, "workflow"), "remove-follow-up": (value) => parseToolInput(WorkflowActionSchemas["remove-follow-up"], value, "workflow"), status: (value) => parseToolInput(WorkflowActionSchemas.status, value, "workflow"), pause: (value) => parseToolInput(WorkflowActionSchemas.pause, value, "workflow"), resume: (value) => parseToolInput(WorkflowActionSchemas.resume, value, "workflow"), cancel: (value) => parseToolInput(WorkflowActionSchemas.cancel, value, "workflow"), answer: (value) => parseToolInput(WorkflowActionSchemas.answer, value, "workflow"), update: (value) => parseToolInput(WorkflowActionSchemas.update, value, "workflow"), submit: (value) => parseToolInput(WorkflowActionSchemas.submit, value, "workflow"), } satisfies Record>; const workflowSubmissionInputParsers = { update: (value) => parseToolInput(WorkflowSubmissionActionSchemas.update, value, "workflow submission"), submit: (value) => parseToolInput(WorkflowSubmissionActionSchemas.submit, value, "workflow submission"), } satisfies Record< keyof typeof WorkflowSubmissionActionSchemas, ToolInputParser >; export function parseWorkflowToolInput(value: unknown): WorkflowToolInput { return parseSelectedAction(workflowInputParsers, value, "workflow"); } export function parseWorkflowSubmissionInput(value: unknown): WorkflowSubmissionInput { return parseSelectedAction( workflowSubmissionInputParsers, value, "workflow submission", ); } function providerObjectSchema(variants: readonly TObject[]): TObject { const actions: string[] = []; const properties: TProperties = {}; for (const variant of variants) { const action = variant.properties.action; if (!isRecord(action) || typeof action.const !== "string") { throw new Error("Workflow action schema must have a string action literal."); } actions.push(action.const); for (const [name, schema] of Object.entries(variant.properties)) { if (name === "action") continue; const optionalSchema = Type.Optional(schema); const current = properties[name]; if (current !== undefined && JSON.stringify(current) !== JSON.stringify(optionalSchema)) { throw new Error(`Workflow property ${JSON.stringify(name)} has incompatible schemas.`); } properties[name] = optionalSchema; } } return Type.Object( { action: Type.String({ enum: actions }), ...properties, }, noExtraProperties, ); } /** * Structured (untyped) tool parameters. Some tool transports deliver these as * raw JSON text instead of a parsed value, and one workflow runner crash per * start is too late to learn about it. A transport-encoded structured value * always serializes as JSON text that starts with `{` or `[`, so only such * text is parsed; every other string is a genuine literal and passes through * untouched. Scalars stay literal because a transport-encoded scalar such as * `123` is indistinguishable from the literal text, and the runner's own * validation still rejects a value whose shape the workflow cannot accept. */ const structuredFieldsByAction: Readonly> = { start: ["input"], answer: ["input"], submit: ["output"], update: ["update"], "change-settings": ["patch"], }; function coerceStructuredFields( action: string, value: Record, _label: string, ): Record { const fields = structuredFieldsByAction[action]; if (fields === undefined) return value; const coerced = { ...value }; for (const field of fields) { const raw = coerced[field]; if (typeof raw !== "string") continue; const trimmed = raw.trim(); if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) { if (action === "answer" && field === "input") { // Free-text answers are legitimate: the command path wraps text that // is not JSON in { answer: text }, and the tool path behaves the same. coerced[field] = { answer: raw }; } continue; } try { coerced[field] = parseJson(raw); } catch { // Text that merely looks like JSON is a literal string; the runner's // input validation reports it if the shape cannot be accepted. } } return coerced; } function parseSelectedAction( parsers: Readonly>>, value: unknown, label: string, ): Output { if (!isRecord(value) || typeof value.action !== "string") throw unknownAction(label); const parser = parsers[value.action]; if (parser === undefined) throw unknownAction(label); return parser(coerceStructuredFields(value.action, value, label)); } function unknownAction(label: string): Error { return new Error(`Invalid ${label} tool input: action is missing or unknown.`); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function parseToolInput( schema: Schema, value: unknown, label: string, ): Static { try { return Parse(schema, value); } catch (error) { if (!(error instanceof ParseError)) throw error; const details = error.cause.errors .slice(0, 3) .map(({ instancePath, message }) => { const field = instancePath.replace(/^\//u, "").replaceAll("/", "."); const clearMessage = message === "must be integer" ? "must be an integer" : message; return `${field ? `${field} ` : ""}${clearMessage}`; }) .join("; "); throw new Error(`Invalid ${label} tool input: ${details}`, { cause: error }); } }