From e13af1f6e400d1dd3f474ebbc272dbbb690e492b Mon Sep 17 00:00:00 2001 From: nicosammito Date: Fri, 6 Nov 2026 02:03:27 +0100 Subject: [PATCH] feat: enrich inline sub-flows in literals with input/output schemas --- src/extraction/getFlowSchemas.ts | 328 ++++++++++++++++++++++++++++--- test/flowSchemas.test.ts | 164 ++++++++++++++++ 2 files changed, 461 insertions(+), 31 deletions(-) diff --git a/src/extraction/getFlowSchemas.ts b/src/extraction/getFlowSchemas.ts index e332513..1133087 100644 --- a/src/extraction/getFlowSchemas.ts +++ b/src/extraction/getFlowSchemas.ts @@ -3,6 +3,8 @@ import type { DataType, Flow, FunctionDefinition, + InlineReferenceValue, + LiteralValue, NodeFunction, NodeParameter, SubFlowValue, @@ -34,7 +36,8 @@ export type SchematizedSubFlowValue = SubFlowValue & SignatureSchemas /** * A {@link Flow} enriched with the input/output schemas of its own signature. * Every {@link SubFlowValue} nested in a node parameter is additionally enriched - * in place (see {@link SchematizedSubFlowValue}). + * in place (see {@link SchematizedSubFlowValue}) — both a parameter that is a + * sub-flow itself and an inline sub-flow sitting in a literal's `references`. */ export type SchematizedFlow = Flow & SignatureSchemas @@ -43,7 +46,8 @@ export type SchematizedFlow = Flow & SignatureSchemas * every sub-flow it contains. * * The same flow is returned back, but with two additional fields on the flow - * itself and on every {@link SubFlowValue} that appears as a node parameter: + * itself and on every {@link SubFlowValue} it contains — whether it fills a node + * parameter directly or is an inline reference of a literal parameter: * - `inputSchema` — an `object` JSON schema keyed by the signature's parameter names * - `outputSchema` — the JSON schema of the signature's return type * @@ -61,6 +65,11 @@ export type SchematizedFlow = Flow & SignatureSchemas * instantiated) argument types the lambda receives, and the output is what the * sub-flow body actually returns (e.g. via a `std::control::return` node), * rather than the `void` the surrounding function merely expects. + * - An inline sub-flow — one addressed via `${signature}` from a literal value's + * `references`, the only way to fill e.g. a `LIST` of sub-flows — is treated + * the same way: its schemas come from the expression generated at its position + * inside the literal, so it is contextually typed by the surrounding slot just + * like a sub-flow parameter. Nested literals are followed. * * The actual JSON Schema generation is delegated to `ts-json-schema-generator` * (see {@link generateJsonSchemas}); this function only resolves the real @@ -122,6 +131,23 @@ const subFlowAlias = ( kind: "input" | "output", ): string => `Node_${sanitizeId(nodeId!)}_${parameterIndex}_${kind}` +/** + * Alias name under which an *inline* sub-flow's input/output schema is generated. + * An inline sub-flow lives inside a literal value's `references`, addressed via + * `${signature}`; the `path` is the chain of signatures leading to it (longer + * than one entry only when literals are nested), which keeps the alias stable + * between type collection and flow re-assembly. + */ +const inlineSubFlowAlias = ( + nodeId: NodeFunction["id"], + parameterIndex: number, + path: string[], + kind: "input" | "output", +): string => + `Node_${sanitizeId(nodeId!)}_${parameterIndex}_ref_${path + .map(sanitizeId) + .join("_")}_${kind}` + /** * Collects the flow's own input/output type expressions from its signature. * @@ -213,35 +239,182 @@ const collectSubFlowTypeExpressions = ( for (const node of flow.nodes?.nodes ?? []) { if (!node?.id) continue const params = node.parameters?.nodes - if (!params?.some(isSubFlowParameter)) continue + if (!params?.some(carriesSubFlow)) continue const callExpression = nodeCallExpression(constants, node) if (!callExpression) continue params.forEach((param, index) => { - if (!isSubFlowParameter(param)) return - // The generated arguments are positional, so the parameter index maps - // straight onto the call argument holding the sub-flow lambda value. + // straight onto the call argument holding the sub-flow value(s). const argument = callExpression.arguments[index] - const valueSignature = argument - ? checker.getTypeAtLocation(argument).getCallSignatures()[0] - : undefined - if (!valueSignature) return - - const {input, output} = subFlowValueToTypeExpressions( - checker, - valueSignature, - argument!, - ) - typeExpressions[subFlowAlias(node.id, index, "input")] = input - typeExpressions[subFlowAlias(node.id, index, "output")] = output + if (!argument) return + + if (isSubFlowParameter(param)) { + const expressions = signatureTypeExpressionsOf(checker, argument) + if (!expressions) return + typeExpressions[subFlowAlias(node.id, index, "input")] = + expressions.input + typeExpressions[subFlowAlias(node.id, index, "output")] = + expressions.output + return + } + + // Inline sub-flows: the parameter is a literal whose `references` + // carry sub-flows spliced into its string leaves. They are emitted + // in the order the literal is walked, so the n-th sub-flow-shaped + // expression inside the argument belongs to the n-th path. + const paths = inlineSubFlowPaths(param) + if (paths.length === 0) return + + const inlineExpressions = collectInlineSubFlowExpressions(argument) + paths.forEach((path, position) => { + const inlineExpression = inlineExpressions[position] + if (!inlineExpression) return + + const expressions = signatureTypeExpressionsOf( + checker, + inlineExpression, + ) + if (!expressions) return + typeExpressions[ + inlineSubFlowAlias(node.id, index, path, "input") + ] = expressions.input + typeExpressions[ + inlineSubFlowAlias(node.id, index, path, "output") + ] = expressions.output + }) }) } return typeExpressions } +/** + * Resolves the input/output type expressions of a generated sub-flow value at + * `expression` — either the lambda `(...p) => { …body… }` or, for a sub-flow that + * directly maps a function, the `fn_…` reference itself. Returns `undefined` when + * the expression has no call signature (an unresolved value). + */ +const signatureTypeExpressionsOf = ( + checker: ts.TypeChecker, + expression: ts.Expression, +): {input: string; output: string} | undefined => { + const valueSignature = checker + .getTypeAtLocation(expression) + .getCallSignatures()[0] + if (!valueSignature) return undefined + return subFlowValueToTypeExpressions(checker, valueSignature, expression) +} + +/** + * Collects, in source order, the sub-flow-shaped expressions emitted inside a + * generated literal argument: the lambdas of inline sub-flows with their own node + * tree, and the bare `fn_…` references of inline sub-flows that directly map a + * function. + * + * Arrow functions are not descended into — a sub-flow body may itself contain + * nodes with sub-flow parameters, and those belong to their own node, not to this + * literal. An `fn_…` identifier in callee position is a generated node call + * rather than a sub-flow value and is therefore skipped as well. + */ +const collectInlineSubFlowExpressions = ( + argument: ts.Expression, +): ts.Expression[] => { + const expressions: ts.Expression[] = [] + + const visit = (child: ts.Node): void => { + if (ts.isArrowFunction(child)) { + expressions.push(child) + return + } + if (ts.isIdentifier(child) && child.text.startsWith("fn_")) { + const parent = child.parent as ts.Node | undefined + const isCallee = + !!parent && + ts.isCallExpression(parent) && + parent.expression === child + if (!isCallee) { + expressions.push(child) + return + } + } + child.forEachChild(visit) + } + + visit(argument) + return expressions +} + +/** Matches a `${signature}` inline-reference token inside a literal string leaf. */ +const INLINE_REFERENCE_TOKEN = /\$\{([^}]+)\}/g + +/** + * Lists the paths of every inline sub-flow of a literal parameter, in the order + * the generated code emits them. + * + * The literal value is walked exactly the way the code generator walks it — + * arrays by index, objects by entry order, strings by `${signature}` token order + * — so the n-th path lines up with the n-th sub-flow expression in the generated + * argument. A reference to a nested literal is descended into, and its signature + * becomes a prefix of the paths found inside it. + */ +const inlineSubFlowPaths = ( + param: NodeParameter | null | undefined, +): string[][] => { + if (!isLiteralParameter(param)) return [] + + const paths: string[][] = [] + collectInlineSubFlowPaths(param.value.value, param.value.references, [], paths) + return paths +} + +const collectInlineSubFlowPaths = ( + value: unknown, + references: LiteralValue["references"], + path: string[], + paths: string[][], +): void => { + const referenceBySignature = new Map() + for (const reference of references ?? []) { + if (reference?.signature) { + referenceBySignature.set(reference.signature, reference) + } + } + if (referenceBySignature.size === 0) return + + const walk = (current: unknown): void => { + if (typeof current === "string") { + for (const match of current.matchAll(INLINE_REFERENCE_TOKEN)) { + const inner = referenceBySignature.get(match[1])?.value + if (!inner) continue + if (inner.__typename === "SubFlowValue") { + paths.push([...path, match[1]]) + } else if (inner.__typename === "LiteralValue") { + collectInlineSubFlowPaths( + inner.value, + inner.references, + [...path, match[1]], + paths, + ) + } + } + return + } + if (Array.isArray(current)) { + current.forEach((item) => walk(item)) + return + } + if (current !== null && typeof current === "object") { + Object.values(current as Record).forEach((item) => + walk(item), + ) + } + } + + walk(value) +} + /** * Turns the call signature of a generated sub-flow lambda into a pair of type * expressions. The lambda is emitted with a single rest parameter (`(...p) => …`) @@ -373,33 +546,113 @@ const emptyObjectSchema = (): JsonSchema => ({type: "object"}) const emptySchema = (): JsonSchema => ({}) /** - * Enriches every SubFlowValue parameter of a single node with its generated - * input/output schemas, leaving all other parameters untouched. + * Enriches every sub-flow of a single node with its generated input/output + * schemas, leaving all other parameters untouched. This covers both a parameter + * that *is* a {@link SubFlowValue} and the inline sub-flows nested in a literal + * parameter's `references` (recursively through nested literals). */ const enrichNode = ( node: NodeFunction, schemas: Record, ): NodeFunction => { const params = node.parameters?.nodes - if (!params?.some(isSubFlowParameter)) return node + if (!params?.some(carriesSubFlow)) return node const enrichedParams = params.map((param, index) => { - if (!isSubFlowParameter(param)) return param - - const value: SchematizedSubFlowValue = { - ...param.value, - inputSchema: - schemas[subFlowAlias(node.id, index, "input")] ?? - emptyObjectSchema(), - outputSchema: - schemas[subFlowAlias(node.id, index, "output")] ?? emptySchema(), + if (isSubFlowParameter(param)) { + const value: SchematizedSubFlowValue = { + ...param.value, + inputSchema: + schemas[subFlowAlias(node.id, index, "input")] ?? + emptyObjectSchema(), + outputSchema: + schemas[subFlowAlias(node.id, index, "output")] ?? + emptySchema(), + } + return {...param, value} as NodeParameter } - return {...param, value} as NodeParameter + + if (!isLiteralParameter(param) || !param.value.references?.length) { + return param + } + + const references = enrichInlineReferences( + param.value.references, + node.id, + index, + [], + schemas, + ) + return { + ...param, + value: {...param.value, references}, + } as NodeParameter }) return {...node, parameters: {...node.parameters, nodes: enrichedParams}} } +/** + * Enriches the inline sub-flows of a literal's `references` with their generated + * schemas, descending into nested literals so a sub-flow at any depth is covered. + * References that resolve to anything else are returned untouched. + */ +const enrichInlineReferences = ( + references: NonNullable, + nodeId: NodeFunction["id"], + parameterIndex: number, + path: string[], + schemas: Record, +): InlineReferenceValue[] => + references.map((reference) => { + const inner = reference?.value + if (!reference?.signature || !inner) return reference + const referencePath = [...path, reference.signature] + + if (inner.__typename === "SubFlowValue") { + const value: SchematizedSubFlowValue = { + ...inner, + inputSchema: + schemas[ + inlineSubFlowAlias( + nodeId, + parameterIndex, + referencePath, + "input", + ) + ] ?? emptyObjectSchema(), + outputSchema: + schemas[ + inlineSubFlowAlias( + nodeId, + parameterIndex, + referencePath, + "output", + ) + ] ?? emptySchema(), + } + return {...reference, value} + } + + if (inner.__typename === "LiteralValue" && inner.references?.length) { + return { + ...reference, + value: { + ...inner, + references: enrichInlineReferences( + inner.references, + nodeId, + parameterIndex, + referencePath, + schemas, + ), + }, + } + } + + return reference + }) + // --- Shared helpers --------------------------------------------------------- /** True when the parameter carries a {@link SubFlowValue}. */ @@ -408,6 +661,19 @@ const isSubFlowParameter = ( ): param is NodeParameter & {value: SubFlowValue} => param?.value?.__typename === "SubFlowValue" +/** True when the parameter carries a {@link LiteralValue}. */ +const isLiteralParameter = ( + param: NodeParameter | null | undefined, +): param is NodeParameter & {value: LiteralValue} => + param?.value?.__typename === "LiteralValue" + +/** + * True when the parameter holds any sub-flow at all — either directly, or inline + * inside a literal's `references`. + */ +const carriesSubFlow = (param: NodeParameter | null | undefined): boolean => + isSubFlowParameter(param) || inlineSubFlowPaths(param).length > 0 + /** True when a parameter is optional (`?`), has a default, or is a rest param. */ const isOptionalParameter = (parameterSymbol: ts.Symbol): boolean => { const declaration = parameterSymbol.valueDeclaration diff --git a/test/flowSchemas.test.ts b/test/flowSchemas.test.ts index 27925aa..d90d67a 100644 --- a/test/flowSchemas.test.ts +++ b/test/flowSchemas.test.ts @@ -455,6 +455,170 @@ describe("getFlowSchemas", () => { expect(subFlow.outputSchema).toEqual({type: "number"}); }); + // --- Inline sub-flow references ------------------------------------------ + + /** + * A function whose single parameter is a *list* of sub-flows, so the only way + * to fill it is a literal list carrying inline sub-flow references — the shape + * an AI agent's `tools` parameter has. + */ + const TOOLS_FN: FunctionDefinition = { + id: "gid://sagittarius/FunctionDefinition/9401", + identifier: "custom::ai::agent", + signature: "(tools: LIST>): void", + }; + + /** A literal value carrying inline references, addressable via `${signature}`. */ + const literalWithReferences = ( + value: unknown, + references: {signature: string; value: NodeParameterValue}[], + ): NodeParameterValue => + ({ + __typename: "LiteralValue", + value, + references: references.map((reference) => ({ + __typename: "InlineReferenceValue", + ...reference, + })), + }) as NodeParameterValue; + + /** The inline reference of `nodeId`'s parameter `parameterIndex`, by signature. */ + const inlineReferenceOf = ( + flow: Flow | undefined, + nodeId: NodeId, + parameterIndex: number, + signature: string, + ): SchematizedSubFlowValue => { + const owner = flow?.nodes?.nodes?.find((n) => n?.id === nodeId); + const value = owner?.parameters?.nodes?.[parameterIndex]?.value as { + references?: {signature?: string; value?: unknown}[]; + }; + return value?.references?.find((r) => r?.signature === signature) + ?.value as SchematizedSubFlowValue; + }; + + it("enriches inline sub-flow references inside a list literal", () => { + // `tools: ["${item_0}", "${item_1}"]` — one tool directly maps a function, + // the other runs its own node tree. Both are SubFlowValues living in the + // literal's `references` rather than in a parameter slot of their own, and + // both must still be schematized. + const flow = flowWithNodes([ + node(LIST_NODE_ID, "custom::ai::agent", [ + literalWithReferences(["${item_0}", "${item_1}"], [ + {signature: "item_0", value: subFlowCalling("std::number::as_text")}, + {signature: "item_1", value: subFlowStartingAt(BODY_NODE_ID)}, + ]), + ]), + node(BODY_NODE_ID, "std::number::as_text", [ + subFlowInputReference(LIST_NODE_ID, 0), + ]), + ]); + + const result = getFlowSchemas( + flow, + [...FUNCTION_SIGNATURES, TOOLS_FN], + DATA_TYPES, + ); + + // The direct mapping *is* the function, so its own signature + // `(number: NUMBER): TEXT` drives the I/O. + const mapped = inlineReferenceOf(result, LIST_NODE_ID, 0, "item_0"); + expect(mapped.inputSchema).toEqual({ + type: "object", + additionalProperties: false, + properties: {number: {type: "number"}}, + required: ["number"], + }); + expect(mapped.outputSchema).toEqual({type: "string"}); + + // The node-tree sub-flow is contextually typed by the list's element type + // `TRANSFORM`, so its input is keyed by `item` with T + // resolved to NUMBER. Its body only computes a value (no + // `std::control::return`), so the generated lambda returns void. + const tree = inlineReferenceOf(result, LIST_NODE_ID, 0, "item_1"); + expect(tree.inputSchema).toEqual({ + type: "object", + additionalProperties: false, + properties: {item: {type: "number"}}, + required: ["item"], + }); + expect(tree.outputSchema).toEqual({type: "null"}); + + // The original SubFlowValue fields survive the enrichment. + expect(tree.__typename).toBe("SubFlowValue"); + expect(tree.startingNodeId).toBe(BODY_NODE_ID); + }); + + it("matches inline sub-flows by their position in the literal, not by reference order", () => { + // The references are declared in the reverse order of their appearance in + // the list, with two different signatures. Each must get the schemas of + // its own function — proving the sub-flows are matched positionally + // against the generated code rather than by the order of `references`. + const flow = flowWithNodes([ + node(LIST_NODE_ID, "custom::ai::agent", [ + literalWithReferences(["${first}", "${second}"], [ + {signature: "second", value: subFlowCalling("std::boolean::as_number")}, + {signature: "first", value: subFlowCalling("std::number::as_text")}, + ]), + ]), + ]); + + const result = getFlowSchemas( + flow, + [...FUNCTION_SIGNATURES, TOOLS_FN], + DATA_TYPES, + ); + + // `(number: NUMBER): TEXT` + const first = inlineReferenceOf(result, LIST_NODE_ID, 0, "first"); + expect(first.inputSchema.properties).toEqual({number: {type: "number"}}); + expect(first.outputSchema).toEqual({type: "string"}); + + // `(value: BOOLEAN): NUMBER` + const second = inlineReferenceOf(result, LIST_NODE_ID, 0, "second"); + expect(second.inputSchema.properties).toEqual({value: {type: "boolean"}}); + expect(second.outputSchema).toEqual({type: "number"}); + }); + + it("enriches an inline sub-flow nested in a literal reference", () => { + // `tools: "${outer}"` where `outer` is itself a literal `["${inner}"]` + // holding the sub-flow — the schemas must reach a sub-flow at any depth. + const flow = flowWithNodes([ + node(LIST_NODE_ID, "custom::ai::agent", [ + literalWithReferences("${outer}", [ + { + signature: "outer", + value: literalWithReferences(["${inner}"], [ + { + signature: "inner", + value: subFlowCalling("std::number::as_text"), + }, + ]), + }, + ]), + ]), + ]); + + const result = getFlowSchemas( + flow, + [...FUNCTION_SIGNATURES, TOOLS_FN], + DATA_TYPES, + ); + + const outer = inlineReferenceOf(result, LIST_NODE_ID, 0, "outer") as unknown as { + references?: {signature?: string; value?: SchematizedSubFlowValue}[]; + }; + const inner = outer.references?.find((r) => r?.signature === "inner")?.value; + + expect(inner?.inputSchema).toEqual({ + type: "object", + additionalProperties: false, + properties: {number: {type: "number"}}, + required: ["number"], + }); + expect(inner?.outputSchema).toEqual({type: "string"}); + }); + it("leaves nodes without sub-flow parameters untouched", () => { const flow = flowWithNodes([ node(LIST_NODE_ID, "std::number::add", [literal(1), literal(2)]),