JatinAutonomousLabs commited on
Commit
39ee314
·
verified ·
1 Parent(s): dccaee5

Update graph.py

Browse files
Files changed (1) hide show
  1. graph.py +237 -263
graph.py CHANGED
@@ -1,4 +1,4 @@
1
- # graph.py (revised, robust)
2
  import json
3
  import re
4
  import math
@@ -6,53 +6,38 @@ import os
6
  import uuid
7
  import shutil
8
  import zipfile
9
- from typing import TypedDict, List, Dict, Optional, Any
 
10
  from langchain_openai import ChatOpenAI
11
  from langgraph.graph import StateGraph, END
12
  from memory_manager import memory_manager
13
  from code_executor import execute_python_code
14
  from logging_config import setup_logging, get_logger
15
 
16
- # Artifact libs (optional — if not installed, artifact-writing functions will fallback to writing text)
17
- try:
18
- import nbformat
19
- from nbformat.v4 import new_notebook, new_markdown_cell, new_code_cell
20
- except Exception:
21
- nbformat = None
22
-
23
- try:
24
- import pandas as pd
25
- except Exception:
26
- pd = None
27
-
28
- try:
29
- from docx import Document
30
- except Exception:
31
- Document = None
32
-
33
- try:
34
- from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
35
- from reportlab.lib.styles import getSampleStyleSheet
36
- except Exception:
37
- SimpleDocTemplate = None
38
 
39
  # --- Helpers ---
40
- def ensure_list(state: Optional[dict], key: str) -> List[Any]:
41
  """Return a list from state[key], default [] if missing/None/not-list."""
42
- v = (state or {}).get(key)
43
  if v is None:
44
  return []
45
  if isinstance(v, list):
46
  return v
47
  if isinstance(v, tuple):
48
  return list(v)
49
- # if e.g. a single string, keep as list
50
  return [v]
51
 
52
- def ensure_int(state: Optional[dict], key: str, default: int = 0) -> int:
53
  """Return an int from state[key], default if missing/invalid."""
54
  try:
55
- v = (state or {}).get(key)
56
  if v is None:
57
  return default
58
  return int(v)
@@ -60,17 +45,8 @@ def ensure_int(state: Optional[dict], key: str, default: int = 0) -> int:
60
  return default
61
 
62
  def sanitize_path(path: str) -> str:
63
- """Return an absolute normalized path, safe for UI linking."""
64
- try:
65
- return os.path.abspath(path)
66
- except Exception:
67
- return path
68
-
69
- def safe_write_text_file(text: str, out_path: str) -> str:
70
- os.makedirs(os.path.dirname(out_path), exist_ok=True)
71
- with open(out_path, "w", encoding="utf-8") as fh:
72
- fh.write(text or "")
73
- return out_path
74
 
75
  # --- Setup & constants ---
76
  setup_logging()
@@ -79,11 +55,9 @@ INITIAL_MAX_REWORK_CYCLES = 3
79
  GPT4O_INPUT_COST_PER_1K_TOKENS = 0.005
80
  GPT4O_OUTPUT_COST_PER_1K_TOKENS = 0.015
81
  AVG_TOKENS_PER_CALL = 2.0
82
- OUT_DIR = "outputs"
83
- os.makedirs(OUT_DIR, exist_ok=True)
84
 
85
- # --- AgentState definition (non-total so keys optional) ---
86
- class AgentState(TypedDict, total=False):
87
  userInput: str
88
  chatHistory: List[str]
89
  coreObjectivePrompt: str
@@ -94,7 +68,8 @@ class AgentState(TypedDict, total=False):
94
  draftResponse: str
95
  qaFeedback: Optional[str]
96
  approved: bool
97
- execution_path: List[str]
 
98
  rework_cycles: int
99
  max_loops: int
100
  status_update: str
@@ -103,16 +78,14 @@ class AgentState(TypedDict, total=False):
103
  llm = ChatOpenAI(model="gpt-4o", temperature=0.1, max_retries=3, request_timeout=60)
104
 
105
  def parse_json_from_llm(llm_output: str) -> Optional[dict]:
106
- """Robustly try to extract a single JSON object from LLM text."""
107
  try:
108
  if not llm_output:
109
  return None
110
- # Prefer fenced json blocks
111
  match = re.search(r"```json\s*({.*?})\s*```", llm_output, re.DOTALL)
112
  if match:
113
  json_str = match.group(1)
114
  else:
115
- # fallback: first {...}
116
  start = llm_output.find('{')
117
  end = llm_output.rfind('}')
118
  if start == -1 or end == -1:
@@ -120,14 +93,14 @@ def parse_json_from_llm(llm_output: str) -> Optional[dict]:
120
  json_str = llm_output[start:end+1]
121
  return json.loads(json_str)
122
  except Exception as e:
123
- log.debug(f"parse_json_from_llm failed: {e}. snippet: {str(llm_output)[:300]}")
124
  return None
125
 
126
- # --- Artifact type detection & normalization ---
127
- KNOWN_ARTIFACT_TYPES = {"notebook", "excel", "word", "pdf", "image", "repo", "script", "docx"}
128
 
129
- def detect_requested_output_types(text: str) -> Dict[str, Optional[str]]:
130
- """Simple heuristics to detect requested artifact types from user text."""
131
  if not text:
132
  return {"requires_artifact": False, "artifact_type": None, "artifact_hint": None}
133
  t = text.lower()
@@ -137,65 +110,65 @@ def detect_requested_output_types(text: str) -> Dict[str, Optional[str]]:
137
  return {"requires_artifact": True, "artifact_type": "excel", "artifact_hint": "Excel/CSV file"}
138
  if any(k in t for k in ["word document", ".docx", "docx", "word file"]):
139
  return {"requires_artifact": True, "artifact_type": "word", "artifact_hint": "Word document (.docx)"}
140
- if "pdf" in t:
141
  return {"requires_artifact": True, "artifact_type": "pdf", "artifact_hint": "PDF document"}
 
 
142
  if any(k in t for k in ["repo", "repository", "app repo", "dockerfile", "requirements.txt", "package.json"]):
143
  return {"requires_artifact": True, "artifact_type": "repo", "artifact_hint": "application repository (zip)"}
144
- if any(k in t for k in [".py", "python script", "r script", ".R", ".r", ".java", ".js"]):
145
- return {"requires_artifact": True, "artifact_type": "script", "artifact_hint": "language script file"}
146
  return {"requires_artifact": False, "artifact_type": None, "artifact_hint": None}
147
 
148
  def normalize_experiment_type(exp_type: Optional[str], goal_text: str) -> str:
149
- """Map arbitrary LLM experiment_type to a known artifact type; fallback to detection."""
150
- if exp_type:
151
- s = exp_type.strip().lower()
152
- if s in KNOWN_ARTIFACT_TYPES:
153
- return s
154
- if "notebook" in s or "ipynb" in s:
155
- return "notebook"
156
- if "excel" in s or "xlsx" in s:
157
- return "excel"
158
- if "word" in s or "docx" in s:
159
- return "word"
160
- if "pdf" in s:
161
- return "pdf"
162
- if "repo" in s or "repository" in s:
163
- return "repo"
164
- if "script" in s or ".py" in s or "python" in s:
165
- return "script"
 
 
 
 
 
166
  detection = detect_requested_output_types(goal_text or "")
167
  return detection.get("artifact_type") or "docx"
168
 
169
- # --- Artifact writers (safe fallbacks) ---
170
- def write_notebook_from_text(llm_text: str, out_dir: str = OUT_DIR) -> str:
171
- """Create an .ipynb from free text/code blocks using nbformat if available."""
172
- os.makedirs(out_dir, exist_ok=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  uid = uuid.uuid4().hex[:10]
174
  filename = os.path.join(out_dir, f"generated_notebook_{uid}.ipynb")
175
- if nbformat:
176
- code_blocks = re.findall(r"```python\s*(.*?)\s*```", llm_text, re.DOTALL)
177
- generic_code = re.findall(r"```(?:\w+)?\s*(.*?)\s*```", llm_text, re.DOTALL)
178
- # if language-specific code found, use that; otherwise use generic fenced blocks
179
- code_blocks = code_blocks or generic_code
180
- md_parts = re.split(r"```(?:\w+)?\s*.*?\s*```", llm_text, flags=re.DOTALL)
181
- nb = new_notebook()
182
- cells = []
183
- max_len = max(len(md_parts), len(code_blocks))
184
- for i in range(max_len):
185
- if i < len(md_parts) and md_parts[i].strip():
186
- cells.append(new_markdown_cell(md_parts[i].strip()))
187
- if i < len(code_blocks) and code_blocks[i].strip():
188
- cells.append(new_code_cell(code_blocks[i].strip()))
189
- if not cells:
190
- cells = [new_markdown_cell("# Notebook\nNo content parsed from LLM output.")]
191
- nb["cells"] = cells
192
- nbformat.write(nb, filename)
193
- return filename
194
- # fallback: write plain text file with .ipynb extension (so downloads still work)
195
- return safe_write_text_file("# nbformat not installed\n\n" + (llm_text or ""), filename)
196
 
197
- def write_script(code_text: str, language_hint: Optional[str] = None, out_dir: str = OUT_DIR) -> str:
198
- os.makedirs(out_dir, exist_ok=True)
199
  ext = ".txt"
200
  if language_hint:
201
  l = language_hint.lower()
@@ -203,95 +176,79 @@ def write_script(code_text: str, language_hint: Optional[str] = None, out_dir: s
203
  ext = ".py"
204
  elif l == "r" or l == ".r":
205
  ext = ".R"
206
- elif "java" in l:
207
  ext = ".java"
208
- elif "javascript" in l or "js" in l:
209
  ext = ".js"
210
  elif "bash" in l or "sh" in l:
211
  ext = ".sh"
212
  uid = uuid.uuid4().hex[:10]
213
  filename = os.path.join(out_dir, f"generated_script_{uid}{ext}")
214
- return safe_write_text_file(code_text or "", filename)
215
-
216
- def write_docx_from_text(text: str, out_dir: str = OUT_DIR) -> str:
217
- os.makedirs(out_dir, exist_ok=True)
 
 
 
 
218
  uid = uuid.uuid4().hex[:10]
219
  filename = os.path.join(out_dir, f"generated_doc_{uid}.docx")
220
- if Document:
221
- doc = Document()
222
- for para in [p.strip() for p in (text or "").split("\n\n") if p.strip()]:
223
- doc.add_paragraph(para)
224
- doc.save(filename)
225
- return filename
226
- # fallback to txt file with .docx extension
227
- return safe_write_text_file((text or ""), filename)
228
 
229
- def write_excel_from_tables(maybe_table_text: str, out_dir: str = OUT_DIR) -> str:
230
- os.makedirs(out_dir, exist_ok=True)
231
  uid = uuid.uuid4().hex[:10]
232
  filename = os.path.join(out_dir, f"generated_excel_{uid}.xlsx")
233
- if pd:
234
  try:
235
- # try JSON table first
236
- parsed = None
237
- try:
238
- parsed = json.loads(maybe_table_text)
239
- except Exception:
240
- parsed = None
241
  if isinstance(parsed, list):
242
  df = pd.DataFrame(parsed)
243
  elif isinstance(parsed, dict):
244
  df = pd.DataFrame([parsed])
245
  else:
246
- # try csv-like
247
- if "," in (maybe_table_text or "") or "\t" in (maybe_table_text or ""):
248
- from io import StringIO
249
- df = pd.read_csv(StringIO(maybe_table_text))
250
- else:
251
- df = pd.DataFrame({"content": [maybe_table_text or ""]})
252
- # write with engine openpyxl if available
253
- try:
254
- df.to_excel(filename, index=False, engine="openpyxl")
255
- except Exception:
256
- df.to_excel(filename, index=False)
257
- return filename
258
- except Exception as e:
259
- log.debug(f"write_excel_from_tables failed: {e}")
260
- return write_docx_from_text(f"Failed to create excel: {e}\n\nOriginal:\n{maybe_table_text}", out_dir=out_dir)
261
- return write_docx_from_text(maybe_table_text, out_dir=out_dir)
262
-
263
- def write_pdf_from_text(text: str, out_dir: str = OUT_DIR) -> str:
264
- os.makedirs(out_dir, exist_ok=True)
265
  uid = uuid.uuid4().hex[:10]
266
  filename = os.path.join(out_dir, f"generated_doc_{uid}.pdf")
267
- if SimpleDocTemplate:
268
- try:
269
- doc = SimpleDocTemplate(filename)
270
- styles = getSampleStyleSheet()
271
- flowables = []
272
- for para in [p.strip() for p in (text or "").split("\n\n") if p.strip()]:
273
- flowables.append(Paragraph(para.replace("\n", "<br/>"), styles["Normal"]))
274
- flowables.append(Spacer(1, 8))
275
- doc.build(flowables)
276
- return filename
277
- except Exception as e:
278
- log.debug(f"write_pdf_from_text failed: {e}")
279
- return write_docx_from_text(f"Failed to create PDF: {e}\n\nOriginal:\n{text}", out_dir=out_dir)
280
- return write_docx_from_text(text, out_dir=out_dir)
281
-
282
- def build_repo_zip(files_map: Dict[str, str], repo_name: str = "generated_app", out_dir: str = OUT_DIR) -> str:
283
- os.makedirs(out_dir, exist_ok=True)
284
  uid = uuid.uuid4().hex[:8]
285
  repo_dir = os.path.join(out_dir, f"{repo_name}_{uid}")
286
  os.makedirs(repo_dir, exist_ok=True)
287
- for rel_path, content in (files_map or {}).items():
288
  dest = os.path.join(repo_dir, rel_path)
289
  os.makedirs(os.path.dirname(dest), exist_ok=True)
290
  if isinstance(content, str) and os.path.exists(content):
291
  shutil.copyfile(content, dest)
292
  else:
293
  with open(dest, "w", encoding="utf-8") as fh:
294
- fh.write(str(content or ""))
295
  zip_path = os.path.join(out_dir, f"{repo_name}_{uid}.zip")
296
  with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
297
  for root, _, files in os.walk(repo_dir):
@@ -301,91 +258,97 @@ def build_repo_zip(files_map: Dict[str, str], repo_name: str = "generated_app",
301
  zf.write(full, arc)
302
  return zip_path
303
 
304
- # --- Node functions (all robust) ---
305
  def run_triage_agent(state: AgentState):
306
- path = ensure_list(state, 'execution_path') + ["Triage Agent"]
307
- prompt = f"Analyze the user input. Is it a simple conversational greeting or a task? Respond with 'greeting' or 'task'.\n\nUser Input: \"{(state or {}).get('userInput','')}\""
308
  response = llm.invoke(prompt)
309
  content = getattr(response, "content", "") or ""
310
- if "greeting" in content.lower():
311
- return {"draftResponse": "Hello! How can I help you today?", "execution_path": path, "status_update": "Responding to greeting."}
312
- return {"execution_path": path, "status_update": "Request requires a plan. Proceeding..."}
 
 
 
313
 
314
  def run_planner_agent(state: AgentState):
 
315
  path = ensure_list(state, 'execution_path') + ["Planner Agent"]
316
  prompt = (
317
  f"Analyze the user's request. Provide a high-level plan and estimate the number of LLM calls for one loop. "
318
- f"User Request: \"{(state or {}).get('userInput','')}\". Respond in JSON with keys: 'plan' (list of strings), 'estimated_llm_calls_per_loop' (integer)."
319
  )
320
  response = llm.invoke(prompt)
321
  plan_data = parse_json_from_llm(getattr(response, "content", "") or "")
322
  if not plan_data:
323
- plan_data = {"plan": ["Could not parse plan defaulting minimal plan."], "estimated_llm_calls_per_loop": 3}
324
- # cost math
325
- calls_per_loop = int(plan_data.get('estimated_llm_calls_per_loop', 3))
326
  cost_per_loop = (calls_per_loop * AVG_TOKENS_PER_CALL) * ((GPT4O_INPUT_COST_PER_1K_TOKENS + GPT4O_OUTPUT_COST_PER_1K_TOKENS) / 2)
327
  estimated_cost = cost_per_loop * (INITIAL_MAX_REWORK_CYCLES + 1)
328
- plan_data.setdefault('max_loops_initial', INITIAL_MAX_REWORK_CYCLES)
329
  plan_data['estimated_cost_usd'] = round(estimated_cost, 2)
330
  plan_data['cost_per_loop_usd'] = max(0.01, round(cost_per_loop, 3))
331
- # If user requested artifact, set experiment flags
332
- detection = detect_requested_output_types((state or {}).get('userInput','') or (state or {}).get('coreObjectivePrompt','') or "")
333
- if detection.get("requires_artifact"):
334
  plan_data.setdefault('experiment_needed', True)
335
  plan_data.setdefault('experiment_type', detection.get('artifact_type'))
336
  plan_data.setdefault('experiment_goal', f"Produce an artifact: {detection.get('artifact_hint')}. {state.get('userInput','')}")
 
337
  return {"pmPlan": plan_data, "execution_path": path, "status_update": "Plan and cost estimate created. Awaiting approval."}
338
 
339
  def run_memory_retrieval(state: AgentState):
 
340
  path = ensure_list(state, 'execution_path') + ["Memory Retriever"]
341
- try:
342
- relevant_mems = memory_manager.retrieve_relevant_memories((state or {}).get('userInput',''))
343
- except Exception:
344
- relevant_mems = []
345
  if relevant_mems:
346
- context = "\n".join([f"Memory: {getattr(mem,'page_content',str(mem))}" for mem in relevant_mems])
 
347
  else:
348
  context = "No relevant memories found."
 
349
  return {"retrievedMemory": context, "execution_path": path, "status_update": "Searching for relevant past information..."}
350
 
351
  def run_intent_agent(state: AgentState):
 
352
  path = ensure_list(state, 'execution_path') + ["Intent Agent"]
353
- prompt = (
354
- f"Refine the user's request into a clear, actionable 'core objective prompt'.\n\nRelevant Memory:\n{(state or {}).get('retrievedMemory')}\n\n"
355
- f"User Request: \"{(state or {}).get('userInput','')}\"\n\nCore Objective:"
356
- )
357
  response = llm.invoke(prompt)
358
- core_obj = getattr(response, "content", "") or (state or {}).get('coreObjectivePrompt', "")
359
- detection = detect_requested_output_types(core_obj or (state or {}).get('userInput',''))
360
  extras = {}
361
  if detection.get('requires_artifact'):
362
  extras['artifact_detection'] = detection
363
  return {"coreObjectivePrompt": core_obj, **extras, "execution_path": path, "status_update": "Clarifying the main objective..."}
364
 
365
  def run_pm_agent(state: AgentState):
366
- # increment cycles safely
367
  current_cycles = ensure_int(state, 'rework_cycles', 0) + 1
368
  max_loops_val = ensure_int(state, 'max_loops', 0)
 
369
  path = ensure_list(state, 'execution_path') + ["PM Agent"]
370
- feedback = f"QA Feedback: {state.get('qaFeedback')}" if state.get('qaFeedback') else ""
371
  prompt = (
372
  f"Decompose the core objective into a plan. Determine if code execution or artifact generation is needed and define the goal.\n\n"
373
  f"Core Objective: {state.get('coreObjectivePrompt')}\n\n{feedback}\n\n"
374
- "Respond in JSON with keys: 'plan_steps' (list), 'experiment_needed' (bool), 'experiment_type' (optional), 'experiment_goal' (optional)."
375
  )
376
  response = llm.invoke(prompt)
377
  plan = parse_json_from_llm(getattr(response, "content", "") or "")
378
  if not plan:
379
- plan = {"plan_steps": ["Analyze files", "If asked, generate artifact", "Synthesize results"], "experiment_needed": False}
 
 
380
  exp_type_raw = plan.get('experiment_type') or ""
381
- plan_goal = plan.get('experiment_goal') or (state or {}).get('userInput','') or (state or {}).get('coreObjectivePrompt','')
382
  normalized = normalize_experiment_type(exp_type_raw, plan_goal)
383
  plan['experiment_type'] = normalized
384
  if plan.get('experiment_needed') and not plan.get('experiment_goal'):
385
  plan['experiment_goal'] = plan_goal
 
386
  return {"pmPlan": plan, "execution_path": path, "rework_cycles": current_cycles, "status_update": "Breaking down the objective into a detailed plan..."}
387
 
388
- def _extract_code_blocks(text: str, lang_hint: Optional[str] = None) -> List[str]:
 
389
  if lang_hint and "python" in (lang_hint or "").lower():
390
  blocks = re.findall(r"```python\s*(.*?)\s*```", text, re.DOTALL)
391
  if blocks:
@@ -394,130 +357,143 @@ def _extract_code_blocks(text: str, lang_hint: Optional[str] = None) -> List[str
394
  return blocks
395
 
396
  def run_experimenter_agent(state: AgentState):
 
397
  path = ensure_list(state, 'execution_path') + ["Experimenter Agent"]
398
- pm = (state or {}).get('pmPlan') or {}
399
  if not pm.get('experiment_needed'):
400
- # return consistent shape: experimentResults.paths is empty dict
401
- return {"experimentCode": None, "experimentResults": {"success": False, "paths": {}, "stdout": "", "stderr": ""}, "execution_path": path, "status_update": "No experiment required."}
402
  exp_type = normalize_experiment_type(pm.get('experiment_type'), pm.get('experiment_goal',''))
403
- goal = pm.get('experiment_goal') or "Produce artifact"
404
- response = llm.invoke(f"Produce content for artifact type '{exp_type}' to achieve: {goal}\nReturn runnable code in fenced code blocks where appropriate, and explanatory text otherwise.")
 
 
 
405
  llm_text = getattr(response, "content", "") or ""
406
- results = {"success": False, "paths": {}, "stdout": "", "stderr": ""}
 
407
  try:
408
- if exp_type == "notebook":
409
- nb_path = write_notebook_from_text(llm_text, out_dir=OUT_DIR)
410
  results.update({"success": True, "paths": {"notebook": sanitize_path(nb_path)}})
411
- return {"experimentCode": None, "experimentResults": results, "execution_path": path, "status_update": f"Notebook generated."}
412
- if exp_type == "excel":
413
- excel_path = write_excel_from_tables(llm_text, out_dir=OUT_DIR)
414
  results.update({"success": True, "paths": {"excel": sanitize_path(excel_path)}})
415
- return {"experimentCode": None, "experimentResults": results, "execution_path": path, "status_update": f"Excel generated."}
416
- if exp_type in ("word", "docx"):
417
- docx_path = write_docx_from_text(llm_text, out_dir=OUT_DIR)
418
  results.update({"success": True, "paths": {"docx": sanitize_path(docx_path)}})
419
- return {"experimentCode": None, "experimentResults": results, "execution_path": path, "status_update": f"DOCX generated."}
420
- if exp_type == "pdf":
421
- pdf_path = write_pdf_from_text(llm_text, out_dir=OUT_DIR)
422
  results.update({"success": True, "paths": {"pdf": sanitize_path(pdf_path)}})
423
- return {"experimentCode": None, "experimentResults": results, "execution_path": path, "status_update": f"PDF generated."}
424
- if exp_type == "script":
425
- lang_hint = pm.get('experiment_language') or ("python" if ".py" in (pm.get('experiment_goal','') or "").lower() else None)
426
  code_blocks = _extract_code_blocks(llm_text, lang_hint)
427
- code_text = "\n\n# === BLOCK ===\n\n".join(code_blocks) if code_blocks else llm_text
428
- script_path = write_script(code_text, language_hint=lang_hint, out_dir=OUT_DIR)
 
 
 
429
  exec_results = {}
430
  if script_path.endswith(".py"):
431
  try:
432
  exec_results = execute_python_code(open(script_path,"r",encoding="utf-8").read())
433
  except Exception as e:
434
- exec_results = {"stdout": "", "stderr": str(e), "success": False}
435
  results.update({"success": True, "paths": {"script": sanitize_path(script_path)}, "stdout": exec_results.get("stdout",""), "stderr": exec_results.get("stderr","")})
436
- return {"experimentCode": code_text, "experimentResults": results, "execution_path": path, "status_update": f"Script generated."}
437
- if exp_type == "repo":
438
- files_map = {"README.md": llm_text or "Generated repo"}
439
- try:
440
- nb_path = write_notebook_from_text(llm_text, out_dir=OUT_DIR)
441
- files_map["analysis.ipynb"] = open(nb_path,"r",encoding="utf-8").read() if os.path.exists(nb_path) else llm_text
442
- except Exception:
443
- files_map["analysis.ipynb"] = llm_text
444
- files_map["requirements.txt"] = "nbformat\npandas\nopenpyxl\npython-docx\nreportlab"
445
- zip_path = build_repo_zip(files_map, repo_name="generated_app", out_dir=OUT_DIR)
446
  results.update({"success": True, "paths": {"repo_zip": sanitize_path(zip_path)}})
447
- return {"experimentCode": None, "experimentResults": results, "execution_path": path, "status_update": f"Repository ZIP created."}
448
- # fallback: write docx
449
- fallback = write_docx_from_text(llm_text, out_dir=OUT_DIR)
450
- results.update({"success": True, "paths": {"docx": sanitize_path(fallback)}})
451
- return {"experimentCode": None, "experimentResults": results, "execution_path": path, "status_update": "Fallback DOCX generated."}
 
452
  except Exception as e:
453
- log.exception("Experimenter failed")
454
  results.update({"success": False, "stderr": str(e)})
455
- return {"experimentCode": None, "experimentResults": results, "execution_path": path, "status_update": "Experimenter error."}
456
 
457
  def run_synthesis_agent(state: AgentState):
 
458
  path = ensure_list(state, 'execution_path') + ["Synthesis Agent"]
459
- exp_results = (state or {}).get('experimentResults') or {}
 
460
  artifact_message = ""
461
  if exp_results and isinstance(exp_results, dict):
462
  paths = exp_results.get("paths") or {}
463
  if paths:
464
- artifact_lines = [f"- {k}: `{v}`" for k,v in paths.items()]
 
 
465
  artifact_message = "\n\n**Artifacts produced:**\n" + "\n".join(artifact_lines)
 
 
 
466
  prompt = (
467
  f"Synthesize all information into a final response.\n\nCore Objective: {state.get('coreObjectivePrompt')}\n\n"
468
- f"Plan: {state.get('pmPlan', {}).get('plan_steps')}\n\n"
469
- f"{artifact_message}\n\nFinal Response:"
470
  )
471
  response = llm.invoke(prompt)
472
  final_text = getattr(response, "content", "") or ""
473
- if not final_text.strip():
474
- final_text = "I prepared the requested artifact(s). Please download them from the provided paths."
475
  if artifact_message:
476
  final_text = final_text + "\n\n" + artifact_message
477
  return {"draftResponse": final_text, "execution_path": path, "status_update": "Putting together the final response..."}
478
 
479
  def run_qa_agent(state: AgentState):
 
480
  path = ensure_list(state, 'execution_path') + ["QA Agent"]
481
- prompt = (
482
- f"Review the draft response based on the core objective. Respond ONLY with 'APPROVED' or provide concise feedback for rework.\n\n"
483
- f"Core Objective: {state.get('coreObjectivePrompt')}\n\nDraft: {state.get('draftResponse','')}"
484
- )
485
  response = llm.invoke(prompt)
486
  content = getattr(response, "content", "") or ""
487
  if "APPROVED" in content.upper():
488
  return {"approved": True, "qaFeedback": None, "execution_path": path, "status_update": "Response approved!"}
489
- return {"approved": False, "qaFeedback": content or "No specific feedback.", "execution_path": path, "status_update": "Response needs rework."}
 
490
 
491
  def run_archivist_agent(state: AgentState):
 
492
  path = ensure_list(state, 'execution_path') + ["Archivist Agent"]
493
- prompt = (
494
- f"Create a concise summary of this successful task for long-term memory.\n\n"
495
- f"Core Objective: {state.get('coreObjectivePrompt')}\n\nFinal Response: {state.get('draftResponse')}\n\nMemory Summary:"
496
- )
497
- response = llm.invoke(prompt)
498
- memory_text = getattr(response, "content", "") or state.get('draftResponse','') or ""
499
- try:
500
- memory_manager.add_to_memory(memory_text, {"objective": state.get('coreObjectivePrompt')})
501
- except Exception:
502
- log.debug("Failed to write memory; continuing.")
503
- return {"execution_path": path, "status_update": "Saved learnings to memory."}
504
 
505
  def run_disclaimer_agent(state: AgentState):
 
506
  path = ensure_list(state, 'execution_path') + ["Disclaimer Agent"]
507
- disclaimer = ("**DISCLAIMER: The process was stopped after exhausting the budget. The following response may be incomplete.**\n\n---\n\n")
508
- final_response = disclaimer + (state.get('draftResponse') or "No response was generated.")
509
- return {"draftResponse": final_response, "execution_path": path, "status_update": "Budget limit reached."}
510
 
511
- # --- Decision functions ---
512
  def should_continue(state: AgentState):
 
513
  if state.get("approved"):
 
514
  return "archivist_agent"
515
  if ensure_int(state, "rework_cycles", 0) > ensure_int(state, "max_loops", 0):
 
516
  return "disclaimer_agent"
517
- return "pm_agent"
 
 
518
 
519
  def should_run_experiment(state: AgentState):
520
- pm = (state or {}).get('pmPlan') or {}
521
  return "experimenter_agent" if pm.get('experiment_needed') else "synthesis_agent"
522
 
523
  # --- Build graphs ---
@@ -546,8 +522,6 @@ main_workflow.add_node("disclaimer_agent", run_disclaimer_agent)
546
  main_workflow.set_entry_point("memory_retriever")
547
  main_workflow.add_edge("memory_retriever", "intent_agent")
548
  main_workflow.add_edge("intent_agent", "pm_agent")
549
- # add direct edges; conditional edges override routing internally
550
- main_workflow.add_edge("pm_agent", "experimenter_agent")
551
  main_workflow.add_edge("experimenter_agent", "synthesis_agent")
552
  main_workflow.add_edge("synthesis_agent", "qa_agent")
553
  main_workflow.add_edge("archivist_agent", END)
@@ -559,4 +533,4 @@ main_workflow.add_conditional_edges("qa_agent", should_continue, {
559
  "pm_agent": "pm_agent",
560
  "disclaimer_agent": "disclaimer_agent"
561
  })
562
- main_app = main_workflow.compile()
 
1
+ # graph.py (final patched)
2
  import json
3
  import re
4
  import math
 
6
  import uuid
7
  import shutil
8
  import zipfile
9
+ import operator
10
+ from typing import TypedDict, List, Dict, Optional, Annotated
11
  from langchain_openai import ChatOpenAI
12
  from langgraph.graph import StateGraph, END
13
  from memory_manager import memory_manager
14
  from code_executor import execute_python_code
15
  from logging_config import setup_logging, get_logger
16
 
17
+ # Artifact libs
18
+ import nbformat
19
+ from nbformat.v4 import new_notebook, new_markdown_cell, new_code_cell
20
+ import pandas as pd
21
+ from docx import Document
22
+ from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
23
+ from reportlab.lib.styles import getSampleStyleSheet
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
  # --- Helpers ---
26
+ def ensure_list(state, key):
27
  """Return a list from state[key], default [] if missing/None/not-list."""
28
+ v = state.get(key) if state else None
29
  if v is None:
30
  return []
31
  if isinstance(v, list):
32
  return v
33
  if isinstance(v, tuple):
34
  return list(v)
 
35
  return [v]
36
 
37
+ def ensure_int(state, key, default=0):
38
  """Return an int from state[key], default if missing/invalid."""
39
  try:
40
+ v = state.get(key) if state else None
41
  if v is None:
42
  return default
43
  return int(v)
 
45
  return default
46
 
47
  def sanitize_path(path: str) -> str:
48
+ """Sanitize/normalize output path for return to UI."""
49
+ return os.path.abspath(path)
 
 
 
 
 
 
 
 
 
50
 
51
  # --- Setup & constants ---
52
  setup_logging()
 
55
  GPT4O_INPUT_COST_PER_1K_TOKENS = 0.005
56
  GPT4O_OUTPUT_COST_PER_1K_TOKENS = 0.015
57
  AVG_TOKENS_PER_CALL = 2.0
 
 
58
 
59
+ # --- AgentState ---
60
+ class AgentState(TypedDict):
61
  userInput: str
62
  chatHistory: List[str]
63
  coreObjectivePrompt: str
 
68
  draftResponse: str
69
  qaFeedback: Optional[str]
70
  approved: bool
71
+ # <-- important: Annotated with operator.add to declare accumulation behavior
72
+ execution_path: Annotated[List[str], operator.add]
73
  rework_cycles: int
74
  max_loops: int
75
  status_update: str
 
78
  llm = ChatOpenAI(model="gpt-4o", temperature=0.1, max_retries=3, request_timeout=60)
79
 
80
  def parse_json_from_llm(llm_output: str) -> Optional[dict]:
81
+ """Robustly try to extract JSON object from LLM text."""
82
  try:
83
  if not llm_output:
84
  return None
 
85
  match = re.search(r"```json\s*({.*?})\s*```", llm_output, re.DOTALL)
86
  if match:
87
  json_str = match.group(1)
88
  else:
 
89
  start = llm_output.find('{')
90
  end = llm_output.rfind('}')
91
  if start == -1 or end == -1:
 
93
  json_str = llm_output[start:end+1]
94
  return json.loads(json_str)
95
  except Exception as e:
96
+ log.error(f"JSON parsing failed. Error: {e}. Raw head: {llm_output[:300]}")
97
  return None
98
 
99
+ # --- Artifact detection & normalization ---
100
+ KNOWN_ARTIFACT_TYPES = {"notebook","excel","word","pdf","image","repo","script"}
101
 
102
+ def detect_requested_output_types(text: str) -> Dict:
103
+ """Heuristic detect requested artifact type from text."""
104
  if not text:
105
  return {"requires_artifact": False, "artifact_type": None, "artifact_hint": None}
106
  t = text.lower()
 
110
  return {"requires_artifact": True, "artifact_type": "excel", "artifact_hint": "Excel/CSV file"}
111
  if any(k in t for k in ["word document", ".docx", "docx", "word file"]):
112
  return {"requires_artifact": True, "artifact_type": "word", "artifact_hint": "Word document (.docx)"}
113
+ if any(k in t for k in ["pdf", "pdf file"]):
114
  return {"requires_artifact": True, "artifact_type": "pdf", "artifact_hint": "PDF document"}
115
+ if any(k in t for k in ["image", "plot", "chart", "png", "jpg", "jpeg"]):
116
+ return {"requires_artifact": True, "artifact_type": "image", "artifact_hint": "image/plot"}
117
  if any(k in t for k in ["repo", "repository", "app repo", "dockerfile", "requirements.txt", "package.json"]):
118
  return {"requires_artifact": True, "artifact_type": "repo", "artifact_hint": "application repository (zip)"}
119
+ if any(k in t for k in [".py", "python script", "r script", ".R", ".r", "java", ".java", "javascript", ".js"]):
120
+ return {"requires_artifact": True, "artifact_type": "script", "artifact_hint": "language script (py/r/java/js/etc.)"}
121
  return {"requires_artifact": False, "artifact_type": None, "artifact_hint": None}
122
 
123
  def normalize_experiment_type(exp_type: Optional[str], goal_text: str) -> str:
124
+ """Map arbitrary LLM returned experiment_type into known set or infer from goal_text."""
125
+ if not exp_type:
126
+ detection = detect_requested_output_types(goal_text or "")
127
+ return detection.get("artifact_type") or "docx"
128
+ s = exp_type.strip().lower()
129
+ # direct mapping heuristics
130
+ if s in KNOWN_ARTIFACT_TYPES:
131
+ return s
132
+ # common synonyms
133
+ if "notebook" in s or "ipynb" in s:
134
+ return "notebook"
135
+ if "excel" in s or "xlsx" in s or "spreadsheet" in s:
136
+ return "excel"
137
+ if "word" in s or "docx" in s:
138
+ return "word"
139
+ if "pdf" in s:
140
+ return "pdf"
141
+ if "repo" in s or "repository" in s or "app" in s:
142
+ return "repo"
143
+ if "script" in s or "python" in s or ".py" in s:
144
+ return "script"
145
+ # fallback to detection from goal
146
  detection = detect_requested_output_types(goal_text or "")
147
  return detection.get("artifact_type") or "docx"
148
 
149
+ # --- Notebook & artifact builders ---
150
+ def write_notebook_from_text(llm_text: str, out_dir: str="/tmp") -> str:
151
+ code_blocks = re.findall(r"```python\s*(.*?)\s*```", llm_text, re.DOTALL)
152
+ if not code_blocks:
153
+ code_blocks = re.findall(r"```\s*(.*?)\s*```", llm_text, re.DOTALL)
154
+ md_parts = re.split(r"```(?:python)?\s*.*?\s*```", llm_text, flags=re.DOTALL)
155
+ nb = new_notebook()
156
+ cells = []
157
+ max_len = max(len(md_parts), len(code_blocks))
158
+ for i in range(max_len):
159
+ if i < len(md_parts) and md_parts[i].strip():
160
+ cells.append(new_markdown_cell(md_parts[i].strip()))
161
+ if i < len(code_blocks) and code_blocks[i].strip():
162
+ cells.append(new_code_cell(code_blocks[i].strip()))
163
+ if not cells:
164
+ cells = [new_markdown_cell("# Notebook\n\nNo content parsed from LLM output.")]
165
+ nb['cells'] = cells
166
  uid = uuid.uuid4().hex[:10]
167
  filename = os.path.join(out_dir, f"generated_notebook_{uid}.ipynb")
168
+ nbformat.write(nb, filename)
169
+ return filename
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
 
171
+ def write_script(code_text: str, language_hint: Optional[str]=None, out_dir: str="/tmp") -> str:
 
172
  ext = ".txt"
173
  if language_hint:
174
  l = language_hint.lower()
 
176
  ext = ".py"
177
  elif l == "r" or l == ".r":
178
  ext = ".R"
179
+ elif "java" in l or ".java" in l:
180
  ext = ".java"
181
+ elif "javascript" in l or ".js" in l:
182
  ext = ".js"
183
  elif "bash" in l or "sh" in l:
184
  ext = ".sh"
185
  uid = uuid.uuid4().hex[:10]
186
  filename = os.path.join(out_dir, f"generated_script_{uid}{ext}")
187
+ with open(filename, "w", encoding="utf-8") as f:
188
+ f.write(code_text)
189
+ return filename
190
+
191
+ def write_docx_from_text(text: str, out_dir: str="/tmp") -> str:
192
+ doc = Document()
193
+ for para in [p.strip() for p in text.split("\n\n") if p.strip()]:
194
+ doc.add_paragraph(para)
195
  uid = uuid.uuid4().hex[:10]
196
  filename = os.path.join(out_dir, f"generated_doc_{uid}.docx")
197
+ doc.save(filename)
198
+ return filename
 
 
 
 
 
 
199
 
200
+ def write_excel_from_tables(maybe_table_text: str, out_dir: str="/tmp") -> str:
 
201
  uid = uuid.uuid4().hex[:10]
202
  filename = os.path.join(out_dir, f"generated_excel_{uid}.xlsx")
203
+ try:
204
  try:
205
+ parsed = json.loads(maybe_table_text)
 
 
 
 
 
206
  if isinstance(parsed, list):
207
  df = pd.DataFrame(parsed)
208
  elif isinstance(parsed, dict):
209
  df = pd.DataFrame([parsed])
210
  else:
211
+ df = pd.DataFrame({"content":[str(maybe_table_text)]})
212
+ except Exception:
213
+ if "," in maybe_table_text or "\t" in maybe_table_text:
214
+ from io import StringIO
215
+ df = pd.read_csv(StringIO(maybe_table_text))
216
+ else:
217
+ df = pd.DataFrame({"content":[maybe_table_text]})
218
+ df.to_excel(filename, index=False, engine="openpyxl")
219
+ return filename
220
+ except Exception as e:
221
+ log.error(f"Excel creation failed: {e}")
222
+ return write_docx_from_text(f"Failed to create excel. Error: {e}\n\nOriginal:\n{maybe_table_text}", out_dir=out_dir)
223
+
224
+ def write_pdf_from_text(text: str, out_dir: str="/tmp") -> str:
 
 
 
 
 
225
  uid = uuid.uuid4().hex[:10]
226
  filename = os.path.join(out_dir, f"generated_doc_{uid}.pdf")
227
+ try:
228
+ doc = SimpleDocTemplate(filename)
229
+ styles = getSampleStyleSheet()
230
+ flowables = []
231
+ for para in [p.strip() for p in text.split("\n\n") if p.strip()]:
232
+ flowables.append(Paragraph(para.replace("\n","<br/>"), styles["Normal"]))
233
+ flowables.append(Spacer(1, 8))
234
+ doc.build(flowables)
235
+ return filename
236
+ except Exception as e:
237
+ log.error(f"PDF creation failed: {e}")
238
+ return write_docx_from_text(f"Failed to create PDF. Error: {e}\n\nOriginal:\n{text}", out_dir=out_dir)
239
+
240
+ def build_repo_zip(files_map: Dict[str,str], repo_name: str="generated_app", out_dir: str="/tmp") -> str:
 
 
 
241
  uid = uuid.uuid4().hex[:8]
242
  repo_dir = os.path.join(out_dir, f"{repo_name}_{uid}")
243
  os.makedirs(repo_dir, exist_ok=True)
244
+ for rel_path, content in files_map.items():
245
  dest = os.path.join(repo_dir, rel_path)
246
  os.makedirs(os.path.dirname(dest), exist_ok=True)
247
  if isinstance(content, str) and os.path.exists(content):
248
  shutil.copyfile(content, dest)
249
  else:
250
  with open(dest, "w", encoding="utf-8") as fh:
251
+ fh.write(str(content))
252
  zip_path = os.path.join(out_dir, f"{repo_name}_{uid}.zip")
253
  with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
254
  for root, _, files in os.walk(repo_dir):
 
258
  zf.write(full, arc)
259
  return zip_path
260
 
261
+ # --- Node functions ---
262
  def run_triage_agent(state: AgentState):
263
+ log.info("--- triage ---")
264
+ prompt = f"Analyze the user input. Is it a simple conversational greeting or a task? Respond with 'greeting' or 'task'.\n\nUser Input: \"{state.get('userInput','')}\""
265
  response = llm.invoke(prompt)
266
  content = getattr(response, "content", "") or ""
267
+ if 'greeting' in content.lower():
268
+ log.info("Triage result: Simple Greeting.")
269
+ return {"draftResponse": "Hello! How can I help you today?", "execution_path": ["Triage Agent"], "status_update": "Responding to greeting."}
270
+ else:
271
+ log.info("Triage result: Complex Task.")
272
+ return {"execution_path": ["Triage Agent"], "status_update": "Request requires a plan. Proceeding..."}
273
 
274
  def run_planner_agent(state: AgentState):
275
+ log.info("--- ✈️ Running Planner Agent ---")
276
  path = ensure_list(state, 'execution_path') + ["Planner Agent"]
277
  prompt = (
278
  f"Analyze the user's request. Provide a high-level plan and estimate the number of LLM calls for one loop. "
279
+ f"User Request: \"{state.get('userInput','')}\". Respond in JSON with keys: 'plan' (list of strings), 'estimated_llm_calls_per_loop' (integer)."
280
  )
281
  response = llm.invoke(prompt)
282
  plan_data = parse_json_from_llm(getattr(response, "content", "") or "")
283
  if not plan_data:
284
+ return {"pmPlan": {"error": "Failed to create a valid plan."}, "execution_path": path, "status_update": "Error: Could not create a plan."}
285
+ calls_per_loop = plan_data.get('estimated_llm_calls_per_loop', 3)
 
286
  cost_per_loop = (calls_per_loop * AVG_TOKENS_PER_CALL) * ((GPT4O_INPUT_COST_PER_1K_TOKENS + GPT4O_OUTPUT_COST_PER_1K_TOKENS) / 2)
287
  estimated_cost = cost_per_loop * (INITIAL_MAX_REWORK_CYCLES + 1)
288
+ plan_data['max_loops_initial'] = INITIAL_MAX_REWORK_CYCLES
289
  plan_data['estimated_cost_usd'] = round(estimated_cost, 2)
290
  plan_data['cost_per_loop_usd'] = max(0.01, round(cost_per_loop, 3))
291
+ detection = detect_requested_output_types(state.get('userInput','') or state.get('coreObjectivePrompt','') or '')
292
+ if detection.get('requires_artifact'):
 
293
  plan_data.setdefault('experiment_needed', True)
294
  plan_data.setdefault('experiment_type', detection.get('artifact_type'))
295
  plan_data.setdefault('experiment_goal', f"Produce an artifact: {detection.get('artifact_hint')}. {state.get('userInput','')}")
296
+ log.info(f"Pre-flight Estimate: {plan_data}")
297
  return {"pmPlan": plan_data, "execution_path": path, "status_update": "Plan and cost estimate created. Awaiting approval."}
298
 
299
  def run_memory_retrieval(state: AgentState):
300
+ log.info("--- 🧠 Accessing Long-Term Memory ---")
301
  path = ensure_list(state, 'execution_path') + ["Memory Retriever"]
302
+ relevant_mems = memory_manager.retrieve_relevant_memories(state.get('userInput',''))
 
 
 
303
  if relevant_mems:
304
+ context = "\n".join([f"Memory: {mem.page_content}" for mem in relevant_mems])
305
+ log.info(f"Found {len(relevant_mems)} relevant memories.")
306
  else:
307
  context = "No relevant memories found."
308
+ log.info(context)
309
  return {"retrievedMemory": context, "execution_path": path, "status_update": "Searching for relevant past information..."}
310
 
311
  def run_intent_agent(state: AgentState):
312
+ log.info("--- 🎯 Running Intent Agent ---")
313
  path = ensure_list(state, 'execution_path') + ["Intent Agent"]
314
+ prompt = (f"Refine the user's request into a clear, actionable 'core objective prompt'.\n\nRelevant Memory:\n{state.get('retrievedMemory')}\n\nUser Request: \"{state.get('userInput','')}\"\n\nCore Objective:")
 
 
 
315
  response = llm.invoke(prompt)
316
+ core_obj = getattr(response, "content", "") or ""
317
+ detection = detect_requested_output_types(core_obj or state.get('userInput',''))
318
  extras = {}
319
  if detection.get('requires_artifact'):
320
  extras['artifact_detection'] = detection
321
  return {"coreObjectivePrompt": core_obj, **extras, "execution_path": path, "status_update": "Clarifying the main objective..."}
322
 
323
  def run_pm_agent(state: AgentState):
324
+ log.info("--- 👷 Running PM Agent ---")
325
  current_cycles = ensure_int(state, 'rework_cycles', 0) + 1
326
  max_loops_val = ensure_int(state, 'max_loops', 0)
327
+ log.info(f"Starting work cycle {current_cycles}/{max_loops_val + 1}")
328
  path = ensure_list(state, 'execution_path') + ["PM Agent"]
329
+ feedback = f"QA Feedback (must be addressed): {state.get('qaFeedback')}" if state.get('qaFeedback') else ""
330
  prompt = (
331
  f"Decompose the core objective into a plan. Determine if code execution or artifact generation is needed and define the goal.\n\n"
332
  f"Core Objective: {state.get('coreObjectivePrompt')}\n\n{feedback}\n\n"
333
+ f"Respond in JSON with keys: 'plan_steps' (list), 'experiment_needed' (bool), 'experiment_type' (optional string), and 'experiment_goal' (str if needed)."
334
  )
335
  response = llm.invoke(prompt)
336
  plan = parse_json_from_llm(getattr(response, "content", "") or "")
337
  if not plan:
338
+ log.warning("PM Agent did not produce JSON applying heuristic fallback.")
339
+ plan = {"plan_steps": ["Analyze files", "Create notebook if requested", "Synthesize answers"], "experiment_needed": False}
340
+ # normalize experiment type
341
  exp_type_raw = plan.get('experiment_type') or ""
342
+ plan_goal = plan.get('experiment_goal') or state.get('userInput','') or state.get('coreObjectivePrompt','')
343
  normalized = normalize_experiment_type(exp_type_raw, plan_goal)
344
  plan['experiment_type'] = normalized
345
  if plan.get('experiment_needed') and not plan.get('experiment_goal'):
346
  plan['experiment_goal'] = plan_goal
347
+ log.info(f"Generated Plan: Experiment Needed = {plan.get('experiment_needed', False)}, Type = {plan.get('experiment_type')}")
348
  return {"pmPlan": plan, "execution_path": path, "rework_cycles": current_cycles, "status_update": "Breaking down the objective into a detailed plan..."}
349
 
350
+ def _extract_code_blocks(text: str, lang_hint: Optional[str]=None) -> List[str]:
351
+ # prefer specific language fences, fallback to generic fenced blocks
352
  if lang_hint and "python" in (lang_hint or "").lower():
353
  blocks = re.findall(r"```python\s*(.*?)\s*```", text, re.DOTALL)
354
  if blocks:
 
357
  return blocks
358
 
359
  def run_experimenter_agent(state: AgentState):
360
+ log.info("--- 🔬 Running Experimenter Agent ---")
361
  path = ensure_list(state, 'execution_path') + ["Experimenter Agent"]
362
+ pm = state.get('pmPlan', {}) or {}
363
  if not pm.get('experiment_needed'):
364
+ return {"experimentCode": None, "experimentResults": None, "execution_path": path, "status_update": "Proceeding without a code experiment."}
 
365
  exp_type = normalize_experiment_type(pm.get('experiment_type'), pm.get('experiment_goal',''))
366
+ goal = pm.get('experiment_goal', 'No goal specified.')
367
+ response = llm.invoke(
368
+ f"Produce content for artifact type '{exp_type}' to achieve: {goal}\n"
369
+ "Return runnable code in fenced code blocks where appropriate, and explanatory text otherwise."
370
+ )
371
  llm_text = getattr(response, "content", "") or ""
372
+ out_dir = "/tmp"
373
+ results = {"success": False, "paths": {}, "stderr": "", "stdout": ""}
374
  try:
375
+ if exp_type == 'notebook':
376
+ nb_path = write_notebook_from_text(llm_text, out_dir=out_dir)
377
  results.update({"success": True, "paths": {"notebook": sanitize_path(nb_path)}})
378
+ return {"experimentCode": None, "experimentResults": results, "execution_path": path, "status_update": f"Notebook generated at {nb_path}"}
379
+ elif exp_type == 'excel':
380
+ excel_path = write_excel_from_tables(llm_text, out_dir=out_dir)
381
  results.update({"success": True, "paths": {"excel": sanitize_path(excel_path)}})
382
+ return {"experimentCode": None, "experimentResults": results, "execution_path": path, "status_update": f"Excel generated at {excel_path}"}
383
+ elif exp_type == 'word':
384
+ docx_path = write_docx_from_text(llm_text, out_dir=out_dir)
385
  results.update({"success": True, "paths": {"docx": sanitize_path(docx_path)}})
386
+ return {"experimentCode": None, "experimentResults": results, "execution_path": path, "status_update": f"DOCX generated at {docx_path}"}
387
+ elif exp_type == 'pdf':
388
+ pdf_path = write_pdf_from_text(llm_text, out_dir=out_dir)
389
  results.update({"success": True, "paths": {"pdf": sanitize_path(pdf_path)}})
390
+ return {"experimentCode": None, "experimentResults": results, "execution_path": path, "status_update": f"PDF generated at {pdf_path}"}
391
+ elif exp_type == 'script':
392
+ lang_hint = pm.get('experiment_language') or ("python" if ".py" in goal.lower() else None)
393
  code_blocks = _extract_code_blocks(llm_text, lang_hint)
394
+ if not code_blocks:
395
+ code_text = llm_text
396
+ else:
397
+ code_text = "\n\n# === BLOCK ===\n\n".join(code_blocks)
398
+ script_path = write_script(code_text, language_hint=lang_hint, out_dir=out_dir)
399
  exec_results = {}
400
  if script_path.endswith(".py"):
401
  try:
402
  exec_results = execute_python_code(open(script_path,"r",encoding="utf-8").read())
403
  except Exception as e:
404
+ exec_results = {"stdout":"","stderr":str(e),"success":False}
405
  results.update({"success": True, "paths": {"script": sanitize_path(script_path)}, "stdout": exec_results.get("stdout",""), "stderr": exec_results.get("stderr","")})
406
+ return {"experimentCode": code_text, "experimentResults": results, "execution_path": path, "status_update": f"Script generated at {script_path}"}
407
+ elif exp_type == 'repo':
408
+ repo_files = {}
409
+ readme = (llm_text[:1000] + "\n\n") if llm_text else "Generated repo"
410
+ repo_files["README.md"] = readme
411
+ nb_path = write_notebook_from_text(llm_text, out_dir=out_dir)
412
+ repo_files["analysis.ipynb"] = nb_path
413
+ reqs = "nbformat\npandas\nopenpyxl\npython-docx\nreportlab"
414
+ repo_files["requirements.txt"] = reqs
415
+ zip_path = build_repo_zip(repo_files, repo_name="generated_app", out_dir=out_dir)
416
  results.update({"success": True, "paths": {"repo_zip": sanitize_path(zip_path)}})
417
+ return {"experimentCode": None, "experimentResults": results, "execution_path": path, "status_update": f"Repository ZIP created at {zip_path}"}
418
+ else:
419
+ # safe fallback: write docx
420
+ fallback = write_docx_from_text(llm_text, out_dir=out_dir)
421
+ results.update({"success": True, "paths": {"docx": sanitize_path(fallback)}})
422
+ return {"experimentCode": None, "experimentResults": results, "execution_path": path, "status_update": f"Fallback DOCX generated at {fallback}"}
423
  except Exception as e:
424
+ log.error(f"Experimenter failed: {e}")
425
  results.update({"success": False, "stderr": str(e)})
426
+ return {"experimentCode": None, "experimentResults": results, "execution_path": path, "status_update": "Error: Experimenter failed."}
427
 
428
  def run_synthesis_agent(state: AgentState):
429
+ log.info("--- ✍️ Running Synthesis Agent ---")
430
  path = ensure_list(state, 'execution_path') + ["Synthesis Agent"]
431
+ exp_results = state.get('experimentResults')
432
+ results_summary = "No experiment was conducted."
433
  artifact_message = ""
434
  if exp_results and isinstance(exp_results, dict):
435
  paths = exp_results.get("paths") or {}
436
  if paths:
437
+ artifact_lines = []
438
+ for k,v in paths.items():
439
+ artifact_lines.append(f"- {k}: `{v}`")
440
  artifact_message = "\n\n**Artifacts produced:**\n" + "\n".join(artifact_lines)
441
+ results_summary = f"Artifacts produced: {list(paths.keys())}"
442
+ else:
443
+ results_summary = f"Experiment Output Stdout: {exp_results.get('stdout','')}\nStderr: {exp_results.get('stderr','')}"
444
  prompt = (
445
  f"Synthesize all information into a final response.\n\nCore Objective: {state.get('coreObjectivePrompt')}\n\n"
446
+ f"Plan: {state.get('pmPlan', {}).get('plan_steps')}\n\n{results_summary}\n\nFinal Response:"
 
447
  )
448
  response = llm.invoke(prompt)
449
  final_text = getattr(response, "content", "") or ""
 
 
450
  if artifact_message:
451
  final_text = final_text + "\n\n" + artifact_message
452
  return {"draftResponse": final_text, "execution_path": path, "status_update": "Putting together the final response..."}
453
 
454
  def run_qa_agent(state: AgentState):
455
+ log.info("--- ✅ Running QA Agent ---")
456
  path = ensure_list(state, 'execution_path') + ["QA Agent"]
457
+ prompt = (f"Review the draft response based on the core objective. Respond ONLY with 'APPROVED' or provide concise feedback for rework.\n\n"
458
+ f"Core Objective: {state.get('coreObjectivePrompt')}\n\nDraft: {state.get('draftResponse')}")
 
 
459
  response = llm.invoke(prompt)
460
  content = getattr(response, "content", "") or ""
461
  if "APPROVED" in content.upper():
462
  return {"approved": True, "qaFeedback": None, "execution_path": path, "status_update": "Response approved!"}
463
+ else:
464
+ return {"approved": False, "qaFeedback": content or "No specific feedback.", "execution_path": path, "status_update": "Response needs improvement. Reworking..."}
465
 
466
  def run_archivist_agent(state: AgentState):
467
+ log.info("--- 💾 Running Archivist Agent ---")
468
  path = ensure_list(state, 'execution_path') + ["Archivist Agent"]
469
+ summary_prompt = (f"Create a concise summary of this successful task for long-term memory.\n\n"
470
+ f"Core Objective: {state.get('coreObjectivePrompt')}\n\nFinal Response: {state.get('draftResponse')}\n\nMemory Summary:")
471
+ response = llm.invoke(summary_prompt)
472
+ memory_manager.add_to_memory(getattr(response,"content",""), {"objective": state.get('coreObjectivePrompt')})
473
+ return {"execution_path": path, "status_update": "Saving key learnings for future reference..."}
 
 
 
 
 
 
474
 
475
  def run_disclaimer_agent(state: AgentState):
476
+ log.warning("--- ⚠️ Running Disclaimer Agent ---")
477
  path = ensure_list(state, 'execution_path') + ["Disclaimer Agent"]
478
+ disclaimer = ("**DISCLAIMER: The process was stopped after exhausting the budget. The following response is the best available draft and may be incomplete.**\n\n---\n\n")
479
+ final_response = disclaimer + state.get('draftResponse', "No response was generated.")
480
+ return {"draftResponse": final_response, "execution_path": path, "status_update": "Budget limit reached. Preparing final draft..."}
481
 
482
+ # --- Decision & Graph ---
483
  def should_continue(state: AgentState):
484
+ log.info("--- 🤔 Decision: Is the response QA approved? ---")
485
  if state.get("approved"):
486
+ log.info("Routing to: Archivist (Success Path)")
487
  return "archivist_agent"
488
  if ensure_int(state, "rework_cycles", 0) > ensure_int(state, "max_loops", 0):
489
+ log.error(f"BUDGET LIMIT REACHED after {ensure_int(state, 'rework_cycles', 0)-1} cycles.")
490
  return "disclaimer_agent"
491
+ else:
492
+ log.info("Routing to: PM Agent for rework")
493
+ return "pm_agent"
494
 
495
  def should_run_experiment(state: AgentState):
496
+ pm = state.get('pmPlan', {}) or {}
497
  return "experimenter_agent" if pm.get('experiment_needed') else "synthesis_agent"
498
 
499
  # --- Build graphs ---
 
522
  main_workflow.set_entry_point("memory_retriever")
523
  main_workflow.add_edge("memory_retriever", "intent_agent")
524
  main_workflow.add_edge("intent_agent", "pm_agent")
 
 
525
  main_workflow.add_edge("experimenter_agent", "synthesis_agent")
526
  main_workflow.add_edge("synthesis_agent", "qa_agent")
527
  main_workflow.add_edge("archivist_agent", END)
 
533
  "pm_agent": "pm_agent",
534
  "disclaimer_agent": "disclaimer_agent"
535
  })
536
+ main_app = main_workflow.compile()