Skip to content
Merged
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
20 changes: 9 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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');
Expand Down
14 changes: 10 additions & 4 deletions docs/api-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| --- | --- |
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 20 additions & 0 deletions src/flows/FlowExecutor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down
12 changes: 5 additions & 7 deletions src/flows/FlowExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -693,11 +693,10 @@ export class FlowExecutor {
*/
private static evaluateCondition(condition: string, variables: Record<string, any>): 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;
}
Expand All @@ -708,8 +707,7 @@ export class FlowExecutor {
*/
private static evaluateExpression(expression: string, variables: Record<string, any>): 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}`);
Expand Down
13 changes: 13 additions & 0 deletions src/flows/converters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
19 changes: 17 additions & 2 deletions src/flows/converters.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { nanoid } from 'nanoid';
import {
EditorStep,
EditorShapeStep,
StepNode,
SequenceNode,
ParallelNode,
Expand All @@ -20,7 +21,7 @@ const convertChildren = (steps: EditorStep[]): any[] =>
// ------------------------------------
// EditorStep -> FlowDefinition, one converter per step type
// ------------------------------------
type StepConverters = { [K in EditorStep['type']]: (step: Extract<EditorStep, { type: K }>) => any };
type StepConverters = { [K in EditorShapeStep['type']]: (step: Extract<EditorShapeStep, { type: K }>) => any };

const STEP_CONVERTERS: StepConverters = {
// STEP - Basic agent execution
Expand Down Expand Up @@ -127,13 +128,27 @@ const STEP_CONVERTER_BY_TYPE: ReadonlyMap<string, (step: any) => 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<Record<string, string>> = {
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);
}
Expand Down
43 changes: 43 additions & 0 deletions src/flows/flowTypes.test-d.ts
Original file line number Diff line number Diff line change
@@ -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<EditorStep>();
});

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<EditorStep[]>();
});

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;
});
});
51 changes: 51 additions & 0 deletions src/flows/safeExpression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) =>
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);
});
});
Loading
Loading