Cyber Catalyst Team commited on
Commit
6733a58
·
1 Parent(s): 2e782b6

Implement workspace isolation, active MCP tools, and Jina search/read tools

Browse files
Files changed (1) hide show
  1. backend.py +342 -31
backend.py CHANGED
@@ -27,6 +27,10 @@ import collections
27
  from pathlib import Path
28
  from typing import AsyncIterator, Optional, List, Dict, Any
29
  from pydantic import BaseModel
 
 
 
 
30
 
31
  # --- Ultimate Agent Brain Imports ---
32
  from second_brain import SecondBrainWrapper
@@ -36,7 +40,8 @@ from helix_state import helix_db
36
  from context_engine import ContextEngine
37
 
38
  # Instantiate singletons for the orchestrator
39
- brain = SecondBrainWrapper(space_name="space2-cerebrum")
 
40
  context_engine = ContextEngine(brain)
41
  watchdog = SurvivalWatchdog()
42
 
@@ -244,6 +249,40 @@ TOOLS = [
244
  }
245
  }
246
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  ]
248
 
249
  # ---------------------------------------------------------------------------
@@ -252,7 +291,7 @@ TOOLS = [
252
 
253
  def _safe_path(rel_path: str) -> Path:
254
  """Resolve a relative path safely within the workspace."""
255
- workspace = Path(WORKSPACE_DIR).resolve()
256
  target = (workspace / rel_path).resolve()
257
  # Prevent path traversal
258
  if not str(target).startswith(str(workspace)):
@@ -301,7 +340,174 @@ def repair_arguments(func_name: str, args: dict) -> tuple[dict, list[str]]:
301
  return repaired_args, notes
302
 
303
 
304
- async def execute_tool(name: str, arguments: dict) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305
  """Execute a tool and return its output as a string asynchronously."""
306
  try:
307
  if name == "read_file":
@@ -333,7 +539,7 @@ async def execute_tool(name: str, arguments: dict) -> str:
333
  command,
334
  stdout=asyncio.subprocess.PIPE,
335
  stderr=asyncio.subprocess.PIPE,
336
- cwd=WORKSPACE_DIR,
337
  env={**os.environ, "HOME": "/tmp", "PATH": os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin")},
338
  )
339
 
@@ -390,7 +596,7 @@ async def execute_tool(name: str, arguments: dict) -> str:
390
  "grep", "-rn", "--include=*", pattern, str(path),
391
  stdout=asyncio.subprocess.PIPE,
392
  stderr=asyncio.subprocess.PIPE,
393
- cwd=WORKSPACE_DIR,
394
  )
395
  try:
396
  stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=10)
@@ -407,6 +613,40 @@ async def execute_tool(name: str, arguments: dict) -> str:
407
  output = output[:10000] + "\n\n[Truncated]"
408
  return output
409
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
410
  else:
411
  return f"Error: Unknown tool: {name}"
412
 
@@ -708,12 +948,19 @@ async def chat_completions(request: Request, authorization: str = Header(None)):
708
  if user_msg:
709
  await save_message(session_id, "user", user_msg.get("content", ""))
710
 
 
 
 
 
711
  if not stream:
712
  # Non-streaming: simple completion
 
713
  try:
 
 
714
  kwargs = {"model": requested_model, "messages": final_messages}
715
  if is_agentic:
716
- kwargs["tools"] = TOOLS
717
  kwargs["tool_choice"] = "auto"
718
  async with completions_semaphore:
719
  response = await client.chat.completions.create(**kwargs)
@@ -731,18 +978,23 @@ async def chat_completions(request: Request, authorization: str = Header(None)):
731
  except Exception as e:
732
  ACTIVE_SESSIONS.discard(session_id)
733
  return JSONResponse({"error": {"message": str(e), "type": "internal_error"}}, status_code=500)
 
 
734
 
735
  # Streaming + agentic loop
736
  async def generate() -> AsyncIterator[str]:
737
  nonlocal final_messages
738
- async with completions_semaphore:
739
- try:
 
 
 
740
  for round_num in range(MAX_TOOL_ROUNDS + 1):
741
  # Perform auto-compaction before calling NIM API
742
  final_messages = compact_history(final_messages)
743
  kwargs = {"model": requested_model, "messages": final_messages, "stream": True}
744
  if is_agentic:
745
- kwargs["tools"] = TOOLS
746
  kwargs["tool_choice"] = "auto"
747
 
748
  # Collect streamed response
@@ -836,7 +1088,6 @@ async def chat_completions(request: Request, authorization: str = Header(None)):
836
  yield make_chunk(request_id, requested_model, f"\n\n🔧 **{func_name}**")
837
  if repair_notes:
838
  yield make_chunk(request_id, requested_model, " *(Auto-Repaired)*")
839
-
840
  if func_name == "run_bash" and "command" in repaired_args:
841
  yield make_chunk(request_id, requested_model, f": `{repaired_args['command']}`\n")
842
  elif func_name == "read_file" and "path" in repaired_args:
@@ -847,11 +1098,17 @@ async def chat_completions(request: Request, authorization: str = Header(None)):
847
  yield make_chunk(request_id, requested_model, f": `{repaired_args.get('path', '.')}`\n")
848
  elif func_name == "grep_search":
849
  yield make_chunk(request_id, requested_model, f": `{repaired_args.get('pattern', '')}`\n")
 
 
 
 
 
 
850
  else:
851
  yield make_chunk(request_id, requested_model, "\n")
852
 
853
  # Execute the tool
854
- result = await execute_tool(func_name, repaired_args)
855
 
856
  # Append teaching note if repaired
857
  if repair_notes:
@@ -877,11 +1134,13 @@ async def chat_completions(request: Request, authorization: str = Header(None)):
877
  yield make_chunk(request_id, requested_model, finish_reason="stop")
878
  yield "data: [DONE]\n\n"
879
 
880
- except Exception as e:
881
- error_msg = f"\n\n❌ Error: {str(e)}"
882
- yield make_chunk(request_id, requested_model, error_msg)
883
- yield make_chunk(request_id, requested_model, finish_reason="stop")
884
- yield "data: [DONE]\n\n"
 
 
885
 
886
  return StreamingResponse(
887
  generate(),
@@ -954,7 +1213,18 @@ async def get_workspace_tree():
954
  }
955
 
956
  try:
957
- w_path = Path(WORKSPACE_DIR).resolve()
 
 
 
 
 
 
 
 
 
 
 
958
  if not w_path.exists():
959
  w_path.mkdir(parents=True, exist_ok=True)
960
  return build_tree(w_path, w_path)
@@ -965,11 +1235,27 @@ async def get_workspace_tree():
965
  @app.get("/api/workspace/file")
966
  async def get_workspace_file(path: str):
967
  try:
968
- safe_p = _safe_path(path)
969
- if not safe_p.exists() or not safe_p.is_file():
970
- raise HTTPException(status_code=404, detail="File not found")
971
- content = safe_p.read_text(encoding="utf-8", errors="replace")
972
- return {"path": path, "content": content}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
973
  except Exception as e:
974
  raise HTTPException(status_code=500, detail=str(e))
975
 
@@ -977,7 +1263,18 @@ async def get_workspace_file(path: str):
977
  @app.get("/api/workspace/latest-screenshot")
978
  async def get_latest_screenshot():
979
  try:
980
- w_path = Path(WORKSPACE_DIR).resolve()
 
 
 
 
 
 
 
 
 
 
 
981
  png_files = []
982
  for p in w_path.rglob("*.png"):
983
  if any(part.startswith(".") for part in p.parts):
@@ -1747,7 +2044,21 @@ class ForgeExecuteRequest(BaseModel):
1747
  async def forge_execute(req: ForgeExecuteRequest, authorization: str = Header(None)):
1748
  auth(authorization)
1749
  log_activity(f"[Forge] Received execution request for task {req.task_id}: '{req.prompt[:80]}'")
 
 
 
 
 
 
 
 
 
 
1750
  try:
 
 
 
 
1751
  # Inject project_state.md into system context if provided (Karpathy protocol)
1752
  system_content = AGENTIC_SYSTEM_PROMPT + f"\nContext rules: {req.context_rules}"
1753
  if req.project_state_md:
@@ -1770,7 +2081,7 @@ async def forge_execute(req: ForgeExecuteRequest, authorization: str = Header(No
1770
  response = await nim_client.chat.completions.create(
1771
  model=RECOMMENDED_MODEL,
1772
  messages=final_messages,
1773
- tools=TOOLS,
1774
  tool_choice="auto",
1775
  timeout=8.0
1776
  )
@@ -1781,7 +2092,7 @@ async def forge_execute(req: ForgeExecuteRequest, authorization: str = Header(No
1781
  response = await mistral_client.chat.completions.create(
1782
  model="mistral-large-latest",
1783
  messages=final_messages,
1784
- tools=TOOLS,
1785
  tool_choice="auto"
1786
  )
1787
  else:
@@ -1831,7 +2142,7 @@ async def forge_execute(req: ForgeExecuteRequest, authorization: str = Header(No
1831
  func_args = json.loads(repaired_str)
1832
  except Exception:
1833
  func_args = {}
1834
- result = await execute_tool(func_name, func_args)
1835
 
1836
  final_messages.append({
1837
  "role": "tool",
@@ -1859,11 +2170,8 @@ async def forge_execute(req: ForgeExecuteRequest, authorization: str = Header(No
1859
  "status": "error",
1860
  "error": str(e)
1861
  }
1862
-
1863
-
1864
- # ---------------------------------------------------------------------------
1865
- # Database Retention & Heartbeat loops
1866
- # ---------------------------------------------------------------------------
1867
 
1868
  async def db_heartbeat_loop():
1869
  log_activity("Database Heartbeat task started")
@@ -2513,6 +2821,9 @@ async def db_heartbeat_loop():
2513
 
2514
 
2515
  def run_backup_loop():
 
 
 
2516
  log_activity("Local Git Backup Loop started")
2517
  while True:
2518
  # Wait 5 minutes between runs
 
27
  from pathlib import Path
28
  from typing import AsyncIterator, Optional, List, Dict, Any
29
  from pydantic import BaseModel
30
+ import contextvars
31
+ import urllib.parse
32
+
33
+ workspace_var = contextvars.ContextVar("workspace_dir", default="/tmp/workspace")
34
 
35
  # --- Ultimate Agent Brain Imports ---
36
  from second_brain import SecondBrainWrapper
 
40
  from context_engine import ContextEngine
41
 
42
  # Instantiate singletons for the orchestrator
43
+ SPACE_NAME = os.environ.get("SPACE_NAME", "space2-cerebrum")
44
+ brain = SecondBrainWrapper(space_name=SPACE_NAME)
45
  context_engine = ContextEngine(brain)
46
  watchdog = SurvivalWatchdog()
47
 
 
249
  }
250
  }
251
  },
252
+ {
253
+ "type": "function",
254
+ "function": {
255
+ "name": "web_search",
256
+ "description": "Search the web for up-to-date information, news, papers, or documentation.",
257
+ "parameters": {
258
+ "type": "object",
259
+ "properties": {
260
+ "query": {
261
+ "type": "string",
262
+ "description": "The search query (be specific and detailed)"
263
+ }
264
+ },
265
+ "required": ["query"]
266
+ }
267
+ }
268
+ },
269
+ {
270
+ "type": "function",
271
+ "function": {
272
+ "name": "web_read",
273
+ "description": "Read the clean markdown content of any webpage URL to get detailed context, articles, documentation, or code.",
274
+ "parameters": {
275
+ "type": "object",
276
+ "properties": {
277
+ "url": {
278
+ "type": "string",
279
+ "description": "The absolute URL of the webpage to read"
280
+ }
281
+ },
282
+ "required": ["url"]
283
+ }
284
+ }
285
+ },
286
  ]
287
 
288
  # ---------------------------------------------------------------------------
 
291
 
292
  def _safe_path(rel_path: str) -> Path:
293
  """Resolve a relative path safely within the workspace."""
294
+ workspace = Path(workspace_var.get()).resolve()
295
  target = (workspace / rel_path).resolve()
296
  # Prevent path traversal
297
  if not str(target).startswith(str(workspace)):
 
340
  return repaired_args, notes
341
 
342
 
343
+ # ---------------------------------------------------------------------------
344
+ # Search and MCP Helpers
345
+ # ---------------------------------------------------------------------------
346
+
347
+ def sanitize_function_name(name: str) -> str:
348
+ sanitized = re.sub(r'[^a-zA-Z0-9_\.\-]', '_', name)
349
+ if not re.match(r'^[a-zA-Z_]', sanitized):
350
+ sanitized = '_' + sanitized
351
+ if len(sanitized) > 124:
352
+ sanitized = sanitized[:124]
353
+ return sanitized
354
+
355
+ def create_mcp_tool_id(server_name: str, tool_name: str) -> str:
356
+ san_server = sanitize_function_name(server_name)
357
+ san_tool = sanitize_function_name(tool_name)
358
+ max_len = 124
359
+ sep = "_"
360
+ if len(san_server) + len(san_tool) + len(sep) > max_len:
361
+ total = len(san_server) + len(san_tool)
362
+ server_portion = int((len(san_server) / total) * (max_len - len(sep)))
363
+ tool_portion = max_len - len(sep) - server_portion
364
+ return f"{san_server[:server_portion]}{sep}{san_tool[:tool_portion]}"
365
+ return f"{san_server}{sep}{san_tool}"
366
+
367
+ async def get_active_mcp_tools():
368
+ """
369
+ Queries `mcp_server` table and returns:
370
+ 1. List of OpenAI function definitions to append to dynamic tools list.
371
+ 2. Dictionary mapping `mcp_tool_id` to `(server_name, original_tool_name, config)`.
372
+ """
373
+ mcp_tools_list = []
374
+ mcp_mapping = {}
375
+ if not db_pool:
376
+ return mcp_tools_list, mcp_mapping
377
+ try:
378
+ async with db_pool.acquire() as conn:
379
+ rows = await conn.fetch("SELECT name, config, tool_info FROM mcp_server WHERE enabled = true")
380
+ for row in rows:
381
+ server_name = row["name"]
382
+ config_raw = row["config"]
383
+ if isinstance(config_raw, str):
384
+ config = json.loads(config_raw)
385
+ else:
386
+ config = config_raw
387
+ tool_info_raw = row["tool_info"]
388
+ if not tool_info_raw:
389
+ continue
390
+ if isinstance(tool_info_raw, str):
391
+ tool_info = json.loads(tool_info_raw)
392
+ else:
393
+ tool_info = tool_info_raw
394
+ for tool in tool_info:
395
+ tool_name = tool.get("name")
396
+ description = tool.get("description", "")
397
+ input_schema = tool.get("inputSchema", {})
398
+ tool_id = create_mcp_tool_id(server_name, tool_name)
399
+ mcp_mapping[tool_id] = (server_name, tool_name, config)
400
+ mcp_tools_list.append({
401
+ "type": "function",
402
+ "function": {
403
+ "name": tool_id,
404
+ "description": f"[from MCP server: {server_name}] {description}",
405
+ "parameters": input_schema
406
+ }
407
+ })
408
+ except Exception as e:
409
+ log_activity(f"[MCP Database Query Warning] Failed to load MCP tools: {e}")
410
+ return mcp_tools_list, mcp_mapping
411
+
412
+ async def execute_web_search(query: str) -> str:
413
+ tavily_key = os.environ.get("TAVILY_API_KEY", "")
414
+ if tavily_key:
415
+ try:
416
+ async with httpx.AsyncClient(timeout=15.0) as client:
417
+ r = await client.post("https://api.tavily.com/search", json={
418
+ "api_key": tavily_key,
419
+ "query": query,
420
+ "search_depth": "basic",
421
+ "max_results": 5
422
+ })
423
+ if r.status_code == 200:
424
+ data = r.json()
425
+ results = []
426
+ for item in data.get("results", []):
427
+ results.append(f"Title: {item.get('title')}\nURL: {item.get('url')}\nContent: {item.get('content')}\n---")
428
+ return "\n".join(results) if results else "No results found."
429
+ except Exception as e:
430
+ log_activity(f"[Tavily Error] {e}")
431
+
432
+ exa_key = os.environ.get("EXA_API_KEY", "")
433
+ if exa_key:
434
+ try:
435
+ async with httpx.AsyncClient(timeout=15.0) as client:
436
+ r = await client.post("https://api.exa.ai/search", headers={
437
+ "x-api-key": exa_key,
438
+ "Content-Type": "application/json"
439
+ }, json={
440
+ "query": query,
441
+ "numResults": 5,
442
+ "text": True
443
+ })
444
+ if r.status_code == 200:
445
+ data = r.json()
446
+ results = []
447
+ for item in data.get("results", []):
448
+ results.append(f"Title: {item.get('title')}\nURL: {item.get('url')}\nContent: {item.get('text', '')[:2000]}\n---")
449
+ return "\n".join(results) if results else "No results found."
450
+ except Exception as e:
451
+ log_activity(f"[Exa Error] {e}")
452
+
453
+ try:
454
+ escaped_query = urllib.parse.quote(query)
455
+ async with httpx.AsyncClient(headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"}, timeout=10.0) as client:
456
+ r = await client.get(f"https://html.duckduckgo.com/html/?q={escaped_query}")
457
+ if r.status_code == 200:
458
+ try:
459
+ from bs4 import BeautifulSoup
460
+ soup = BeautifulSoup(r.text, 'html.parser')
461
+ results = []
462
+ for a in soup.find_all('a', class_='result__snippet')[:5]:
463
+ title_el = a.find_previous('a', class_='result__url')
464
+ title = title_el.text.strip() if title_el else "No Title"
465
+ url = title_el['href'] if title_el and 'href' in title_el.attrs else ""
466
+ snippet = a.text.strip()
467
+ results.append(f"Title: {title}\nURL: {url}\nSnippet: {snippet}\n---")
468
+ return "\n".join(results) if results else "No results found."
469
+ except Exception:
470
+ snippets = re.findall(r'<a class="result__snippet"[^>]*>(.*?)</a>', r.text, re.DOTALL)
471
+ results = []
472
+ for s in snippets[:5]:
473
+ clean_s = re.sub(r'<[^>]*>', '', s).strip()
474
+ results.append(f"Snippet: {clean_s}\n---")
475
+ return "\n".join(results) if results else "No results found."
476
+ except Exception as e:
477
+ log_activity(f"[DDG Scrape Error] {e}")
478
+ return "Error: Web search failed. Setup API keys (TAVILY_API_KEY, EXA_API_KEY) for best results."
479
+
480
+ async def execute_web_read(url: str) -> str:
481
+ jina_url = f"https://r.jina.ai/{url}"
482
+ try:
483
+ async with httpx.AsyncClient(timeout=20.0) as client:
484
+ r = await client.get(jina_url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"})
485
+ if r.status_code == 200:
486
+ content = r.text
487
+ if len(content) > 30000:
488
+ content = content[:30000] + "\n\n[Truncated - webpage content is extremely long]"
489
+ return content
490
+ except Exception as e:
491
+ log_activity(f"[Jina Reader Error] {e}")
492
+
493
+ try:
494
+ async with httpx.AsyncClient(timeout=15.0) as client:
495
+ r = await client.get(url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"})
496
+ if r.status_code == 200:
497
+ html = r.text
498
+ clean = re.sub(r'<script.*?>.*?</script>', '', html, flags=re.DOTALL | re.IGNORECASE)
499
+ clean = re.sub(r'<style.*?>.*?</style>', '', clean, flags=re.DOTALL | re.IGNORECASE)
500
+ clean = re.sub(r'<.*?>', ' ', clean)
501
+ clean = re.sub(r'\s+', ' ', clean).strip()
502
+ if len(clean) > 15000:
503
+ clean = clean[:15000] + "\n\n[Truncated]"
504
+ return clean
505
+ except Exception as e:
506
+ log_activity(f"[Direct Scrape Error] {e}")
507
+ return f"Error: Failed to read URL {url}."
508
+
509
+
510
+ async def execute_tool(name: str, arguments: dict, mcp_mapping: dict = None) -> str:
511
  """Execute a tool and return its output as a string asynchronously."""
512
  try:
513
  if name == "read_file":
 
539
  command,
540
  stdout=asyncio.subprocess.PIPE,
541
  stderr=asyncio.subprocess.PIPE,
542
+ cwd=workspace_var.get(),
543
  env={**os.environ, "HOME": "/tmp", "PATH": os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin")},
544
  )
545
 
 
596
  "grep", "-rn", "--include=*", pattern, str(path),
597
  stdout=asyncio.subprocess.PIPE,
598
  stderr=asyncio.subprocess.PIPE,
599
+ cwd=workspace_var.get(),
600
  )
601
  try:
602
  stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=10)
 
613
  output = output[:10000] + "\n\n[Truncated]"
614
  return output
615
 
616
+ elif name == "web_search":
617
+ query = arguments.get("query")
618
+ return await execute_web_search(query)
619
+
620
+ elif name == "web_read":
621
+ url = arguments.get("url")
622
+ return await execute_web_read(url)
623
+
624
+ elif mcp_mapping and name in mcp_mapping:
625
+ server_name, original_tool_name, config = mcp_mapping[name]
626
+ try:
627
+ # Ensure registered on Space 4
628
+ await apost_json(f"{SPACE4_URL}/api/mcp/register", {
629
+ "name": server_name,
630
+ "config": config
631
+ })
632
+ # Call tool on Space 4
633
+ call_res = await apost_json(f"{SPACE4_URL}/api/mcp/call", {
634
+ "serverName": server_name,
635
+ "toolName": original_tool_name,
636
+ "arguments": arguments
637
+ })
638
+ if isinstance(call_res, dict) and "error" in call_res:
639
+ return f"Error from MCP server {server_name}: {call_res['error']}"
640
+ if isinstance(call_res, dict) and "content" in call_res:
641
+ texts = []
642
+ for item in call_res["content"]:
643
+ if isinstance(item, dict) and item.get("type") == "text":
644
+ texts.append(item.get("text", ""))
645
+ return "\n".join(texts)
646
+ return str(call_res)
647
+ except Exception as e:
648
+ return f"Error executing MCP tool {name}: {str(e)}"
649
+
650
  else:
651
  return f"Error: Unknown tool: {name}"
652
 
 
948
  if user_msg:
949
  await save_message(session_id, "user", user_msg.get("content", ""))
950
 
951
+ session_folder = session_id[:8] if session_id else "default"
952
+ active_workspace = os.path.join(WORKSPACE_DIR, session_folder)
953
+ os.makedirs(active_workspace, exist_ok=True)
954
+
955
  if not stream:
956
  # Non-streaming: simple completion
957
+ workspace_token = workspace_var.set(active_workspace)
958
  try:
959
+ mcp_tools, mcp_mapping = await get_active_mcp_tools()
960
+ active_tools = list(TOOLS) + mcp_tools
961
  kwargs = {"model": requested_model, "messages": final_messages}
962
  if is_agentic:
963
+ kwargs["tools"] = active_tools
964
  kwargs["tool_choice"] = "auto"
965
  async with completions_semaphore:
966
  response = await client.chat.completions.create(**kwargs)
 
978
  except Exception as e:
979
  ACTIVE_SESSIONS.discard(session_id)
980
  return JSONResponse({"error": {"message": str(e), "type": "internal_error"}}, status_code=500)
981
+ finally:
982
+ workspace_var.reset(workspace_token)
983
 
984
  # Streaming + agentic loop
985
  async def generate() -> AsyncIterator[str]:
986
  nonlocal final_messages
987
+ workspace_token = workspace_var.set(active_workspace)
988
+ try:
989
+ mcp_tools, mcp_mapping = await get_active_mcp_tools()
990
+ active_tools = list(TOOLS) + mcp_tools
991
+ async with completions_semaphore:
992
  for round_num in range(MAX_TOOL_ROUNDS + 1):
993
  # Perform auto-compaction before calling NIM API
994
  final_messages = compact_history(final_messages)
995
  kwargs = {"model": requested_model, "messages": final_messages, "stream": True}
996
  if is_agentic:
997
+ kwargs["tools"] = active_tools
998
  kwargs["tool_choice"] = "auto"
999
 
1000
  # Collect streamed response
 
1088
  yield make_chunk(request_id, requested_model, f"\n\n🔧 **{func_name}**")
1089
  if repair_notes:
1090
  yield make_chunk(request_id, requested_model, " *(Auto-Repaired)*")
 
1091
  if func_name == "run_bash" and "command" in repaired_args:
1092
  yield make_chunk(request_id, requested_model, f": `{repaired_args['command']}`\n")
1093
  elif func_name == "read_file" and "path" in repaired_args:
 
1098
  yield make_chunk(request_id, requested_model, f": `{repaired_args.get('path', '.')}`\n")
1099
  elif func_name == "grep_search":
1100
  yield make_chunk(request_id, requested_model, f": `{repaired_args.get('pattern', '')}`\n")
1101
+ elif func_name == "web_search" and "query" in repaired_args:
1102
+ yield make_chunk(request_id, requested_model, f": `{repaired_args['query']}`\n")
1103
+ elif func_name == "web_read" and "url" in repaired_args:
1104
+ yield make_chunk(request_id, requested_model, f": `{repaired_args['url']}`\n")
1105
+ elif mcp_mapping and func_name in mcp_mapping:
1106
+ yield make_chunk(request_id, requested_model, f": calling tool\n")
1107
  else:
1108
  yield make_chunk(request_id, requested_model, "\n")
1109
 
1110
  # Execute the tool
1111
+ result = await execute_tool(func_name, repaired_args, mcp_mapping)
1112
 
1113
  # Append teaching note if repaired
1114
  if repair_notes:
 
1134
  yield make_chunk(request_id, requested_model, finish_reason="stop")
1135
  yield "data: [DONE]\n\n"
1136
 
1137
+ except Exception as e:
1138
+ error_msg = f"\n\n❌ Error: {str(e)}"
1139
+ yield make_chunk(request_id, requested_model, error_msg)
1140
+ yield make_chunk(request_id, requested_model, finish_reason="stop")
1141
+ yield "data: [DONE]\n\n"
1142
+ finally:
1143
+ workspace_var.reset(workspace_token)
1144
 
1145
  return StreamingResponse(
1146
  generate(),
 
1213
  }
1214
 
1215
  try:
1216
+ active_project = ""
1217
+ if db_pool:
1218
+ try:
1219
+ async with db_pool.acquire() as conn:
1220
+ row = await conn.fetchrow("SELECT project_name FROM eternity_projects WHERE is_active = true ORDER BY id DESC LIMIT 1")
1221
+ if row:
1222
+ active_project = row["project_name"].replace(" ", "-").lower()
1223
+ except Exception:
1224
+ pass
1225
+
1226
+ project_folder = active_project if active_project else "default"
1227
+ w_path = (Path(WORKSPACE_DIR) / project_folder).resolve()
1228
  if not w_path.exists():
1229
  w_path.mkdir(parents=True, exist_ok=True)
1230
  return build_tree(w_path, w_path)
 
1235
  @app.get("/api/workspace/file")
1236
  async def get_workspace_file(path: str):
1237
  try:
1238
+ active_project = ""
1239
+ if db_pool:
1240
+ try:
1241
+ async with db_pool.acquire() as conn:
1242
+ row = await conn.fetchrow("SELECT project_name FROM eternity_projects WHERE is_active = true ORDER BY id DESC LIMIT 1")
1243
+ if row:
1244
+ active_project = row["project_name"].replace(" ", "-").lower()
1245
+ except Exception:
1246
+ pass
1247
+
1248
+ project_folder = active_project if active_project else "default"
1249
+ w_path = (Path(WORKSPACE_DIR) / project_folder).resolve()
1250
+ token = workspace_var.set(str(w_path))
1251
+ try:
1252
+ safe_p = _safe_path(path)
1253
+ if not safe_p.exists() or not safe_p.is_file():
1254
+ raise HTTPException(status_code=404, detail="File not found")
1255
+ content = safe_p.read_text(encoding="utf-8", errors="replace")
1256
+ return {"path": path, "content": content}
1257
+ finally:
1258
+ workspace_var.reset(token)
1259
  except Exception as e:
1260
  raise HTTPException(status_code=500, detail=str(e))
1261
 
 
1263
  @app.get("/api/workspace/latest-screenshot")
1264
  async def get_latest_screenshot():
1265
  try:
1266
+ active_project = ""
1267
+ if db_pool:
1268
+ try:
1269
+ async with db_pool.acquire() as conn:
1270
+ row = await conn.fetchrow("SELECT project_name FROM eternity_projects WHERE is_active = true ORDER BY id DESC LIMIT 1")
1271
+ if row:
1272
+ active_project = row["project_name"].replace(" ", "-").lower()
1273
+ except Exception:
1274
+ pass
1275
+
1276
+ project_folder = active_project if active_project else "default"
1277
+ w_path = (Path(WORKSPACE_DIR) / project_folder).resolve()
1278
  png_files = []
1279
  for p in w_path.rglob("*.png"):
1280
  if any(part.startswith(".") for part in p.parts):
 
2044
  async def forge_execute(req: ForgeExecuteRequest, authorization: str = Header(None)):
2045
  auth(authorization)
2046
  log_activity(f"[Forge] Received execution request for task {req.task_id}: '{req.prompt[:80]}'")
2047
+
2048
+ project_folder = "default"
2049
+ if "_" in req.task_id:
2050
+ parts = req.task_id.split("_")
2051
+ if len(parts) >= 2:
2052
+ project_folder = parts[1].replace(" ", "-").lower()
2053
+ active_workspace = os.path.join(WORKSPACE_DIR, project_folder)
2054
+ os.makedirs(active_workspace, exist_ok=True)
2055
+ workspace_token = workspace_var.set(active_workspace)
2056
+
2057
  try:
2058
+ # Load active MCP tools
2059
+ mcp_tools, mcp_mapping = await get_active_mcp_tools()
2060
+ active_tools = list(TOOLS) + mcp_tools
2061
+
2062
  # Inject project_state.md into system context if provided (Karpathy protocol)
2063
  system_content = AGENTIC_SYSTEM_PROMPT + f"\nContext rules: {req.context_rules}"
2064
  if req.project_state_md:
 
2081
  response = await nim_client.chat.completions.create(
2082
  model=RECOMMENDED_MODEL,
2083
  messages=final_messages,
2084
+ tools=active_tools,
2085
  tool_choice="auto",
2086
  timeout=8.0
2087
  )
 
2092
  response = await mistral_client.chat.completions.create(
2093
  model="mistral-large-latest",
2094
  messages=final_messages,
2095
+ tools=active_tools,
2096
  tool_choice="auto"
2097
  )
2098
  else:
 
2142
  func_args = json.loads(repaired_str)
2143
  except Exception:
2144
  func_args = {}
2145
+ result = await execute_tool(func_name, func_args, mcp_mapping)
2146
 
2147
  final_messages.append({
2148
  "role": "tool",
 
2170
  "status": "error",
2171
  "error": str(e)
2172
  }
2173
+ finally:
2174
+ workspace_var.reset(workspace_token)
 
 
 
2175
 
2176
  async def db_heartbeat_loop():
2177
  log_activity("Database Heartbeat task started")
 
2821
 
2822
 
2823
  def run_backup_loop():
2824
+ if os.environ.get("SPACE_NAME", "space2-cerebrum") == "space2-cerebrum":
2825
+ log_activity("Local Git Backup Loop disabled on Cerebrum (Space 2)")
2826
+ return
2827
  log_activity("Local Git Backup Loop started")
2828
  while True:
2829
  # Wait 5 minutes between runs