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.
- package/LICENSE +21 -0
- package/README.md +262 -0
- package/adapters/langgraph/demo_template.js +104 -0
- package/adapters/langgraph/index.js +363 -0
- package/adapters/langgraph/templates.js +502 -0
- package/cli/env.js +193 -0
- package/cli/index.js +580 -0
- package/compiler/compiler.js +134 -0
- package/compiler/index.js +7 -0
- package/compiler/ir-generator.js +142 -0
- package/compiler/validator.js +594 -0
- package/examples/hello.oaf +14 -0
- package/examples/software-dev.oaf +60 -0
- package/examples/summarize-input.json +3 -0
- package/examples/summarize.oaf +28 -0
- package/examples/support-triage-input.json +3 -0
- package/examples/support-triage.oaf +49 -0
- package/package.json +44 -0
- package/parser/ast.js +240 -0
- package/parser/index.js +9 -0
- package/parser/lexer.js +376 -0
- package/parser/parser.js +505 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAgentFlow — LangGraph Python Templates
|
|
3
|
+
*
|
|
4
|
+
* This module contains the reusable Python templates and runtime helper stubs
|
|
5
|
+
* used by the LangGraph adapter. It has no knowledge of the OpenAgentFlow compiler,
|
|
6
|
+
* IR parsing, or JavaScript implementation.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { generateDemoHelperTemplate } from './demo_template.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Generate the Python file header comment block.
|
|
13
|
+
* @param {object} params
|
|
14
|
+
* @param {string} params.workflowName
|
|
15
|
+
* @param {string} params.version
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
export function generateHeaderTemplate({ workflowName, version }) {
|
|
19
|
+
return [
|
|
20
|
+
`"""`,
|
|
21
|
+
`OpenAgentFlow — Generated LangGraph Workflow`,
|
|
22
|
+
``,
|
|
23
|
+
`Workflow: ${workflowName}`,
|
|
24
|
+
`Generated by: OpenAgentFlow Compiler v${version}`,
|
|
25
|
+
``,
|
|
26
|
+
`This file was auto-generated from an .oaf workflow definition.`,
|
|
27
|
+
`Do not edit manually — regenerate from the source .oaf file.`,
|
|
28
|
+
`"""`,
|
|
29
|
+
``,
|
|
30
|
+
].join('\n');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Generate Python imports block.
|
|
35
|
+
* @param {object} params
|
|
36
|
+
* @param {string[]} params.typingImports - Array of typing symbols (e.g. ['Dict', 'List', 'Optional', 'TypedDict'])
|
|
37
|
+
* @param {boolean} params.needsLlmProviders - Whether to include provider import checks
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
export function generateImportsTemplate({ typingImports, needsLlmProviders, needsOperator }) {
|
|
41
|
+
const lines = [
|
|
42
|
+
`import os`,
|
|
43
|
+
`import sys`,
|
|
44
|
+
`import json`,
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
if (needsOperator) {
|
|
48
|
+
lines.push(`import operator`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
lines.push(`from typing import ${typingImports.sort().join(', ')}`);
|
|
52
|
+
lines.push(``);
|
|
53
|
+
lines.push(`from langgraph.graph import StateGraph, END`);
|
|
54
|
+
|
|
55
|
+
if (needsLlmProviders) {
|
|
56
|
+
lines.push(``);
|
|
57
|
+
lines.push(`# ─── Environment Variable Hierarchy ──────────────────────────────────────────`);
|
|
58
|
+
lines.push(`def _load_env_hierarchy():`);
|
|
59
|
+
lines.push(` """Load environment variables from local .env and ~/.oaf/.env if not already set."""`);
|
|
60
|
+
lines.push(` def parse_env_file(path):`);
|
|
61
|
+
lines.push(` if not os.path.exists(path):`);
|
|
62
|
+
lines.push(` return {}`);
|
|
63
|
+
lines.push(` res = {}`);
|
|
64
|
+
lines.push(` try:`);
|
|
65
|
+
lines.push(` with open(path, "r", encoding="utf-8") as f:`);
|
|
66
|
+
lines.push(` for line in f:`);
|
|
67
|
+
lines.push(` line = line.strip()`);
|
|
68
|
+
lines.push(` if not line or line.startswith("#") or "=" not in line:`);
|
|
69
|
+
lines.push(` continue`);
|
|
70
|
+
lines.push(` k, v = line.split("=", 1)`);
|
|
71
|
+
lines.push(` k, v = k.strip(), v.strip()`);
|
|
72
|
+
lines.push(` if (v.startswith('"') and v.endswith('"')) or (v.startswith("'") and v.endswith("'")):`);
|
|
73
|
+
lines.push(` v = v[1:-1]`);
|
|
74
|
+
lines.push(` res[k] = v`);
|
|
75
|
+
lines.push(` except Exception:`);
|
|
76
|
+
lines.push(` pass`);
|
|
77
|
+
lines.push(` return res`);
|
|
78
|
+
lines.push(``);
|
|
79
|
+
lines.push(` local_path = os.path.join(os.getcwd(), ".env")`);
|
|
80
|
+
lines.push(` global_path = os.path.join(os.path.expanduser("~"), ".oaf", ".env")`);
|
|
81
|
+
lines.push(``);
|
|
82
|
+
lines.push(` local_env = parse_env_file(local_path)`);
|
|
83
|
+
lines.push(` global_env = parse_env_file(global_path)`);
|
|
84
|
+
lines.push(``);
|
|
85
|
+
lines.push(` for k, v in local_env.items():`);
|
|
86
|
+
lines.push(` if k not in os.environ:`);
|
|
87
|
+
lines.push(` os.environ[k] = v`);
|
|
88
|
+
lines.push(` for k, v in global_env.items():`);
|
|
89
|
+
lines.push(` if k not in os.environ:`);
|
|
90
|
+
lines.push(` os.environ[k] = v`);
|
|
91
|
+
lines.push(``);
|
|
92
|
+
lines.push(`_load_env_hierarchy()`);
|
|
93
|
+
lines.push(``);
|
|
94
|
+
lines.push(`# LLM providers — Gemini, OpenAI, Anthropic`);
|
|
95
|
+
lines.push(`_LLM_PROVIDER = "demo" if os.environ.get("OAF_DEMO") == "1" else None`);
|
|
96
|
+
lines.push(`if os.environ.get("OAF_DEMO") != "1":`);
|
|
97
|
+
lines.push(` try:`);
|
|
98
|
+
lines.push(` from langchain_google_genai import ChatGoogleGenerativeAI`);
|
|
99
|
+
lines.push(` if os.environ.get("GOOGLE_API_KEY"):`);
|
|
100
|
+
lines.push(` _LLM_PROVIDER = "gemini"`);
|
|
101
|
+
lines.push(` except ImportError:`);
|
|
102
|
+
lines.push(` pass`);
|
|
103
|
+
lines.push(``);
|
|
104
|
+
lines.push(` try:`);
|
|
105
|
+
lines.push(` from langchain_openai import ChatOpenAI`);
|
|
106
|
+
lines.push(` if os.environ.get("OPENAI_API_KEY") and _LLM_PROVIDER is None:`);
|
|
107
|
+
lines.push(` _LLM_PROVIDER = "openai"`);
|
|
108
|
+
lines.push(` except ImportError:`);
|
|
109
|
+
lines.push(` pass`);
|
|
110
|
+
lines.push(``);
|
|
111
|
+
lines.push(` try:`);
|
|
112
|
+
lines.push(` from langchain_anthropic import ChatAnthropic`);
|
|
113
|
+
lines.push(` if os.environ.get("ANTHROPIC_API_KEY") and _LLM_PROVIDER is None:`);
|
|
114
|
+
lines.push(` _LLM_PROVIDER = "anthropic"`);
|
|
115
|
+
lines.push(` except ImportError:`);
|
|
116
|
+
lines.push(` pass`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
lines.push('');
|
|
120
|
+
return lines.join('\n');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Generate the WorkflowState TypedDict definition.
|
|
125
|
+
* @param {object} params
|
|
126
|
+
* @param {Array<{ name: string, pyType: string }>} params.fields
|
|
127
|
+
* @returns {string}
|
|
128
|
+
*/
|
|
129
|
+
export function generateStateClassTemplate({ fields }) {
|
|
130
|
+
const lines = [
|
|
131
|
+
`# ─── State Schema ───────────────────────────────────────────────────────────`,
|
|
132
|
+
``,
|
|
133
|
+
`class WorkflowState(TypedDict, total=False):`,
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
if (!fields || fields.length === 0) {
|
|
137
|
+
lines.push(` pass`);
|
|
138
|
+
} else {
|
|
139
|
+
for (const field of fields) {
|
|
140
|
+
const comment = field.required ? ` # @required` : '';
|
|
141
|
+
const baseType = `Optional[${field.pyType}]`;
|
|
142
|
+
const typeStr = field.reducer ? `Annotated[${baseType}, operator.add]` : baseType;
|
|
143
|
+
lines.push(` ${field.name}: ${typeStr}${comment}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
lines.push('');
|
|
148
|
+
return lines.join('\n');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Generate the runtime LLM helper (`get_llm`).
|
|
153
|
+
* @param {object} params
|
|
154
|
+
* @param {string|null} params.defaultModel
|
|
155
|
+
* @param {number} params.defaultTemperature
|
|
156
|
+
* @returns {string}
|
|
157
|
+
*/
|
|
158
|
+
export function generateLlmHelperTemplate({ defaultModel, defaultTemperature }) {
|
|
159
|
+
const defaultModelArg = defaultModel != null ? `"${defaultModel}"` : `None`;
|
|
160
|
+
const lines = [
|
|
161
|
+
generateDemoHelperTemplate(),
|
|
162
|
+
`# ─── LLM Helper ─────────────────────────────────────────────────────────────`,
|
|
163
|
+
``,
|
|
164
|
+
`def get_llm(model: Optional[str] = ${defaultModelArg}, temperature: float = ${defaultTemperature}, provider: Optional[str] = None):`,
|
|
165
|
+
` """Get the LLM instance. Uses provided model directly; falls back to OAF_DEFAULT_MODEL or raises error."""`,
|
|
166
|
+
` target_model = model if model else os.environ.get("OAF_DEFAULT_MODEL")`,
|
|
167
|
+
` override_model = os.environ.get("OAF_OVERRIDE_MODEL")`,
|
|
168
|
+
` if override_model:`,
|
|
169
|
+
` target_model = override_model`,
|
|
170
|
+
` provider = None`,
|
|
171
|
+
``,
|
|
172
|
+
` if not target_model:`,
|
|
173
|
+
` raise RuntimeError(`,
|
|
174
|
+
` "No model specified and no default model configured. "`,
|
|
175
|
+
` "Please specify a 'model' property in your .oaf agent definition or set the OAF_DEFAULT_MODEL environment variable."`,
|
|
176
|
+
` )`,
|
|
177
|
+
``,
|
|
178
|
+
` target_provider = provider`,
|
|
179
|
+
` if not target_provider and target_model:`,
|
|
180
|
+
` if target_model.startswith("claude-"):`,
|
|
181
|
+
` target_provider = "anthropic"`,
|
|
182
|
+
` elif target_model.startswith("gpt-") or target_model.startswith("o1") or target_model.startswith("o3"):`,
|
|
183
|
+
` target_provider = "openai"`,
|
|
184
|
+
` elif target_model.startswith("gemini-") or target_model.startswith("gemma-"):`,
|
|
185
|
+
` target_provider = "gemini"`,
|
|
186
|
+
` if not target_provider:`,
|
|
187
|
+
` target_provider = _LLM_PROVIDER`,
|
|
188
|
+
``,
|
|
189
|
+
` # --- DEMO HOOK (Cleanly removable) ---`,
|
|
190
|
+
` _demo_inst = _maybe_get_demo_llm(target_model, temperature, target_provider)`,
|
|
191
|
+
` if _demo_inst is not None:`,
|
|
192
|
+
` return _demo_inst`,
|
|
193
|
+
` # -------------------------------------`,
|
|
194
|
+
``,
|
|
195
|
+
` if target_provider == "gemini":`,
|
|
196
|
+
` try:`,
|
|
197
|
+
` _cls = globals().get("ChatGoogleGenerativeAI")`,
|
|
198
|
+
` if _cls is None:`,
|
|
199
|
+
` from langchain_google_genai import ChatGoogleGenerativeAI as _cls`,
|
|
200
|
+
` inst = _cls(model=target_model, temperature=temperature)`,
|
|
201
|
+
` inst._oaf_provider = "gemini"`,
|
|
202
|
+
` return inst`,
|
|
203
|
+
` except ImportError:`,
|
|
204
|
+
` raise RuntimeError("Missing package: pip install langchain-google-genai")`,
|
|
205
|
+
` elif target_provider == "openai":`,
|
|
206
|
+
` try:`,
|
|
207
|
+
` _cls = globals().get("ChatOpenAI")`,
|
|
208
|
+
` if _cls is None:`,
|
|
209
|
+
` from langchain_openai import ChatOpenAI as _cls`,
|
|
210
|
+
` inst = _cls(model=target_model, temperature=temperature)`,
|
|
211
|
+
` inst._oaf_provider = "openai"`,
|
|
212
|
+
` return inst`,
|
|
213
|
+
` except ImportError:`,
|
|
214
|
+
` raise RuntimeError("Missing package: pip install langchain-openai")`,
|
|
215
|
+
` elif target_provider == "anthropic":`,
|
|
216
|
+
` try:`,
|
|
217
|
+
` _cls = globals().get("ChatAnthropic")`,
|
|
218
|
+
` if _cls is None:`,
|
|
219
|
+
` from langchain_anthropic import ChatAnthropic as _cls`,
|
|
220
|
+
` inst = _cls(model=target_model, temperature=temperature)`,
|
|
221
|
+
` inst._oaf_provider = "anthropic"`,
|
|
222
|
+
` return inst`,
|
|
223
|
+
` except ImportError:`,
|
|
224
|
+
` raise RuntimeError("Missing package: pip install langchain-anthropic")`,
|
|
225
|
+
` else:`,
|
|
226
|
+
` raise RuntimeError(`,
|
|
227
|
+
` "No LLM provider available or configured. Set GOOGLE_API_KEY (Gemini), OPENAI_API_KEY (OpenAI), or ANTHROPIC_API_KEY (Anthropic)."`,
|
|
228
|
+
` )`,
|
|
229
|
+
``,
|
|
230
|
+
`def _parse_llm_output(raw_content, outputs):`,
|
|
231
|
+
` """Cleanly parse LLM content (string or list of thinking/text blocks) into state updates."""`,
|
|
232
|
+
` if isinstance(raw_content, list):`,
|
|
233
|
+
` text_parts = []`,
|
|
234
|
+
` for block in raw_content:`,
|
|
235
|
+
` if isinstance(block, dict):`,
|
|
236
|
+
` if block.get("type") == "text" or "text" in block:`,
|
|
237
|
+
` text_parts.append(str(block.get("text", "")))`,
|
|
238
|
+
` elif isinstance(block, str):`,
|
|
239
|
+
` text_parts.append(block)`,
|
|
240
|
+
` result_text = "\\n".join(text_parts).strip()`,
|
|
241
|
+
` else:`,
|
|
242
|
+
` result_text = str(raw_content).strip()`,
|
|
243
|
+
``,
|
|
244
|
+
` clean_text = result_text`,
|
|
245
|
+
` if clean_text.startswith("\`\`\`"):`,
|
|
246
|
+
` lines = clean_text.splitlines()`,
|
|
247
|
+
` if lines and lines[0].startswith("\`\`\`"):`,
|
|
248
|
+
` lines = lines[1:]`,
|
|
249
|
+
` if lines and lines[-1].startswith("\`\`\`"):`,
|
|
250
|
+
` lines = lines[:-1]`,
|
|
251
|
+
` clean_text = "\\n".join(lines).strip()`,
|
|
252
|
+
``,
|
|
253
|
+
` if len(outputs) == 1:`,
|
|
254
|
+
` out_key = outputs[0]`,
|
|
255
|
+
` try:`,
|
|
256
|
+
` parsed = json.loads(clean_text)`,
|
|
257
|
+
` if isinstance(parsed, dict) and out_key in parsed:`,
|
|
258
|
+
` return {out_key: parsed[out_key]}`,
|
|
259
|
+
` except Exception:`,
|
|
260
|
+
` pass`,
|
|
261
|
+
` return {out_key: result_text}`,
|
|
262
|
+
` elif len(outputs) > 1:`,
|
|
263
|
+
` target_text = clean_text`,
|
|
264
|
+
` if not (target_text.startswith("{") and target_text.endswith("}")):`,
|
|
265
|
+
` start_idx = target_text.find("{")`,
|
|
266
|
+
` end_idx = target_text.rfind("}")`,
|
|
267
|
+
` if start_idx != -1 and end_idx != -1 and end_idx > start_idx:`,
|
|
268
|
+
` target_text = target_text[start_idx:end_idx + 1]`,
|
|
269
|
+
` try:`,
|
|
270
|
+
` parsed = json.loads(target_text)`,
|
|
271
|
+
` if isinstance(parsed, dict):`,
|
|
272
|
+
` updates = {}`,
|
|
273
|
+
` for out_key in outputs:`,
|
|
274
|
+
` if out_key in parsed:`,
|
|
275
|
+
` updates[out_key] = parsed[out_key]`,
|
|
276
|
+
` if updates:`,
|
|
277
|
+
` return updates`,
|
|
278
|
+
` except Exception:`,
|
|
279
|
+
` pass`,
|
|
280
|
+
` import re`,
|
|
281
|
+
` updates = {}`,
|
|
282
|
+
` for out_key in outputs:`,
|
|
283
|
+
` pattern = re.compile("""(?:^|\\n)[ \\t]*[-*]?[ \\t]*(?:["'])?""" + re.escape(out_key) + """(?:["'])?[ \\t]*[:=][ \\t]*(.+)""", re.IGNORECASE)`,
|
|
284
|
+
` match = pattern.search(clean_text)`,
|
|
285
|
+
` if match:`,
|
|
286
|
+
` val_str = match.group(1).strip()`,
|
|
287
|
+
` try:`,
|
|
288
|
+
` updates[out_key] = json.loads(val_str)`,
|
|
289
|
+
` except Exception:`,
|
|
290
|
+
` if val_str.isdigit() or (val_str.startswith("-") and val_str[1:].isdigit()):`,
|
|
291
|
+
` updates[out_key] = int(val_str)`,
|
|
292
|
+
` elif val_str.lower() in ("true", "false"):`,
|
|
293
|
+
` updates[out_key] = (val_str.lower() == "true")`,
|
|
294
|
+
` else:`,
|
|
295
|
+
` updates[out_key] = val_str`,
|
|
296
|
+
` if updates:`,
|
|
297
|
+
` return updates`,
|
|
298
|
+
` return {outputs[0]: result_text}`,
|
|
299
|
+
` else:`,
|
|
300
|
+
` return {}`,
|
|
301
|
+
``,
|
|
302
|
+
];
|
|
303
|
+
return lines.join('\n');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Generate a single Agent node function.
|
|
308
|
+
* @param {object} params
|
|
309
|
+
* @param {string} params.fnName - Python function name (snake_case)
|
|
310
|
+
* @param {string} params.id - Agent ID
|
|
311
|
+
* @param {string|null} params.model - Model string
|
|
312
|
+
* @param {number} params.temperature - Numeric temperature
|
|
313
|
+
* @param {string|null} params.provider - Provider ('gemini'|'openai'|null)
|
|
314
|
+
* @param {string} params.escapedInstructions - Triple-quote escaped instructions
|
|
315
|
+
* @param {string[]} params.inputs - Array of input variable names
|
|
316
|
+
* @param {string[]} params.outputs - Array of output variable names
|
|
317
|
+
* @returns {string}
|
|
318
|
+
*/
|
|
319
|
+
export function generateAgentNodeTemplate({
|
|
320
|
+
fnName,
|
|
321
|
+
id,
|
|
322
|
+
model,
|
|
323
|
+
temperature,
|
|
324
|
+
provider,
|
|
325
|
+
escapedInstructions,
|
|
326
|
+
inputs,
|
|
327
|
+
outputs,
|
|
328
|
+
}) {
|
|
329
|
+
const modelArg = model != null ? `"${model}"` : `None`;
|
|
330
|
+
const providerArg = provider ? `"${provider}"` : `None`;
|
|
331
|
+
const formatPrompt = outputs.length > 1
|
|
332
|
+
? `\\n\\nIMPORTANT: You must respond ONLY with a valid JSON object containing exactly these fields: ${outputs.join(', ')}. Do not include any other text, markdown, or commentary outside the JSON object.`
|
|
333
|
+
: '';
|
|
334
|
+
|
|
335
|
+
const lines = [
|
|
336
|
+
`def ${fnName}(state: WorkflowState) -> WorkflowState:`,
|
|
337
|
+
` """Agent: ${id}"""`,
|
|
338
|
+
` llm = get_llm(model=${modelArg}, temperature=${temperature}, provider=${providerArg})`,
|
|
339
|
+
` print(f'[${id}] Running agent (model=${modelArg}, provider={getattr(llm, "_oaf_provider", "unknown")})')`,
|
|
340
|
+
``,
|
|
341
|
+
` system_prompt = """${escapedInstructions}${formatPrompt}"""`,
|
|
342
|
+
``,
|
|
343
|
+
];
|
|
344
|
+
|
|
345
|
+
if (inputs && inputs.length > 0) {
|
|
346
|
+
lines.push(` # Collect input from state`);
|
|
347
|
+
lines.push(` user_parts = []`);
|
|
348
|
+
for (const input of inputs) {
|
|
349
|
+
lines.push(` if state.get("${input}") is not None:`);
|
|
350
|
+
lines.push(` user_parts.append(f"${input}: {state['${input}']}")`);
|
|
351
|
+
}
|
|
352
|
+
lines.push(` user_message = "\\n".join(user_parts) if user_parts else "No input provided."`);
|
|
353
|
+
} else {
|
|
354
|
+
lines.push(` user_message = json.dumps({k: v for k, v in state.items() if v is not None})`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
lines.push(``);
|
|
358
|
+
lines.push(` messages = [`);
|
|
359
|
+
lines.push(` {"role": "system", "content": system_prompt},`);
|
|
360
|
+
lines.push(` {"role": "user", "content": user_message},`);
|
|
361
|
+
lines.push(` ]`);
|
|
362
|
+
lines.push(``);
|
|
363
|
+
lines.push(` response = llm.invoke(messages)`);
|
|
364
|
+
lines.push(` return _parse_llm_output(response.content, ${JSON.stringify(outputs)})`);
|
|
365
|
+
lines.push(``);
|
|
366
|
+
lines.push(``);
|
|
367
|
+
return lines.join('\n');
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Generate the build_graph() function.
|
|
372
|
+
* @param {object} params
|
|
373
|
+
* @param {Array<{ id: string, fnName: string }>} params.nodes
|
|
374
|
+
* @param {string} params.entrypoint
|
|
375
|
+
* @param {Array<{ source: string, target: string }>} params.edges
|
|
376
|
+
* @param {string[]} params.terminals
|
|
377
|
+
* @returns {string}
|
|
378
|
+
*/
|
|
379
|
+
export function generateGraphBuilderTemplate({ nodes, entrypoint, edges, terminals }) {
|
|
380
|
+
const lines = [
|
|
381
|
+
`# ─── Graph Construction ──────────────────────────────────────────────────────`,
|
|
382
|
+
``,
|
|
383
|
+
`def build_graph() -> StateGraph:`,
|
|
384
|
+
` """Build and compile the LangGraph workflow."""`,
|
|
385
|
+
` graph = StateGraph(WorkflowState)`,
|
|
386
|
+
``,
|
|
387
|
+
` # Register agent nodes`,
|
|
388
|
+
];
|
|
389
|
+
|
|
390
|
+
for (const node of nodes) {
|
|
391
|
+
lines.push(` graph.add_node("${node.id}", ${node.fnName})`);
|
|
392
|
+
}
|
|
393
|
+
lines.push(``);
|
|
394
|
+
|
|
395
|
+
lines.push(` # Set entry point`);
|
|
396
|
+
lines.push(` graph.set_entry_point("${entrypoint}")`);
|
|
397
|
+
lines.push(``);
|
|
398
|
+
|
|
399
|
+
if (edges && edges.length > 0) {
|
|
400
|
+
lines.push(` # Add edges between agents`);
|
|
401
|
+
for (const edge of edges) {
|
|
402
|
+
lines.push(` graph.add_edge("${edge.source}", "${edge.target}")`);
|
|
403
|
+
}
|
|
404
|
+
lines.push(``);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
lines.push(` # Connect terminal nodes to END`);
|
|
408
|
+
for (const terminal of terminals) {
|
|
409
|
+
lines.push(` graph.add_edge("${terminal}", END)`);
|
|
410
|
+
}
|
|
411
|
+
lines.push(``);
|
|
412
|
+
lines.push(` return graph.compile()`);
|
|
413
|
+
lines.push(``);
|
|
414
|
+
return lines.join('\n');
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Generate the __main__ execution block.
|
|
419
|
+
* @param {object} params
|
|
420
|
+
* @param {string} params.workflowName
|
|
421
|
+
* @param {Array<{ name: string, defaultVal: string }>} params.initialStateFields
|
|
422
|
+
* @returns {string}
|
|
423
|
+
*/
|
|
424
|
+
export function generateMainTemplate({ workflowName, initialStateFields, requiredFields = [] }) {
|
|
425
|
+
const lines = [
|
|
426
|
+
`# ─── Execution ──────────────────────────────────────────────────────────────`,
|
|
427
|
+
``,
|
|
428
|
+
`if __name__ == "__main__":`,
|
|
429
|
+
` if hasattr(sys.stdout, 'reconfigure'):`,
|
|
430
|
+
` sys.stdout.reconfigure(encoding='utf-8')`,
|
|
431
|
+
` # Ensure at least one API key is set`,
|
|
432
|
+
` if not _LLM_PROVIDER:`,
|
|
433
|
+
` print("Error: No LLM provider configured.")`,
|
|
434
|
+
` print(" Set GOOGLE_API_KEY (Gemini, preferred) or OPENAI_API_KEY (OpenAI).")`,
|
|
435
|
+
` print(" Example: export GOOGLE_API_KEY='your-key-here'")`,
|
|
436
|
+
` exit(1)`,
|
|
437
|
+
``,
|
|
438
|
+
` print(f"Default fallback provider: {_LLM_PROVIDER}")`,
|
|
439
|
+
` app = build_graph()`,
|
|
440
|
+
``,
|
|
441
|
+
` # Initial state — populate input fields before running`,
|
|
442
|
+
` initial_state: WorkflowState = {`,
|
|
443
|
+
];
|
|
444
|
+
|
|
445
|
+
for (const field of initialStateFields) {
|
|
446
|
+
lines.push(` "${field.name}": ${field.defaultVal},`);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
lines.push(` }`);
|
|
450
|
+
lines.push(``);
|
|
451
|
+
lines.push(` # Override initial_state from --input / -i file or OAF_INPUT_FILE if provided at runtime`);
|
|
452
|
+
lines.push(` input_file = os.environ.get("OAF_INPUT_FILE")`);
|
|
453
|
+
lines.push(` args = sys.argv[1:]`);
|
|
454
|
+
lines.push(` for idx in range(len(args)):`);
|
|
455
|
+
lines.push(` if args[idx] in ("--input", "-i") and idx + 1 < len(args):`);
|
|
456
|
+
lines.push(` input_file = args[idx + 1]`);
|
|
457
|
+
lines.push(` break`);
|
|
458
|
+
lines.push(` elif args[idx].startswith("--input="):`);
|
|
459
|
+
lines.push(` input_file = args[idx].split("=")[1]`);
|
|
460
|
+
lines.push(` break`);
|
|
461
|
+
lines.push(` elif args[idx].startswith("-i="):`);
|
|
462
|
+
lines.push(` input_file = args[idx].split("=")[1]`);
|
|
463
|
+
lines.push(` break`);
|
|
464
|
+
lines.push(``);
|
|
465
|
+
lines.push(` if input_file:`);
|
|
466
|
+
lines.push(` try:`);
|
|
467
|
+
lines.push(` with open(input_file, "r", encoding="utf-8") as f:`);
|
|
468
|
+
lines.push(` runtime_input = json.load(f)`);
|
|
469
|
+
lines.push(` if isinstance(runtime_input, dict):`);
|
|
470
|
+
lines.push(` initial_state.update(runtime_input)`);
|
|
471
|
+
lines.push(` except Exception as err:`);
|
|
472
|
+
lines.push(` print(f"Error reading input file '{input_file}': {err}", file=sys.stderr)`);
|
|
473
|
+
lines.push(` sys.exit(1)`);
|
|
474
|
+
lines.push(``);
|
|
475
|
+
|
|
476
|
+
if (requiredFields && requiredFields.length > 0) {
|
|
477
|
+
lines.push(` # Validate required state variables`);
|
|
478
|
+
lines.push(` missing_required = [`);
|
|
479
|
+
lines.push(` f for f in ${JSON.stringify(requiredFields)}`);
|
|
480
|
+
lines.push(` if initial_state.get(f) is None or (isinstance(initial_state.get(f), str) and initial_state.get(f) == "")`);
|
|
481
|
+
lines.push(` ]`);
|
|
482
|
+
lines.push(` if missing_required:`);
|
|
483
|
+
lines.push(` print(f"Error: Missing required state variables: {', '.join(missing_required)}", file=sys.stderr)`);
|
|
484
|
+
lines.push(` sys.exit(1)`);
|
|
485
|
+
lines.push(``);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
lines.push(` print(f"Running workflow: ${workflowName}")`);
|
|
489
|
+
lines.push(` print(f"{'-' * 50}")`);
|
|
490
|
+
lines.push(``);
|
|
491
|
+
lines.push(` result = app.invoke(initial_state)`);
|
|
492
|
+
lines.push(``);
|
|
493
|
+
lines.push(` print(f"{'-' * 50}")`);
|
|
494
|
+
lines.push(` print("Workflow completed. Final state:")`);
|
|
495
|
+
lines.push(` # --- DEMO HOOK (Cleanly removable) ---`);
|
|
496
|
+
lines.push(` _final_state = _format_demo_state(result, initial_state) if "_format_demo_state" in globals() else result`);
|
|
497
|
+
lines.push(` print(json.dumps(_final_state, indent=2, default=str))`);
|
|
498
|
+
lines.push(` # -------------------------------------`);
|
|
499
|
+
lines.push(``);
|
|
500
|
+
|
|
501
|
+
return lines.join('\n');
|
|
502
|
+
}
|
package/cli/env.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, statSync } from 'fs';
|
|
2
|
+
import { join, resolve, dirname } from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import readline from 'readline';
|
|
5
|
+
|
|
6
|
+
const colors = {
|
|
7
|
+
reset: '\x1b[0m',
|
|
8
|
+
bold: '\x1b[1m',
|
|
9
|
+
red: '\x1b[31m',
|
|
10
|
+
green: '\x1b[32m',
|
|
11
|
+
yellow: '\x1b[33m',
|
|
12
|
+
cyan: '\x1b[36m',
|
|
13
|
+
dim: '\x1b[2m',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Parse raw .env file content into a key-value object.
|
|
18
|
+
* Supports comments, single/double quotes, and basic trimming.
|
|
19
|
+
*/
|
|
20
|
+
export function parseDotEnv(content) {
|
|
21
|
+
const result = {};
|
|
22
|
+
const lines = content.split(/\r?\n/);
|
|
23
|
+
|
|
24
|
+
for (let line of lines) {
|
|
25
|
+
line = line.trim();
|
|
26
|
+
if (!line || line.startsWith('#')) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const eqIndex = line.indexOf('=');
|
|
31
|
+
if (eqIndex === -1) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const key = line.slice(0, eqIndex).trim();
|
|
36
|
+
let val = line.slice(eqIndex + 1).trim();
|
|
37
|
+
|
|
38
|
+
if (val.startsWith('"') && val.endsWith('"') && val.length >= 2) {
|
|
39
|
+
val = val.slice(1, -1).replace(/\\n/g, '\n').replace(/\\"/g, '"');
|
|
40
|
+
} else if (val.startsWith("'") && val.endsWith("'") && val.length >= 2) {
|
|
41
|
+
val = val.slice(1, -1);
|
|
42
|
+
} else {
|
|
43
|
+
// Remove trailing comments for unquoted values
|
|
44
|
+
const commentIndex = val.indexOf(' #');
|
|
45
|
+
if (commentIndex !== -1) {
|
|
46
|
+
val = val.slice(0, commentIndex).trim();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (key) {
|
|
51
|
+
result[key] = val;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve environment variables across the 4-tier hierarchy:
|
|
60
|
+
* 1. Inline CLI overrides & System Environment Variables (already in process.env)
|
|
61
|
+
* 2. Local Project .env (sitting next to targetFilePath or process.cwd())
|
|
62
|
+
* 3. System Environment Variables (merged with #1 in Node process.env)
|
|
63
|
+
* 4. Global OAF Store (~/.oaf/.env)
|
|
64
|
+
*/
|
|
65
|
+
export function resolveEnvHierarchy(targetFilePath = null) {
|
|
66
|
+
const initialEnvKeys = new Set(Object.keys(process.env));
|
|
67
|
+
const resolvedSources = new Map();
|
|
68
|
+
|
|
69
|
+
for (const key of initialEnvKeys) {
|
|
70
|
+
resolvedSources.set(key, 'inline_or_system');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (process.env.OAF_IGNORE_DOTENV) {
|
|
74
|
+
return { sources: resolvedSources };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const globalEnvPath = join(os.homedir(), '.oaf', '.env');
|
|
78
|
+
let globalEnv = {};
|
|
79
|
+
if (existsSync(globalEnvPath)) {
|
|
80
|
+
try {
|
|
81
|
+
globalEnv = parseDotEnv(readFileSync(globalEnvPath, 'utf-8'));
|
|
82
|
+
} catch (err) {}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let localEnvPath = null;
|
|
86
|
+
if (targetFilePath && existsSync(targetFilePath)) {
|
|
87
|
+
const absTarget = resolve(targetFilePath);
|
|
88
|
+
try {
|
|
89
|
+
const dir = statSync(absTarget).isDirectory() ? absTarget : dirname(absTarget);
|
|
90
|
+
localEnvPath = join(dir, '.env');
|
|
91
|
+
} catch (err) {
|
|
92
|
+
localEnvPath = join(process.cwd(), '.env');
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
localEnvPath = join(process.cwd(), '.env');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let localEnv = {};
|
|
99
|
+
if (localEnvPath && existsSync(localEnvPath)) {
|
|
100
|
+
try {
|
|
101
|
+
localEnv = parseDotEnv(readFileSync(localEnvPath, 'utf-8'));
|
|
102
|
+
} catch (err) {}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Tier 2: Local Project .env (overrides Tier 4 when not in process.env already)
|
|
106
|
+
for (const [key, val] of Object.entries(localEnv)) {
|
|
107
|
+
if (!initialEnvKeys.has(key)) {
|
|
108
|
+
process.env[key] = val;
|
|
109
|
+
resolvedSources.set(key, 'local_env');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Tier 4: Global OAF Store (~/.oaf/.env)
|
|
114
|
+
for (const [key, val] of Object.entries(globalEnv)) {
|
|
115
|
+
if (!initialEnvKeys.has(key) && !localEnv.hasOwnProperty(key)) {
|
|
116
|
+
process.env[key] = val;
|
|
117
|
+
resolvedSources.set(key, 'global_env');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
sources: resolvedSources,
|
|
123
|
+
globalEnvPath,
|
|
124
|
+
localEnvPath: (localEnvPath && existsSync(localEnvPath)) ? localEnvPath : null,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Interactive setup for `oaf auth`.
|
|
130
|
+
* Prompts for API keys and writes them to ~/.oaf/.env with 600 permissions.
|
|
131
|
+
*/
|
|
132
|
+
export async function setupAuth() {
|
|
133
|
+
const oafDir = join(os.homedir(), '.oaf');
|
|
134
|
+
const envPath = join(oafDir, '.env');
|
|
135
|
+
|
|
136
|
+
if (!existsSync(oafDir)) {
|
|
137
|
+
mkdirSync(oafDir, { recursive: true, mode: 0o700 });
|
|
138
|
+
try { chmodSync(oafDir, 0o700); } catch (e) {}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let existing = {};
|
|
142
|
+
if (existsSync(envPath)) {
|
|
143
|
+
try {
|
|
144
|
+
existing = parseDotEnv(readFileSync(envPath, 'utf-8'));
|
|
145
|
+
} catch (err) {}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const rl = readline.createInterface({
|
|
149
|
+
input: process.stdin,
|
|
150
|
+
output: process.stdout,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const ask = (query) => new Promise((resolvePrompt) => rl.question(query, resolvePrompt));
|
|
154
|
+
|
|
155
|
+
console.log(`${colors.bold}OpenAgentFlow Authentication Setup${colors.reset}\n`);
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const openaiKey = await ask(`? Enter your OpenAI API Key (leave blank to skip): `);
|
|
159
|
+
const anthropicKey = await ask(`? Enter your Anthropic API Key (leave blank to skip): `);
|
|
160
|
+
const geminiKey = await ask(`? Enter your Google Gemini API Key (leave blank to skip): `);
|
|
161
|
+
|
|
162
|
+
rl.close();
|
|
163
|
+
|
|
164
|
+
const newEnv = { ...existing };
|
|
165
|
+
if (openaiKey !== undefined && openaiKey.trim() !== '') newEnv.OPENAI_API_KEY = openaiKey.trim();
|
|
166
|
+
if (anthropicKey !== undefined && anthropicKey.trim() !== '') newEnv.ANTHROPIC_API_KEY = anthropicKey.trim();
|
|
167
|
+
if (geminiKey !== undefined && geminiKey.trim() !== '') newEnv.GOOGLE_API_KEY = geminiKey.trim();
|
|
168
|
+
|
|
169
|
+
const lines = [
|
|
170
|
+
`# OpenAgentFlow Global Configuration (~/.oaf/.env)`,
|
|
171
|
+
`# File permissions: 600 (owner read/write only)`,
|
|
172
|
+
];
|
|
173
|
+
|
|
174
|
+
if (newEnv.OPENAI_API_KEY) lines.push(`OPENAI_API_KEY=${newEnv.OPENAI_API_KEY}`);
|
|
175
|
+
if (newEnv.ANTHROPIC_API_KEY) lines.push(`ANTHROPIC_API_KEY=${newEnv.ANTHROPIC_API_KEY}`);
|
|
176
|
+
if (newEnv.GOOGLE_API_KEY) lines.push(`GOOGLE_API_KEY=${newEnv.GOOGLE_API_KEY}`);
|
|
177
|
+
|
|
178
|
+
for (const [k, v] of Object.entries(newEnv)) {
|
|
179
|
+
if (k !== 'OPENAI_API_KEY' && k !== 'ANTHROPIC_API_KEY' && k !== 'GOOGLE_API_KEY') {
|
|
180
|
+
lines.push(`${k}=${v}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
writeFileSync(envPath, lines.join('\n') + '\n', { mode: 0o600 });
|
|
185
|
+
try { chmodSync(envPath, 0o600); } catch (e) {}
|
|
186
|
+
|
|
187
|
+
console.log(`\n${colors.green}✔ Saved to ~/.oaf/.env${colors.reset}`);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
rl.close();
|
|
190
|
+
console.error(`\n${colors.red}✗ Authentication setup failed:${colors.reset} ${err.message}`);
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
}
|