openagentflow 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,363 @@
1
+ /**
2
+ * OpenAgentFlow — LangGraph Adapter
3
+ *
4
+ * Transforms the OpenAgentFlow IR into executable LangGraph Python code.
5
+ * Generates a complete, self-contained Python script that defines a
6
+ * StateGraph workflow using the LangGraph API.
7
+ *
8
+ * Pipeline: IR → [LangGraph Adapter] → Python code (LangGraph StateGraph)
9
+ */
10
+
11
+ import {
12
+ generateHeaderTemplate,
13
+ generateImportsTemplate,
14
+ generateStateClassTemplate,
15
+ generateLlmHelperTemplate,
16
+ generateAgentNodeTemplate,
17
+ generateGraphBuilderTemplate,
18
+ generateMainTemplate,
19
+ } from './templates.js';
20
+
21
+ // ─── IR Type → Python Type Mapping ─────────────────────────────────────────────
22
+
23
+ const PRIMITIVE_TYPE_MAP = {
24
+ string: 'str',
25
+ int: 'int',
26
+ float: 'float',
27
+ bool: 'bool',
28
+ };
29
+
30
+ /**
31
+ * Convert an IR type descriptor (e.g. "list<string>", "map<string,int>")
32
+ * into a Python typing annotation.
33
+ * @param {string} irType
34
+ * @returns {string}
35
+ */
36
+ function irTypeToPython(irType) {
37
+ // Primitives
38
+ if (PRIMITIVE_TYPE_MAP[irType]) {
39
+ return PRIMITIVE_TYPE_MAP[irType];
40
+ }
41
+
42
+ // list<T>
43
+ const listMatch = irType.match(/^list<(.+)>$/);
44
+ if (listMatch) {
45
+ return `List[${irTypeToPython(listMatch[1])}]`;
46
+ }
47
+
48
+ // map<K,V> — need to handle nested generics carefully
49
+ const mapMatch = irType.match(/^map<(.+)>$/);
50
+ if (mapMatch) {
51
+ const inner = mapMatch[1];
52
+ const splitIdx = findTopLevelComma(inner);
53
+ if (splitIdx !== -1) {
54
+ const keyType = inner.substring(0, splitIdx);
55
+ const valType = inner.substring(splitIdx + 1);
56
+ return `Dict[${irTypeToPython(keyType)}, ${irTypeToPython(valType)}]`;
57
+ }
58
+ }
59
+
60
+ // Fallback
61
+ return 'Any';
62
+ }
63
+
64
+ /**
65
+ * Find the index of the top-level comma in a type string,
66
+ * respecting nested angle brackets.
67
+ */
68
+ function findTopLevelComma(str) {
69
+ let depth = 0;
70
+ for (let i = 0; i < str.length; i++) {
71
+ if (str[i] === '<') depth++;
72
+ else if (str[i] === '>') depth--;
73
+ else if (str[i] === ',' && depth === 0) return i;
74
+ }
75
+ return -1;
76
+ }
77
+
78
+ /**
79
+ * Convert an agent ID to a valid Python function name (snake_case).
80
+ * @param {string} id
81
+ * @returns {string}
82
+ */
83
+ function toSnakeCase(id) {
84
+ return id
85
+ .replace(/([A-Z])/g, (m, c, i) => (i > 0 ? '_' : '') + c.toLowerCase())
86
+ .replace(/[^a-z0-9_]/g, '_')
87
+ .replace(/_+/g, '_')
88
+ .toLowerCase();
89
+ }
90
+
91
+ /**
92
+ * Escape a string for use inside a Python triple-quoted string.
93
+ * @param {string} str
94
+ * @returns {string}
95
+ */
96
+ function escapePythonTripleQuote(str) {
97
+ return str
98
+ .replace(/\\/g, '\\\\')
99
+ .replace(/"""/g, '\\"\\"\\"');
100
+ }
101
+
102
+ // ─── LangGraph Adapter ────────────────────────────────────────────────────────
103
+
104
+ export class LangGraphAdapter {
105
+ /**
106
+ * @param {object} ir - The OpenAgentFlow IR document
107
+ * @param {object} [options] - Adapter options
108
+ * @param {object} [options.input] - Initial state values loaded from file or CLI
109
+ */
110
+ constructor(ir, options = {}) {
111
+ this.ir = ir;
112
+ this.options = options;
113
+ }
114
+
115
+ /**
116
+ * Generate LangGraph Python code from the IR.
117
+ * @returns {string} Generated Python source code
118
+ * @throws {Error} If the IR contains unsupported features
119
+ */
120
+ generate() {
121
+ const compat = this.checkCompatibility();
122
+ if (!compat.supported) {
123
+ throw new Error(
124
+ `IR is not compatible with LangGraph: ${compat.issues.join('; ')}`
125
+ );
126
+ }
127
+
128
+ // Build intermediate generation model separating compiler logic from templates
129
+ const model = this._buildGenerationModel();
130
+
131
+ // Compose final Python script using reusable templates
132
+ const sections = [
133
+ generateHeaderTemplate(model.header),
134
+ generateImportsTemplate(model.imports),
135
+ generateStateClassTemplate(model.stateClass),
136
+ generateLlmHelperTemplate(model.llmHelper),
137
+ ];
138
+
139
+ for (const agentNode of model.agents) {
140
+ sections.push(generateAgentNodeTemplate(agentNode));
141
+ }
142
+
143
+ sections.push(generateGraphBuilderTemplate(model.graphBuilder));
144
+ sections.push(generateMainTemplate(model.main));
145
+
146
+ return sections.join('\n');
147
+ }
148
+
149
+ /**
150
+ * Validate that the IR can be compiled to LangGraph.
151
+ * @returns {{ supported: boolean, issues: string[] }}
152
+ */
153
+ checkCompatibility() {
154
+ const issues = [];
155
+
156
+ if (!this.ir.graph.entrypoint) {
157
+ issues.push('Missing entrypoint in IR graph');
158
+ }
159
+
160
+ if (this.ir.graph.terminals.length === 0) {
161
+ issues.push('No terminal nodes in IR graph');
162
+ }
163
+
164
+ if (!this.ir.agents || this.ir.agents.length === 0) {
165
+ issues.push('No agents defined in IR');
166
+ }
167
+
168
+ return {
169
+ supported: issues.length === 0,
170
+ issues,
171
+ };
172
+ }
173
+
174
+ /**
175
+ * Build intermediate generation model from IR.
176
+ * Owns compiler logic, IR inspection, validation, type conversion, and structure building.
177
+ * @returns {object}
178
+ */
179
+ _buildGenerationModel() {
180
+ const vars = this.ir.state?.variables ?? [];
181
+ const inputData = this.options?.input || {};
182
+ if (this.options?.input) {
183
+ this._validateInputData(inputData, vars);
184
+ }
185
+
186
+ const header = {
187
+ workflowName: this.ir.workflow.name,
188
+ version: this.ir.version,
189
+ };
190
+
191
+ const typingImports = new Set(['TypedDict', 'Optional']);
192
+ let needsOperator = false;
193
+ for (const v of vars) {
194
+ if (v.type.includes('list<')) typingImports.add('List');
195
+ if (v.type.includes('map<')) typingImports.add('Dict');
196
+ if ((v.options ?? []).some(opt => opt.name === 'reducer')) {
197
+ typingImports.add('Annotated');
198
+ needsOperator = true;
199
+ }
200
+ }
201
+ const imports = {
202
+ typingImports: Array.from(typingImports),
203
+ needsLlmProviders: this.ir.agents.length > 0,
204
+ needsOperator,
205
+ };
206
+
207
+ const stateClass = {
208
+ fields: vars.map(v => ({
209
+ name: v.name,
210
+ pyType: irTypeToPython(v.type),
211
+ required: (v.options ?? []).some(opt => opt.name === 'required'),
212
+ reducer: (v.options ?? []).some(opt => opt.name === 'reducer'),
213
+ })),
214
+ };
215
+
216
+ const defaultAgent = this.ir.agents[0]; // todo: handle default config in a proper way
217
+ const llmHelper = {
218
+ defaultModel: defaultAgent?.model ?? null,
219
+ defaultTemperature: defaultAgent?.temperature != null ? defaultAgent.temperature : 0.7,
220
+ };
221
+
222
+ const agents = this.ir.agents.map(agent => ({
223
+ fnName: `${toSnakeCase(agent.id)}_node`,
224
+ id: agent.id,
225
+ model: agent.model ?? null,
226
+ temperature: agent.temperature != null ? agent.temperature : 0.7,
227
+ provider: agent.provider ?? null,
228
+ escapedInstructions: escapePythonTripleQuote(agent.instructions),
229
+ inputs: agent.inputs ?? [],
230
+ outputs: agent.outputs ?? [],
231
+ }));
232
+
233
+ const graphBuilder = {
234
+ nodes: this.ir.agents.map(agent => ({
235
+ id: agent.id,
236
+ fnName: `${toSnakeCase(agent.id)}_node`,
237
+ })),
238
+ entrypoint: this.ir.graph.entrypoint,
239
+ edges: this.ir.graph.edges ?? [],
240
+ terminals: this.ir.graph.terminals ?? [],
241
+ };
242
+
243
+ const requiredFields = vars
244
+ .filter(v => (v.options ?? []).some(opt => opt.name === 'required'))
245
+ .map(v => v.name);
246
+
247
+ const main = {
248
+ workflowName: this.ir.workflow.name,
249
+ initialStateFields: vars.map(v => {
250
+ const isRequired = (v.options ?? []).some(opt => opt.name === 'required');
251
+ let defaultVal;
252
+ if (inputData[v.name] !== undefined) {
253
+ defaultVal = this._toPythonLiteral(inputData[v.name]);
254
+ } else if (isRequired) {
255
+ defaultVal = 'None';
256
+ } else {
257
+ defaultVal = this._pythonDefault(v.type);
258
+ }
259
+ return {
260
+ name: v.name,
261
+ defaultVal,
262
+ };
263
+ }),
264
+ requiredFields,
265
+ };
266
+
267
+ return {
268
+ header,
269
+ imports,
270
+ stateClass,
271
+ llmHelper,
272
+ agents,
273
+ graphBuilder,
274
+ main,
275
+ };
276
+ }
277
+
278
+ /**
279
+ * Convert a JavaScript value to a Python literal string.
280
+ */
281
+ _toPythonLiteral(val) {
282
+ if (val === null || val === undefined) return 'None';
283
+ if (typeof val === 'boolean') return val ? 'True' : 'False';
284
+ if (typeof val === 'number') return String(val);
285
+ if (typeof val === 'string') return JSON.stringify(val);
286
+ if (Array.isArray(val)) {
287
+ const items = val.map(item => this._toPythonLiteral(item));
288
+ return `[${items.join(', ')}]`;
289
+ }
290
+ if (typeof val === 'object') {
291
+ const entries = Object.entries(val).map(([k, v]) => `${JSON.stringify(k)}: ${this._toPythonLiteral(v)}`);
292
+ return `{${entries.join(', ')}}`;
293
+ }
294
+ return JSON.stringify(val);
295
+ }
296
+
297
+ /**
298
+ * Validate initial state input data against workflow state variables.
299
+ */
300
+ _validateInputData(inputData, vars) {
301
+ const varMap = new Map(vars.map(v => [v.name, v]));
302
+
303
+ // 1. Unknown keys
304
+ for (const key of Object.keys(inputData)) {
305
+ if (!varMap.has(key)) {
306
+ throw new Error(`Input JSON contains variable "${key}" which is not defined in workflow state`);
307
+ }
308
+ }
309
+
310
+ // 2. Type compatibility
311
+ for (const [key, val] of Object.entries(inputData)) {
312
+ if (val === null || val === undefined) continue;
313
+ const varDef = varMap.get(key);
314
+ const irType = varDef.type;
315
+
316
+ let valid = true;
317
+ let actualType = typeof val;
318
+ if (Array.isArray(val)) actualType = 'list';
319
+
320
+ if (irType === 'string') {
321
+ valid = (typeof val === 'string');
322
+ } else if (irType === 'int') {
323
+ valid = (typeof val === 'number' && Number.isInteger(val));
324
+ if (!valid && typeof val === 'number') actualType = 'float';
325
+ } else if (irType === 'float') {
326
+ valid = (typeof val === 'number');
327
+ } else if (irType === 'bool') {
328
+ valid = (typeof val === 'boolean');
329
+ } else if (irType.startsWith('list<')) {
330
+ valid = Array.isArray(val);
331
+ } else if (irType.startsWith('map<')) {
332
+ valid = (typeof val === 'object' && val !== null && !Array.isArray(val));
333
+ }
334
+
335
+ if (!valid) {
336
+ throw new Error(`Type mismatch for state variable "${key}": expected ${irType}, found ${actualType}`);
337
+ }
338
+ }
339
+
340
+ // 3. Required variables check (if OAF_INPUT_FILE is not set)
341
+ if (!process.env.OAF_INPUT_FILE) {
342
+ for (const v of vars) {
343
+ const isRequired = (v.options ?? []).some(opt => opt.name === 'required');
344
+ if (isRequired && inputData[v.name] === undefined) {
345
+ throw new Error(`Missing required initial state variable: "${v.name}"`);
346
+ }
347
+ }
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Return a Python default value for an IR type descriptor.
353
+ */
354
+ _pythonDefault(irType) {
355
+ if (irType === 'string') return '""';
356
+ if (irType === 'int') return '0';
357
+ if (irType === 'float') return '0.0';
358
+ if (irType === 'bool') return 'False';
359
+ if (irType.startsWith('list<')) return '[]';
360
+ if (irType.startsWith('map<')) return '{}';
361
+ return 'None';
362
+ }
363
+ }