diff --git a/README.md b/README.md index dcf664f3f..b53c00fce 100644 --- a/README.md +++ b/README.md @@ -248,16 +248,15 @@ Flows orchestrate multi-step workflows within a single agent: import { FlowBuilder, FlowExecutor, type EditorStep } from '@loushy/build-ai-agent'; // FlowBuilder is a metadata builder: setCode/setName/setInputs/setFlow(...).build(). -// The executable node types ('sequence', 'llmCall', 'oneOf', 'setVariable', ...) are -// those handled by FlowExecutor; EditorStep currently types the editor-side shapes, -// hence the cast. -const steps = { +// EditorStep covers every node type FlowExecutor runs ('sequence', 'llmCall', +// 'oneOf', 'setVariable', ...). +const steps: EditorStep = { type: 'sequence', steps: [ { type: 'llmCall', prompt: 'Classify this message as billing, technical or sales: {{message}}', outputVariable: 'category' }, { type: 'llmCall', prompt: 'Write a one-line reply for a {{category}} request: {{message}}' }, ], -} as unknown as EditorStep; +}; const flow = new FlowBuilder() .setCode('triage') @@ -468,13 +467,12 @@ const hash = await sha256('password', 'salt'); ### Storage ```typescript -import { StorageService, type FileSystemAdapter } from '@loushy/build-ai-agent'; -import fs from 'node:fs'; -import path from 'node:path'; +import { StorageService } from '@loushy/build-ai-agent'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; -// fs/path are injected as adapters (LOU-A7). Node's `fs` is structurally close -// but its writeFileSync options type is wider, so it needs a cast. -const storage = new StorageService('user-123', 'attachments', fs as unknown as FileSystemAdapter, path); +// fs/path are injected as adapters (LOU-A7); the Node modules satisfy them as-is. +const storage = new StorageService('user-123', 'attachments', fs, path); await storage.saveAttachment(file, 'document.pdf'); const buffer = storage.readAttachment('document.pdf'); diff --git a/docs/api-overview.md b/docs/api-overview.md index 726fe9834..1217564eb 100644 --- a/docs/api-overview.md +++ b/docs/api-overview.md @@ -285,10 +285,16 @@ The built-in context windows and prices are a dated snapshot (see the retrieval `oneOf` branch conditions (and the Agent Forge router node's branch conditions) and `evaluator` node expressions are evaluated by a small built-in expression evaluator. It never compiles or runs host code: there is no `eval`, -`new Function` or `vm` in `src/flows`. Before evaluation, `{{name}}` placeholders -are replaced with the variable's text (so quote string placeholders: -`'{{classify}}' === 'refund'`). The expression is then evaluated against the -flow's variables. +`new Function` or `vm` in `src/flows`. `{{name}}` placeholders are bound as +values, never pasted into the expression text: a bare `{{score}} >= 90` uses the +variable's value, and inside a string literal, `'{{classify}}' === 'refund'` +interpolates the value's text into that literal after the expression has been +tokenized. A variable whose value contains quotes, backslashes or operators +(for example `x' === 'x' || 'a`) is therefore just data and cannot change the +condition's logic. A missing or `null` variable is an empty string inside a +literal; used bare it contributes nothing, so `{{missing}} >= 90` is a syntax +error and the condition counts as not matched. The expression is evaluated +against the flow's variables. | Form | Examples | | --- | --- | diff --git a/package.json b/package.json index 618537c84..54e0b4bad 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "test:evals:judge": "vitest run --config vitest.judge.config.ts", "lint": "eslint src", "typecheck": "tsc --noEmit", - "test:types": "vitest run --typecheck.only", + "test:types": "tsc -p tsconfig.test-d.json && vitest run --typecheck.only", "example:tracing:console": "tsx examples/tracing/run-console.ts", "example:tracing:otel": "tsx examples/tracing/run-otel.ts", "example:support-bot": "tsx examples/support-bot/index.ts", diff --git a/src/flows/FlowExecutor.test.ts b/src/flows/FlowExecutor.test.ts index 7a1e7e8f6..dc304325b 100644 --- a/src/flows/FlowExecutor.test.ts +++ b/src/flows/FlowExecutor.test.ts @@ -286,6 +286,26 @@ describe('FlowExecutor', () => { expect(result.output).toBe('B'); }); + it('binds {{vars}} as values: a hostile value cannot change the condition logic', async () => { + context.variables = { input: "x' === 'x' || 'a" }; + + const flow: AgentFlow = { + code: 'test-flow', + name: 'Test Flow', + flow: { + type: 'oneOf', + options: [ + { condition: "'{{input}}' === 'admin'", step: { type: 'return', value: 'admin' } }, + { step: { type: 'return', value: 'guest' } }, + ], + }, + }; + + const result = await FlowExecutor.execute(flow, context); + + expect(result.output).toBe('guest'); + }); + it('should execute default option when no conditions match', async () => { context.variables = { score: 50 }; diff --git a/src/flows/FlowExecutor.ts b/src/flows/FlowExecutor.ts index f6c4f45bd..6c7bf50a3 100644 --- a/src/flows/FlowExecutor.ts +++ b/src/flows/FlowExecutor.ts @@ -693,11 +693,10 @@ export class FlowExecutor { */ private static evaluateCondition(condition: string, variables: Record): boolean { try { - // Interpolate {{vars}}, then evaluate with the safe expression evaluator - // (./safeExpression). Invalid/unsupported syntax is a failed condition - // (false), exactly as a throwing eval() was before. - const interpolated = this.interpolate(condition, variables); - return !!evaluateSafeExpression(interpolated, variables); + // Evaluate with the safe expression evaluator (./safeExpression), which + // binds {{vars}} as values. Invalid/unsupported syntax is a failed + // condition (false), exactly as a throwing eval() was before. + return !!evaluateSafeExpression(condition, variables, { bindPlaceholders: true }); } catch { return false; } @@ -708,8 +707,7 @@ export class FlowExecutor { */ private static evaluateExpression(expression: string, variables: Record): any { try { - const interpolated = this.interpolate(expression, variables); - return evaluateSafeExpression(interpolated, variables); + return evaluateSafeExpression(expression, variables, { bindPlaceholders: true }); } catch (error) { const detail = error instanceof ExpressionError ? ` (${error.message})` : ''; throw new Error(`Failed to evaluate expression: ${expression}${detail}`); diff --git a/src/flows/converters.test.ts b/src/flows/converters.test.ts index 2fcd24d81..0b130e979 100644 --- a/src/flows/converters.test.ts +++ b/src/flows/converters.test.ts @@ -18,6 +18,19 @@ describe('Flow Converters', () => { expect(result.id).toBeDefined(); }); + it('should map executor-shaped nodes to the unknownAgent placeholder instead of throwing', () => { + const nodes: EditorStep[] = [ + { type: 'oneOf', options: [{ step: { type: 'end' } }] }, + { type: 'forEach', items: [1, 2] }, + { type: 'evaluator', expression: 'true' }, + { type: 'llmCall', prompt: 'hi' }, + ]; + + for (const node of nodes) { + expect(convertToFlowDefinition(node).agent).toBe('unknownAgent'); + } + }); + it('should convert sequence', () => { const step: EditorStep = { type: 'sequence', diff --git a/src/flows/converters.ts b/src/flows/converters.ts index cf19ae714..e8e0174e3 100644 --- a/src/flows/converters.ts +++ b/src/flows/converters.ts @@ -1,6 +1,7 @@ import { nanoid } from 'nanoid'; import { EditorStep, + EditorShapeStep, StepNode, SequenceNode, ParallelNode, @@ -20,7 +21,7 @@ const convertChildren = (steps: EditorStep[]): any[] => // ------------------------------------ // EditorStep -> FlowDefinition, one converter per step type // ------------------------------------ -type StepConverters = { [K in EditorStep['type']]: (step: Extract) => any }; +type StepConverters = { [K in EditorShapeStep['type']]: (step: Extract) => any }; const STEP_CONVERTERS: StepConverters = { // STEP - Basic agent execution @@ -127,13 +128,27 @@ const STEP_CONVERTER_BY_TYPE: ReadonlyMap any> = new Map( Object.entries(STEP_CONVERTERS) ); +// Executor-side shapes of node types that also have an editor shape +// (`options` / `items` / `expression` instead of branches / inputFlow / subFlow); +// they have no flows-ai equivalent, so they convert to the unknownAgent placeholder. +const EXECUTOR_ONLY_FIELD: Readonly> = { + oneOf: 'options', + forEach: 'items', + evaluator: 'expression', +}; + +function isExecutorShaped(step: EditorStep): boolean { + const field = EXECUTOR_ONLY_FIELD[step.type]; + return field !== undefined && field in step; +} + /** * Convert EditorStep to flows-ai compatible FlowDefinition * This recursively transforms the internal EditorStep structure * to the format expected by the flows-ai execution engine */ export function convertToFlowDefinition(step: EditorStep): any { - const convert = STEP_CONVERTER_BY_TYPE.get(step.type); + const convert = isExecutorShaped(step) ? undefined : STEP_CONVERTER_BY_TYPE.get(step.type); if (convert) { return convert(step); } diff --git a/src/flows/flowTypes.test-d.ts b/src/flows/flowTypes.test-d.ts new file mode 100644 index 000000000..4ff2537c5 --- /dev/null +++ b/src/flows/flowTypes.test-d.ts @@ -0,0 +1,43 @@ +import { describe, it, expectTypeOf } from 'vitest'; +import type { EditorStep } from '../types'; + +describe('EditorStep covers every node kind FlowExecutor runs', () => { + it('accepts executor-side nodes without a cast', () => { + const steps: EditorStep = { + type: 'sequence', + steps: [ + { type: 'llmCall', prompt: 'Hi {{name}}', model: 'm', temperature: 0, maxTokens: 10, outputVariable: 'out' }, + { type: 'toolCall', tool: 'search', arguments: { q: '{{out}}' }, outputVariable: 'hits' }, + { type: 'setVariable', variable: 'n', value: 1 }, + { type: 'forEach', items: '$hits', itemVariable: 'hit', indexVariable: 'i', step: { type: 'end', value: '$hit' } }, + { type: 'evaluator', expression: 'n > 0' }, + { + type: 'oneOf', + options: [ + { condition: "'{{out}}' === 'a'", step: { type: 'return', value: 'a' } }, + { step: { type: 'throw', message: 'no' } }, + ], + }, + ], + }; + expectTypeOf(steps).toMatchTypeOf(); + }); + + it('still accepts the editor-side shapes', () => { + const steps: EditorStep[] = [ + { type: 'step', agent: 'a', input: 'x' }, + { type: 'oneOf', branches: [{ when: 'true', flow: { type: 'step', agent: 'a', input: 'x' } }] }, + { type: 'condition', condition: 'true', trueFlow: { type: 'end' }, falseFlow: { type: 'end' } }, + ]; + expectTypeOf(steps).toMatchTypeOf(); + }); + + it('rejects an unknown node type and wrong field types', () => { + // @ts-expect-error - not a node kind + const bad: EditorStep = { type: 'nope' }; + // @ts-expect-error - llmCall prompt must be a string + const bad2: EditorStep = { type: 'llmCall', prompt: 3 }; + void bad; + void bad2; + }); +}); diff --git a/src/flows/safeExpression.test.ts b/src/flows/safeExpression.test.ts index fec9869c3..d2a09dde7 100644 --- a/src/flows/safeExpression.test.ts +++ b/src/flows/safeExpression.test.ts @@ -192,3 +192,54 @@ describe('evaluateSafeExpression: rejected syntax', () => { expect(() => evaluateSafeExpression('!'.repeat(5000) + 'flag', scope)).toThrow(/nested too deeply/); }); }); + +describe('evaluateSafeExpression: bound placeholders', () => { + const bound = (expr: string, scope: Record) => + evaluateSafeExpression(expr, scope, { bindPlaceholders: true }); + + it('keeps the quoted and bare forms working for benign values', () => { + expect(bound("'{{role}}' === 'admin'", { role: 'admin' })).toBe(true); + expect(bound("'{{role}}' === 'admin'", { role: 'user' })).toBe(false); + expect(bound('{{score}} >= 90', { score: 95 })).toBe(true); + expect(bound('{{score}} >= 90', { score: 80 })).toBe(false); + expect(bound("{{ok}} && '{{a}}-{{b}}' === 'x-y'", { ok: true, a: 'x', b: 'y' })).toBe(true); + }); + + it('treats an injection attempt in a quoted placeholder as data', () => { + const hostile = "x' === 'x' || 'a"; + expect(bound("'{{input}}' === 'admin'", { input: hostile })).toBe(false); + expect(bound('"{{input}}" === "admin"', { input: 'x" === "x" || "a' })).toBe(false); + expect(bound("'{{input}}' === 'admin'", { input: "' || true || '" })).toBe(false); + expect(bound("'{{input}}' === 'x\\' === \\'x\\' || \\'a'", { input: hostile })).toBe(true); + }); + + it('handles each quote type and backslashes inside values literally', () => { + expect(bound("'{{v}}' === \"it's\"", { v: "it's" })).toBe(true); + expect(bound("'{{v}}' === 'say \"hi\"'", { v: 'say "hi"' })).toBe(true); + expect(bound("'{{v}}'.length", { v: 'a\\nb' })).toBe(4); + expect(bound("'{{v}}' === 'a'", { v: 'a\\' })).toBe(false); + expect(bound("'{{v}}'.endsWith('\\\\')", { v: 'a\\' })).toBe(true); + expect(bound("'{{v}}'.includes('\\\\q')", { v: 'x\\q' })).toBe(true); + }); + + it('binds a bare string value as a string, never as code or a variable', () => { + expect(bound("{{v}} === 'process'", { v: 'process' })).toBe(true); + expect(bound("{{v}} === 'a'", { v: "'a' || 'b'" })).toBe(false); + }); + + it('treats missing variables like the old text substitution', () => { + expect(bound("'{{nope}}' === ''", {})).toBe(true); + expect(bound("'{{nope}}' === ''", { nope: null })).toBe(true); + expect(bound('{{nope}}', {})).toBeUndefined(); + expect(() => bound('{{nope}} >= 90', {})).toThrow(ExpressionError); + expect(bound("'{{z}}'", { z: 0 })).toBe('0'); + }); + + it('does not read inherited properties as variables', () => { + expect(bound("'{{toString}}' === ''", {})).toBe(true); + }); + + it('leaves placeholders unsupported unless binding is enabled', () => { + expect(() => evaluateSafeExpression('{{score}} >= 90', { score: 95 })).toThrow(ExpressionError); + }); +}); diff --git a/src/flows/safeExpression.ts b/src/flows/safeExpression.ts index 1a4f805d4..57e30597e 100644 --- a/src/flows/safeExpression.ts +++ b/src/flows/safeExpression.ts @@ -46,9 +46,11 @@ const SUPPORTED_SUMMARY = type Scope = Record; interface Token { - readonly kind: 'num' | 'str' | 'id' | 'op' | 'eof'; + readonly kind: 'num' | 'str' | 'id' | 'op' | 'val' | 'eof'; readonly value: string | number; readonly pos: number; + /** The bound value of a bare `{{name}}` placeholder (kind `val` only). */ + readonly data?: unknown; } type Node = @@ -100,20 +102,43 @@ function readNumber(src: string, start: number): { token: Token; end: number } { return { token: { kind: 'num', value: Number(src.slice(start, end)), pos: start }, end }; } -function readString(src: string, start: number): { token: Token; end: number } { +const PLACEHOLDER = /\{\{(\w+)\}\}/y; + +/** Match a `{{name}}` placeholder starting exactly at `start`. */ +function matchPlaceholder(src: string, start: number): { name: string; end: number } | undefined { + PLACEHOLDER.lastIndex = start; + const match = PLACEHOLDER.exec(src); + return match ? { name: match[1], end: start + match[0].length } : undefined; +} + +/** + * Text a placeholder contributes inside a string literal: the variable's + * string form, or '' when it is missing or null (as the former text + * substitution did). + */ +function placeholderText(name: string, scope: Scope): string { + const value = Object.hasOwn(scope, name) ? scope[name] : undefined; + return value === undefined || value === null ? '' : String(value); +} + +/** One piece of a string literal: an interpolated placeholder, an escape, or a plain character. */ +function readStringPiece(src: string, i: number, scope?: Scope): { text: string; end: number } { + const placeholder = scope && src[i] === '{' ? matchPlaceholder(src, i) : undefined; + if (placeholder && scope) return { text: placeholderText(placeholder.name, scope), end: placeholder.end }; + if (src[i] !== '\\') return { text: src[i], end: i + 1 }; + const escaped = ESCAPES[src[i + 1] ?? '']; + if (escaped === undefined) throw new ExpressionError(src, i + 1, 'unsupported escape sequence in string'); + return { text: escaped, end: i + 2 }; +} + +function readString(src: string, start: number, scope?: Scope): { token: Token; end: number } { const quote = src[start]; let out = ''; let i = start + 1; while (i < src.length && src[i] !== quote) { - if (src[i] === '\\') { - i++; - const escaped = ESCAPES[src[i] ?? '']; - if (escaped === undefined) throw new ExpressionError(src, i, 'unsupported escape sequence in string'); - out += escaped; - } else { - out += src[i]; - } - i++; + const piece = readStringPiece(src, i, scope); + out += piece.text; + i = piece.end; } if (i >= src.length) throw new ExpressionError(src, start, 'unterminated string literal'); return { token: { kind: 'str', value: out, pos: start }, end: i + 1 }; @@ -128,31 +153,51 @@ function readOperator(src: string, start: number): Token { throw new ExpressionError(src, start, `unexpected character '${src[start]}'`); } -function tokenize(src: string): Token[] { +/** + * Token for a bare `{{name}}`: the variable's value, bound as data. A missing + * or null variable contributes no token (the old text substitution produced + * an empty string there, so `{{x}} > 1` stays a syntax error). + */ +function readBoundPlaceholder(name: string, pos: number, scope: Scope): Token[] { + const value = Object.hasOwn(scope, name) ? scope[name] : undefined; + return value === undefined || value === null ? [] : [{ kind: 'val', value: '', pos, data: value }]; +} + +function readIdentifier(src: string, start: number): { token: Token; end: number } { + let end = start + 1; + while (isIdentPart(src[end])) end++; + return { token: { kind: 'id', value: src.slice(start, end), pos: start }, end }; +} + +interface Read { + tokens: Token[]; + end: number; +} + +/** Read whatever token starts at `i` (none, for whitespace and missing placeholders). */ +function readNext(src: string, i: number, scope?: Scope): Read { + const ch = src[i]; + const placeholder = scope && ch === '{' ? matchPlaceholder(src, i) : undefined; + if (placeholder && scope) return { tokens: readBoundPlaceholder(placeholder.name, i, scope), end: placeholder.end }; + if (/\s/.test(ch)) return { tokens: [], end: i + 1 }; + let read: { token: Token; end: number }; + if (isDigit(ch)) read = readNumber(src, i); + else if (ch === "'" || ch === '"') read = readString(src, i, scope); + else if (isIdentStart(ch)) read = readIdentifier(src, i); + else { + const token = readOperator(src, i); + read = { token, end: i + String(token.value).length }; + } + return { tokens: [read.token], end: read.end }; +} + +function tokenize(src: string, scope?: Scope): Token[] { const tokens: Token[] = []; let i = 0; while (i < src.length) { - const ch = src[i]; - if (/\s/.test(ch)) { - i++; - } else if (isDigit(ch)) { - const { token, end } = readNumber(src, i); - tokens.push(token); - i = end; - } else if (ch === "'" || ch === '"') { - const { token, end } = readString(src, i); - tokens.push(token); - i = end; - } else if (isIdentStart(ch)) { - let end = i + 1; - while (isIdentPart(src[end])) end++; - tokens.push({ kind: 'id', value: src.slice(i, end), pos: i }); - i = end; - } else { - const token = readOperator(src, i); - tokens.push(token); - i += String(token.value).length; - } + const read = readNext(src, i, scope); + tokens.push(...read.tokens); + i = read.end; } tokens.push({ kind: 'eof', value: '', pos: src.length }); return tokens; @@ -278,6 +323,7 @@ class Parser { private parsePrimary(): Node { const token = this.next(); if (token.kind === 'num' || token.kind === 'str') return { t: 'lit', value: token.value }; + if (token.kind === 'val') return { t: 'lit', value: token.data }; if (token.kind === 'id') { const name = token.value as string; if (Object.hasOwn(LITERAL_KEYWORDS, name)) return { t: 'lit', value: LITERAL_KEYWORDS[name] }; @@ -422,14 +468,32 @@ class Evaluator { } } +/** Options for {@link evaluateSafeExpression}. */ +export interface EvaluateOptions { + /** + * Bind `{{name}}` placeholders from the scope as values. Bare, `{{score}}` + * is the variable's value; inside a string literal, `'{{name}}'` + * interpolates the value's string form into that literal. Values are never + * re-parsed, so quotes, backslashes or operators in them stay data. + * Missing and null variables read as an empty string inside a literal and + * as nothing when bare. + */ + readonly bindPlaceholders?: boolean; +} + /** * Evaluate an expression string against a variable scope without executing * any host code. An empty expression evaluates to `undefined`. * * @throws {ExpressionError} on malformed or unsupported syntax. */ -export function evaluateSafeExpression(expression: string, scope: Scope): unknown { - if (expression.trim() === '') return undefined; - const ast = new Parser(expression, tokenize(expression)).parse(); +export function evaluateSafeExpression( + expression: string, + scope: Scope, + options: EvaluateOptions = {} +): unknown { + const tokens = tokenize(expression, options.bindPlaceholders ? scope : undefined); + if (tokens.length === 1) return undefined; // only the eof token: empty expression + const ast = new Parser(expression, tokens).parse(); return new Evaluator(expression, scope).eval(ast); } diff --git a/src/storage/StorageService.test-d.ts b/src/storage/StorageService.test-d.ts new file mode 100644 index 000000000..f0f4e508d --- /dev/null +++ b/src/storage/StorageService.test-d.ts @@ -0,0 +1,28 @@ +import { describe, it, expectTypeOf } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { FileSystemAdapter, PathAdapter } from './StorageService'; + +describe('StorageService adapters accept the real Node modules', () => { + it('node:fs is assignable to FileSystemAdapter without a cast', () => { + expectTypeOf(fs).toMatchTypeOf(); + const adapter: FileSystemAdapter = fs; + void adapter; + }); + + it('node:path is assignable to PathAdapter without a cast', () => { + expectTypeOf(path).toMatchTypeOf(); + }); + + it('a hand-written adapter with a plain string encoding still fits', () => { + const custom = { + existsSync: (_path: string) => true, + mkdirSync: (_path: string, _options?: { recursive?: boolean }) => undefined, + writeFileSync: (_path: string, _data: string | Uint8Array, _encoding?: string) => undefined, + unlinkSync: (_path: string) => undefined, + readFileSync: fs.readFileSync, + rmSync: (_path: string) => undefined, + }; + expectTypeOf(custom).toMatchTypeOf(); + }); +}); diff --git a/src/storage/StorageService.ts b/src/storage/StorageService.ts index d6366e139..32f26fdd7 100644 --- a/src/storage/StorageService.ts +++ b/src/storage/StorageService.ts @@ -14,14 +14,17 @@ declare const process: any; /** * Minimal shape of the Node.js `fs` module (or a compatible implementation) * required by StorageService. Only the methods actually called on the - * injected `fs` instance are declared here. + * injected `fs` instance are declared here. Encodings are typed as the one + * value StorageService passes (`'utf8'`) so the real `node:fs` module is + * assignable without a cast: `new StorageService(id, dir, fs, path)` with + * `import * as fs from 'node:fs'`. */ export interface FileSystemAdapter { existsSync(path: string): boolean; mkdirSync(path: string, options?: { recursive?: boolean }): void; - writeFileSync(path: string, data: string | Uint8Array, encoding?: string): void; + writeFileSync(path: string, data: string | Uint8Array, encoding?: 'utf8'): void; unlinkSync(path: string): void; - readFileSync(path: string, encoding: string): string; + readFileSync(path: string, encoding: 'utf8'): string; readFileSync(path: string): Buffer; rmSync(path: string): void; } diff --git a/src/types/flow.ts b/src/types/flow.ts index 9ef36b0d0..bb42a099d 100644 --- a/src/types/flow.ts +++ b/src/types/flow.ts @@ -108,9 +108,35 @@ export type FlowExecutionMode = 'sync' | 'async'; export type FlowOutputMode = 'stream' | 'buffer'; /** - * Editor step types + * A flow node: every shape `FlowExecutor` can run, discriminated on `type`. + * + * The first group are the editor-side shapes (also what the flows-ai + * converters understand); the second group are the executor-side shapes the + * handler map in `FlowExecutor` reads. `oneOf`, `forEach` and `evaluator` + * appear in both groups with different fields: `oneOf` has `branches` + * (editor) or `options` (executor), and so on, so narrow on those fields too. + * + * @example + * ```ts + * const steps: EditorStep = { + * type: 'sequence', + * steps: [ + * { type: 'llmCall', prompt: 'Classify: {{message}}', outputVariable: 'category' }, + * { + * type: 'oneOf', + * options: [ + * { condition: "'{{category}}' === 'billing'", step: { type: 'return', value: 'billing' } }, + * { step: { type: 'return', value: 'other' } }, + * ], + * }, + * ], + * }; + * ``` */ -export type EditorStep = +export type EditorStep = EditorShapeStep | RuntimeStep; + +/** Editor-side node shapes (what the flows-ai converters accept). */ +export type EditorShapeStep = | StepNode | SequenceNode | ParallelNode @@ -123,6 +149,18 @@ export type EditorStep = | ConditionNode | LoopNode; +/** Executor-side node shapes, with the fields `FlowExecutor`'s handlers read. */ +export type RuntimeStep = + | OneOfOptionsNode + | ForEachItemsNode + | ExpressionEvaluatorNode + | LLMCallNode + | ToolCallNode + | SetVariableNode + | ReturnNode + | EndNode + | ThrowNode; + export interface StepNode { type: 'step'; agent: string; @@ -191,3 +229,90 @@ export interface LoopNode { condition: string; loopFlow: EditorStep; } + +/** One option of a {@link OneOfOptionsNode}. No `condition` means "default". */ +export interface OneOfOption { + /** Expression with `{{var}}` placeholders; see "Flow expressions" in the docs. */ + condition?: string; + step: EditorStep; +} + +/** `oneOf` as executed: runs the first option whose condition holds. */ +export interface OneOfOptionsNode { + type: 'oneOf'; + id?: string; + options: OneOfOption[]; +} + +/** `forEach` as executed: `items` is a literal array or a `$variable` reference. */ +export interface ForEachItemsNode { + type: 'forEach'; + id?: string; + items: unknown[] | string; + /** Variable holding the current item (default `item`). */ + itemVariable?: string; + /** Variable holding the current index (default `index`). */ + indexVariable?: string; + step?: EditorStep; +} + +/** `evaluator` as executed: evaluates `expression` and returns the result. */ +export interface ExpressionEvaluatorNode { + type: 'evaluator'; + id?: string; + expression: string; +} + +/** Calls the agent's provider with an interpolated prompt. */ +export interface LLMCallNode { + type: 'llmCall'; + id?: string; + /** Prompt template; `{{var}}` placeholders are replaced by variable text. */ + prompt?: string; + model?: string; + temperature?: number; + maxTokens?: number; + /** Variable that receives the response text. */ + outputVariable?: string; +} + +/** Calls a registered tool by name. */ +export interface ToolCallNode { + type: 'toolCall'; + id?: string; + /** Name of the tool in the ToolRegistry. */ + tool: string; + /** Arguments; strings (at any depth) have `{{var}}` placeholders replaced. */ + arguments?: Record; + /** Variable that receives the tool result. */ + outputVariable?: string; +} + +/** Sets a flow variable; a `value` string starting with `$` reads another variable. */ +export interface SetVariableNode { + type: 'setVariable'; + id?: string; + variable: string; + value?: unknown; +} + +/** Returns `value` (a string starting with `$` reads a variable). */ +export interface ReturnNode { + type: 'return'; + id?: string; + value?: unknown; +} + +/** Ends the flow with `value` (a string starting with `$` reads a variable). */ +export interface EndNode { + type: 'end'; + id?: string; + value?: unknown; +} + +/** Fails the flow with `message` (`{{var}}` placeholders are replaced). */ +export interface ThrowNode { + type: 'throw'; + id?: string; + message?: string; +} diff --git a/tsconfig.test-d.json b/tsconfig.test-d.json new file mode 100644 index 000000000..b22600f65 --- /dev/null +++ b/tsconfig.test-d.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "noEmit": true, "noUnusedLocals": false }, + "include": ["src/**/*.test-d.ts", "typings/**/*.d.ts"], + "exclude": ["node_modules", "dist"] +}