synapse-orch-ai 1.6.6 → 1.7.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.
Files changed (93) hide show
  1. package/backend/core/agent_logger.py +114 -26
  2. package/backend/core/config.py +36 -10
  3. package/backend/core/models.py +13 -0
  4. package/backend/core/orchestration/engine.py +38 -4
  5. package/backend/core/orchestration/logger.py +104 -25
  6. package/backend/core/orchestration/steps.py +9 -11
  7. package/backend/core/react_engine.py +16 -6
  8. package/backend/core/routes/api_v2.py +742 -0
  9. package/backend/core/routes/orchestrations.py +72 -10
  10. package/backend/core/routes/scale.py +701 -0
  11. package/backend/core/routes/tools.py +39 -0
  12. package/backend/core/routes/vault.py +207 -30
  13. package/backend/core/s3_storage.py +149 -0
  14. package/backend/core/scale/__init__.py +0 -0
  15. package/backend/core/scale/config.py +104 -0
  16. package/backend/core/scale/context.py +92 -0
  17. package/backend/core/scale/db.py +56 -0
  18. package/backend/core/scale/event_bridge.py +229 -0
  19. package/backend/core/scale/heartbeat.py +121 -0
  20. package/backend/core/scale/llm_keys.py +73 -0
  21. package/backend/core/scale/metrics.py +113 -0
  22. package/backend/core/scale/models_db.py +230 -0
  23. package/backend/core/scale/pubsub.py +175 -0
  24. package/backend/core/scale/state_pg.py +157 -0
  25. package/backend/core/scale/sync.py +305 -0
  26. package/backend/core/scale/telemetry.py +101 -0
  27. package/backend/core/scale/webhook.py +60 -0
  28. package/backend/core/scale/worker.py +787 -0
  29. package/backend/core/scale/worker_engine_adapter.py +274 -0
  30. package/backend/core/scale/worker_server_module.py +232 -0
  31. package/backend/core/schedule_logger.py +117 -29
  32. package/backend/core/scheduler.py +2 -0
  33. package/backend/core/server.py +89 -12
  34. package/backend/core/tools_registry.py +43 -0
  35. package/backend/core/vault.py +74 -5
  36. package/backend/requirements.txt +6 -1
  37. package/backend/tools/code_search.py +10 -0
  38. package/backend/tools/file_reader.py +190 -0
  39. package/backend/tools/sandbox.py +123 -11
  40. package/backend/tools/web_scraper.py +33 -7
  41. package/frontend-build/.next/BUILD_ID +1 -1
  42. package/frontend-build/.next/build-manifest.json +3 -3
  43. package/frontend-build/.next/prerender-manifest.json +3 -3
  44. package/frontend-build/.next/server/app/_global-error.html +1 -1
  45. package/frontend-build/.next/server/app/_global-error.rsc +1 -1
  46. package/frontend-build/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  47. package/frontend-build/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  48. package/frontend-build/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  49. package/frontend-build/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  50. package/frontend-build/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  51. package/frontend-build/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  52. package/frontend-build/.next/server/app/_not-found.html +1 -1
  53. package/frontend-build/.next/server/app/_not-found.rsc +2 -2
  54. package/frontend-build/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  55. package/frontend-build/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  56. package/frontend-build/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  57. package/frontend-build/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  58. package/frontend-build/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  59. package/frontend-build/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  60. package/frontend-build/.next/server/app/index.html +1 -1
  61. package/frontend-build/.next/server/app/index.rsc +2 -2
  62. package/frontend-build/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  63. package/frontend-build/.next/server/app/index.segments/_full.segment.rsc +2 -2
  64. package/frontend-build/.next/server/app/index.segments/_head.segment.rsc +1 -1
  65. package/frontend-build/.next/server/app/index.segments/_index.segment.rsc +2 -2
  66. package/frontend-build/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  67. package/frontend-build/.next/server/app/login/page_client-reference-manifest.js +1 -1
  68. package/frontend-build/.next/server/app/login.html +1 -1
  69. package/frontend-build/.next/server/app/login.rsc +2 -2
  70. package/frontend-build/.next/server/app/login.segments/_full.segment.rsc +2 -2
  71. package/frontend-build/.next/server/app/login.segments/_head.segment.rsc +1 -1
  72. package/frontend-build/.next/server/app/login.segments/_index.segment.rsc +2 -2
  73. package/frontend-build/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  74. package/frontend-build/.next/server/app/login.segments/login/__PAGE__.segment.rsc +1 -1
  75. package/frontend-build/.next/server/app/login.segments/login.segment.rsc +1 -1
  76. package/frontend-build/.next/server/app/page_client-reference-manifest.js +1 -1
  77. package/frontend-build/.next/server/app/settings/[tab]/page_client-reference-manifest.js +1 -1
  78. package/frontend-build/.next/server/chunks/ssr/_0b~n.nn._.js +313 -9
  79. package/frontend-build/.next/server/chunks/ssr/_0rwng5.._.js +1 -1
  80. package/frontend-build/.next/server/middleware-build-manifest.js +3 -3
  81. package/frontend-build/.next/server/middleware-manifest.json +5 -5
  82. package/frontend-build/.next/server/pages/404.html +1 -1
  83. package/frontend-build/.next/server/pages/500.html +1 -1
  84. package/frontend-build/.next/server/server-reference-manifest.js +1 -1
  85. package/frontend-build/.next/server/server-reference-manifest.json +1 -1
  86. package/frontend-build/.next/static/chunks/01jwck3c_vyp2.css +1 -0
  87. package/frontend-build/.next/static/chunks/{02k3e9vyxajlw.js → 0560sc9qpz__k.js} +1 -1
  88. package/frontend-build/.next/static/chunks/{0_8po4-o2i03b.js → 12b2i0--ts715.js} +328 -24
  89. package/package.json +1 -1
  90. package/frontend-build/.next/static/chunks/06ydx~dmu5o7z.css +0 -1
  91. /package/frontend-build/.next/static/{m4R7kM-i8HghwNcddY-ZF → dhHv31QYFDvYA6L4M3Kdb}/_buildManifest.js +0 -0
  92. /package/frontend-build/.next/static/{m4R7kM-i8HghwNcddY-ZF → dhHv31QYFDvYA6L4M3Kdb}/_clientMiddlewareManifest.js +0 -0
  93. /package/frontend-build/.next/static/{m4R7kM-i8HghwNcddY-ZF → dhHv31QYFDvYA6L4M3Kdb}/_ssgManifest.js +0 -0
@@ -96,6 +96,37 @@ class AgentLogger:
96
96
  {'='*80}
97
97
  """)
98
98
 
99
+ def close(self) -> None:
100
+ """Drain the write queue, then upload the completed log to S3 (scale mode)."""
101
+ self._q.put(None) # poison pill — stops the drain thread
102
+ self._thread.join(timeout=10)
103
+ try:
104
+ from core.s3_storage import get_s3
105
+ s3 = get_s3()
106
+ if s3 and self.path.exists():
107
+ head = self.path.read_text(encoding="utf-8", errors="replace")[:1000]
108
+
109
+ def _extract(label: str) -> str:
110
+ for line in head.split("\n"):
111
+ if label in line:
112
+ return line.split(":", 1)[1].strip()
113
+ return ""
114
+
115
+ s3.upload_text(
116
+ f"logs/agent/{self.path.name}",
117
+ self.path.read_text(encoding="utf-8"),
118
+ metadata={
119
+ "agent_name": _extract("Agent Name :"),
120
+ "agent_id": _extract("Agent ID :"),
121
+ "source": _extract("Source :"),
122
+ "session_id": _extract("Session ID :"),
123
+ "started_at": _extract("Started at :"),
124
+ "user_input": _extract("User Input :")[:200],
125
+ },
126
+ )
127
+ except Exception:
128
+ pass # S3 upload failure must never surface to callers
129
+
99
130
  # ── Event logging ──────────────────────────────────────────────
100
131
 
101
132
  def log_event(self, event: dict):
@@ -212,44 +243,101 @@ class AgentLogger:
212
243
  @staticmethod
213
244
  def get_log(run_id: str) -> str | None:
214
245
  path = LOGS_DIR / f"{run_id}.log"
215
- if not path.exists():
216
- return None
217
- return path.read_text(encoding="utf-8")
246
+ if path.exists():
247
+ return path.read_text(encoding="utf-8")
248
+ # Fall back to S3 for logs written by other workers
249
+ try:
250
+ from core.s3_storage import get_s3
251
+ s3 = get_s3()
252
+ if s3:
253
+ return s3.download_text(f"logs/agent/{run_id}.log")
254
+ except Exception:
255
+ pass
256
+ return None
218
257
 
219
258
  @staticmethod
220
259
  def list_logs(limit: int = 100, offset: int = 0) -> list[dict]:
221
260
  _ensure_logs_dir()
222
- logs = []
261
+
262
+ def _parse_head(head: str, run_id: str, size_kb: float) -> dict:
263
+ def _extract(label: str) -> str:
264
+ for line in head.split("\n"):
265
+ if label in line:
266
+ return line.split(":", 1)[1].strip()
267
+ return ""
268
+ return {
269
+ "run_id": run_id,
270
+ "agent_name": _extract("Agent Name :"),
271
+ "agent_id": _extract("Agent ID :"),
272
+ "source": _extract("Source :"),
273
+ "session_id": _extract("Session ID :"),
274
+ "started_at": _extract("Started at :"),
275
+ "user_input": _extract("User Input :")[:200],
276
+ "file_size_kb": size_kb,
277
+ }
278
+
279
+ # Collect local entries
280
+ local_ids: set[str] = set()
281
+ logs: list[dict] = []
223
282
  files = sorted(LOGS_DIR.glob("*.log"), key=lambda p: p.stat().st_mtime, reverse=True)
224
- for f in files[offset : offset + limit]:
283
+ for f in files:
225
284
  run_id = f.stem
285
+ local_ids.add(run_id)
226
286
  try:
227
287
  head = f.read_text(encoding="utf-8", errors="replace")[:1000]
228
-
229
- def _extract(label: str) -> str:
230
- for line in head.split("\n"):
231
- if label in line:
232
- return line.split(":", 1)[1].strip()
233
- return ""
234
-
235
- logs.append({
236
- "run_id": run_id,
237
- "agent_name": _extract("Agent Name :"),
238
- "agent_id": _extract("Agent ID :"),
239
- "source": _extract("Source :"),
240
- "session_id": _extract("Session ID :"),
241
- "started_at": _extract("Started at :"),
242
- "user_input": _extract("User Input :")[:200],
243
- "file_size_kb": round(f.stat().st_size / 1024, 1),
244
- })
288
+ logs.append(_parse_head(head, run_id, round(f.stat().st_size / 1024, 1)))
245
289
  except Exception:
246
- logs.append({"run_id": run_id})
247
- return logs
290
+ logs.append({"run_id": run_id, "file_size_kb": 0})
291
+
292
+ # Merge S3 entries that aren't already local
293
+ try:
294
+ from core.s3_storage import get_s3
295
+ s3 = get_s3()
296
+ if s3:
297
+ from concurrent.futures import ThreadPoolExecutor
298
+ s3_keys = s3.list_keys("logs/agent/")
299
+ missing_keys = [k for k in s3_keys if k.endswith(".log") and Path(k).stem not in local_ids]
300
+
301
+ def _fetch_meta(rel_key: str) -> dict:
302
+ run_id = Path(rel_key).stem
303
+ try:
304
+ meta = s3.get_metadata(rel_key) or {}
305
+ return {
306
+ "run_id": run_id,
307
+ "agent_name": meta.get("agent_name", ""),
308
+ "agent_id": meta.get("agent_id", ""),
309
+ "source": meta.get("source", ""),
310
+ "session_id": meta.get("session_id", ""),
311
+ "started_at": meta.get("started_at", ""),
312
+ "user_input": meta.get("user_input", "")[:200],
313
+ "file_size_kb": 0,
314
+ }
315
+ except Exception:
316
+ return {"run_id": run_id}
317
+
318
+ with ThreadPoolExecutor(max_workers=10) as pool:
319
+ s3_entries = list(pool.map(_fetch_meta, missing_keys))
320
+ logs.extend(s3_entries)
321
+ except Exception:
322
+ pass
323
+
324
+ # Sort by started_at descending (local files already sorted; blend with S3)
325
+ logs.sort(key=lambda x: x.get("started_at", ""), reverse=True)
326
+ return logs[offset: offset + limit]
248
327
 
249
328
  @staticmethod
250
329
  def delete_log(run_id: str) -> bool:
251
330
  path = LOGS_DIR / f"{run_id}.log"
331
+ deleted = False
252
332
  if path.exists():
253
333
  path.unlink()
254
- return True
255
- return False
334
+ deleted = True
335
+ try:
336
+ from core.s3_storage import get_s3
337
+ s3 = get_s3()
338
+ if s3:
339
+ s3.delete(f"logs/agent/{run_id}.log")
340
+ deleted = True
341
+ except Exception:
342
+ pass
343
+ return deleted
@@ -77,19 +77,45 @@ def load_settings():
77
77
  "login_enabled": False,
78
78
  "login_username": "",
79
79
  "login_password_hash": "",
80
+ # Scale / distributed execution
81
+ "redis_url": "",
82
+ "scale_postgres_url": "",
83
+ "scale_mode_enabled": False,
84
+ "scale_auto_sync": False,
85
+ "worker_concurrency": 10,
86
+ "otlp_endpoint": "",
87
+ "metrics_token": "",
88
+ "max_global_queue_depth": 1_000_000,
89
+ "rate_limit_per_tenant_rps": 1000,
90
+ "pgbouncer_mode": False,
91
+ "num_queue_shards": 1,
80
92
  }
81
93
 
82
94
  if not os.path.exists(SETTINGS_FILE):
83
- return default_settings
84
-
85
- try:
86
- with open(SETTINGS_FILE, 'r') as f:
87
- data = json.load(f)
88
- # Merge defaults
89
- return {**default_settings, **data}
90
- except Exception as e:
91
- print(f"DEBUG: Error loading settings: {e}")
92
- return default_settings
95
+ file_settings = {}
96
+ else:
97
+ try:
98
+ with open(SETTINGS_FILE, 'r') as f:
99
+ file_settings = json.load(f)
100
+ except Exception as e:
101
+ print(f"DEBUG: Error loading settings: {e}")
102
+ file_settings = {}
103
+
104
+ settings = {**default_settings, **file_settings}
105
+
106
+ # In scale worker mode, inject_llm_env() populates SYNAPSE_SETTING_* env vars
107
+ # from Postgres. Overlay them here so all callers of load_settings() see the
108
+ # Postgres-sourced values without needing access to the local settings.json.
109
+ _prefix = "SYNAPSE_SETTING_"
110
+ for _env_key, _env_val in os.environ.items():
111
+ if _env_key.startswith(_prefix):
112
+ _setting_key = _env_key[len(_prefix):].lower()
113
+ try:
114
+ settings[_setting_key] = json.loads(_env_val)
115
+ except Exception:
116
+ settings[_setting_key] = _env_val
117
+
118
+ return settings
93
119
 
94
120
 
95
121
  def get_or_create_jwt_secret() -> str:
@@ -124,6 +124,19 @@ class Settings(BaseModel):
124
124
  login_username: str = ""
125
125
  login_password_hash: str = ""
126
126
 
127
+ # Scale / distributed execution settings
128
+ redis_url: str = ""
129
+ scale_postgres_url: str = ""
130
+ scale_mode_enabled: bool = False
131
+ scale_auto_sync: bool = False
132
+ worker_concurrency: int = 10
133
+ otlp_endpoint: str = ""
134
+ metrics_token: str = ""
135
+ max_global_queue_depth: int = 1_000_000
136
+ rate_limit_per_tenant_rps: int = 1000
137
+ pgbouncer_mode: bool = False
138
+ num_queue_shards: int = 1
139
+
127
140
 
128
141
  class PersonalAddress(BaseModel):
129
142
  address1: str = ""
@@ -4,7 +4,7 @@ checkpointing, loop guards, and yielding SSE events.
4
4
  """
5
5
  import asyncio
6
6
  import time
7
- from typing import AsyncGenerator
7
+ from typing import AsyncGenerator, Awaitable, Callable
8
8
 
9
9
  import anyio
10
10
 
@@ -20,10 +20,20 @@ MAX_NESTED_DEPTH = 3
20
20
  class OrchestrationEngine:
21
21
  """Runs an orchestration by walking through its step graph."""
22
22
 
23
- def __init__(self, orchestration: Orchestration, server_module, depth: int = 0):
23
+ def __init__(
24
+ self,
25
+ orchestration: Orchestration,
26
+ server_module,
27
+ depth: int = 0,
28
+ cancel_hook: Callable[[], Awaitable[bool]] | None = None,
29
+ ):
24
30
  self.orch = orchestration
25
31
  self.server_module = server_module
26
32
  self.depth = depth # 0 = top-level run; >0 = nested sub-orchestration
33
+ # Optional async callable returning True when the run should be cancelled.
34
+ # Used by distributed workers to check Redis instead of the in-memory set.
35
+ # V1 never sets this — cancel_hook=None uses the existing in-memory check.
36
+ self.cancel_hook = cancel_hook
27
37
  self.step_map: dict[str, StepConfig] = {s.id: s for s in orchestration.steps}
28
38
  self.executors = STEP_EXECUTORS
29
39
  self.agent_names: dict[str, str] = self._load_agent_names()
@@ -98,10 +108,20 @@ class OrchestrationEngine:
98
108
  # before the next step starts sending new requests.
99
109
  await anyio.sleep(0)
100
110
 
101
- # Check if this run was cancelled via the cancel endpoint
111
+ # Check if this run was cancelled via the cancel endpoint.
112
+ # cancel_hook (set by distributed workers) checks Redis; the default
113
+ # path checks the in-memory _cancelled_run_ids set (V1 / standalone).
102
114
  from .state import _cancelled_run_ids
103
- if run.run_id in _cancelled_run_ids:
115
+ _is_cancelled = False
116
+ if self.cancel_hook:
117
+ try:
118
+ _is_cancelled = await self.cancel_hook()
119
+ except Exception:
120
+ pass
121
+ if not _is_cancelled and run.run_id in _cancelled_run_ids:
104
122
  _cancelled_run_ids.discard(run.run_id)
123
+ _is_cancelled = True
124
+ if _is_cancelled:
105
125
  run.status = "cancelled"
106
126
  print(f"DEBUG ENGINE: 🛑 run '{run.run_id}' cancelled by request", flush=True)
107
127
  break
@@ -195,6 +215,7 @@ class OrchestrationEngine:
195
215
  if logger:
196
216
  logger.step_end(step.id, "paused")
197
217
  logger.run_end("paused")
218
+ logger.close()
198
219
  # Break exits the async-for and then the
199
220
  # fail_after scope cleanly in this task.
200
221
  # The event is yielded below, outside the scope,
@@ -295,6 +316,7 @@ class OrchestrationEngine:
295
316
 
296
317
  if logger:
297
318
  logger.run_end(run.status)
319
+ logger.close()
298
320
 
299
321
  final_output = self._build_final_response(run)
300
322
 
@@ -376,19 +398,24 @@ class OrchestrationEngine:
376
398
  """Resume a paused orchestration after human input."""
377
399
  from .state import SharedState as SS
378
400
 
401
+ print(f"[engine.resume] ▶ restoring state from JSON checkpoint for run_id={run_id}", flush=True)
379
402
  restored = SS.restore(run_id)
380
403
  run = restored.run
404
+ print(f"[engine.resume] 📋 restored run: orch_id={run.orchestration_id} status={run.status} current_step_id={run.current_step_id} waiting_for_human={run.waiting_for_human} step_history_len={len(run.step_history)}", flush=True)
381
405
 
382
406
  # Load the orchestration definition
383
407
  from core.routes.orchestrations import load_orchestrations
384
408
  orchestrations = load_orchestrations()
409
+ print(f"[engine.resume] 📦 loaded {len(orchestrations)} orchestrations from disk, looking for id={run.orchestration_id}", flush=True)
385
410
  orch_data = next((o for o in orchestrations if o["id"] == run.orchestration_id), None)
386
411
  if not orch_data:
412
+ print(f"[engine.resume] ❌ orchestration '{run.orchestration_id}' NOT FOUND on disk — available ids: {[o.get('id') for o in orchestrations]}", flush=True)
387
413
  yield {"type": "orchestration_error", "error": f"Orchestration '{run.orchestration_id}' not found"}
388
414
  return
389
415
 
390
416
  orch = Orchestration.model_validate(orch_data)
391
417
  engine = cls(orch, server_module)
418
+ print(f"[engine.resume] 🗺 step_map keys: {list(engine.step_map.keys())}", flush=True)
392
419
 
393
420
  # Create logger — appends to existing log file if present
394
421
  engine.logger = OrchestrationLogger(
@@ -402,6 +429,7 @@ class OrchestrationEngine:
402
429
  # If the parent was paused because a NESTED orchestration hit a human step,
403
430
  # resume the sub-run and let it complete before continuing the parent.
404
431
  if run.nested_run_id:
432
+ print(f"[engine.resume] 🔗 nested run detected: nested_run_id={run.nested_run_id}, delegating to _resume_nested_orch", flush=True)
405
433
  async for event in cls._resume_nested_orch(run, engine, human_response, server_module):
406
434
  yield event
407
435
  return
@@ -409,6 +437,7 @@ class OrchestrationEngine:
409
437
  # Normal path: human step is directly in this orchestration.
410
438
  # Move to next step after the HUMAN step
411
439
  current_step = engine.step_map.get(run.current_step_id)
440
+ print(f"[engine.resume] 🔍 current_step lookup: current_step_id={run.current_step_id!r} → found={current_step is not None} type={current_step.type.value if current_step else 'N/A'}", flush=True)
412
441
 
413
442
  # Write human response to shared state under the step's configured output_key.
414
443
  # Falling back to "human_response" preserves backward-compat for steps with no output_key.
@@ -424,10 +453,15 @@ class OrchestrationEngine:
424
453
  if current_step:
425
454
  next_id, _ = engine._resolve_next(current_step, run)
426
455
  run.current_step_id = next_id
456
+ print(f"[engine.resume] ➡ _resolve_next({run.current_step_id!r} was HUMAN) → next_step_id={next_id!r}", flush=True)
457
+ else:
458
+ print(f"[engine.resume] ⚠️ current_step not found in step_map — current_step_id stays as {run.current_step_id!r}, loop will likely fail immediately", flush=True)
427
459
 
460
+ print(f"[engine.resume] 🚀 entering _execute_loop with current_step_id={run.current_step_id!r} status={run.status}", flush=True)
428
461
  state = SharedState(run)
429
462
  async for event in engine._execute_loop(run, state):
430
463
  yield event
464
+ print(f"[engine.resume] 🏁 _execute_loop finished, run.status={run.status} run.current_step_id={run.current_step_id!r}", flush=True)
431
465
 
432
466
  @classmethod
433
467
  async def _resume_nested_orch(
@@ -67,6 +67,34 @@ class OrchestrationLogger:
67
67
  {'='*80}
68
68
  """)
69
69
 
70
+ def close(self) -> None:
71
+ """Upload the completed log to S3 (scale mode). No-op in standalone mode."""
72
+ try:
73
+ from core.s3_storage import get_s3
74
+ s3 = get_s3()
75
+ if s3 and self.path.exists():
76
+ head = self.path.read_text(encoding="utf-8", errors="replace")[:1000]
77
+
78
+ def _extract(label: str) -> str:
79
+ for line in head.split("\n"):
80
+ if label in line:
81
+ return line.split(":", 1)[1].strip()
82
+ return ""
83
+
84
+ s3.upload_text(
85
+ f"logs/orchestration/{self.path.name}",
86
+ self.path.read_text(encoding="utf-8"),
87
+ metadata={
88
+ "orchestration_name": _extract("Orchestration :"),
89
+ "orchestration_id": _extract("Orchestration ID:"),
90
+ "session_id": _extract("Session ID :"),
91
+ "started_at": _extract("Started at :"),
92
+ "user_input": _extract("User Input :")[:200],
93
+ },
94
+ )
95
+ except Exception:
96
+ pass
97
+
70
98
  # ── Step lifecycle ─────────────────────────────────────────────
71
99
 
72
100
  def step_start(self, step_id: str, step_name: str, step_type: str,
@@ -295,43 +323,94 @@ class OrchestrationLogger:
295
323
  @staticmethod
296
324
  def get_log(run_id: str) -> str | None:
297
325
  path = LOGS_DIR / f"{run_id}.log"
298
- if not path.exists():
299
- return None
300
- return path.read_text(encoding="utf-8")
326
+ if path.exists():
327
+ return path.read_text(encoding="utf-8")
328
+ try:
329
+ from core.s3_storage import get_s3
330
+ s3 = get_s3()
331
+ if s3:
332
+ return s3.download_text(f"logs/orchestration/{run_id}.log")
333
+ except Exception:
334
+ pass
335
+ return None
301
336
 
302
337
  @staticmethod
303
338
  def list_logs(limit: int = 100, offset: int = 0) -> list[dict]:
304
339
  _ensure_logs_dir()
305
- logs = []
340
+
341
+ def _parse_head(head: str, run_id: str, size_kb: float) -> dict:
342
+ def _extract(label: str) -> str:
343
+ for line in head.split("\n"):
344
+ if label in line:
345
+ return line.split(":", 1)[1].strip()
346
+ return ""
347
+ return {
348
+ "run_id": run_id,
349
+ "orchestration_name": _extract("Orchestration :"),
350
+ "orchestration_id": _extract("Orchestration ID:"),
351
+ "session_id": _extract("Session ID :"),
352
+ "started_at": _extract("Started at :"),
353
+ "user_input": _extract("User Input :")[:200],
354
+ "file_size_kb": size_kb,
355
+ }
356
+
357
+ local_ids: set[str] = set()
358
+ logs: list[dict] = []
306
359
  files = sorted(LOGS_DIR.glob("*.log"), key=lambda p: p.stat().st_mtime, reverse=True)
307
- for f in files[offset : offset + limit]:
360
+ for f in files:
308
361
  run_id = f.stem
309
- # Parse header lines for summary
362
+ local_ids.add(run_id)
310
363
  try:
311
364
  head = f.read_text(encoding="utf-8", errors="replace")[:1000]
312
- def _extract(label: str) -> str:
313
- for line in head.split("\n"):
314
- if label in line:
315
- return line.split(":", 1)[1].strip()
316
- return ""
317
-
318
- logs.append({
319
- "run_id": run_id,
320
- "orchestration_name": _extract("Orchestration :"),
321
- "orchestration_id": _extract("Orchestration ID:"),
322
- "session_id": _extract("Session ID :"),
323
- "started_at": _extract("Started at :"),
324
- "user_input": _extract("User Input :")[:200],
325
- "file_size_kb": round(f.stat().st_size / 1024, 1),
326
- })
365
+ logs.append(_parse_head(head, run_id, round(f.stat().st_size / 1024, 1)))
327
366
  except Exception:
328
- logs.append({"run_id": run_id})
329
- return logs
367
+ logs.append({"run_id": run_id, "file_size_kb": 0})
368
+
369
+ try:
370
+ from core.s3_storage import get_s3
371
+ s3 = get_s3()
372
+ if s3:
373
+ from concurrent.futures import ThreadPoolExecutor
374
+ s3_keys = s3.list_keys("logs/orchestration/")
375
+ missing_keys = [k for k in s3_keys if k.endswith(".log") and Path(k).stem not in local_ids]
376
+
377
+ def _fetch_meta(rel_key: str) -> dict:
378
+ run_id = Path(rel_key).stem
379
+ try:
380
+ meta = s3.get_metadata(rel_key) or {}
381
+ return {
382
+ "run_id": run_id,
383
+ "orchestration_name": meta.get("orchestration_name", ""),
384
+ "orchestration_id": meta.get("orchestration_id", ""),
385
+ "session_id": meta.get("session_id", ""),
386
+ "started_at": meta.get("started_at", ""),
387
+ "user_input": meta.get("user_input", "")[:200],
388
+ "file_size_kb": 0,
389
+ }
390
+ except Exception:
391
+ return {"run_id": run_id}
392
+
393
+ with ThreadPoolExecutor(max_workers=10) as pool:
394
+ logs.extend(pool.map(_fetch_meta, missing_keys))
395
+ except Exception:
396
+ pass
397
+
398
+ logs.sort(key=lambda x: x.get("started_at", ""), reverse=True)
399
+ return logs[offset: offset + limit]
330
400
 
331
401
  @staticmethod
332
402
  def delete_log(run_id: str) -> bool:
333
403
  path = LOGS_DIR / f"{run_id}.log"
404
+ deleted = False
334
405
  if path.exists():
335
406
  path.unlink()
336
- return True
337
- return False
407
+ deleted = True
408
+ try:
409
+ from core.s3_storage import get_s3
410
+ s3 = get_s3()
411
+ if s3:
412
+ s3.delete(f"logs/orchestration/{run_id}.log")
413
+ deleted = True
414
+ except Exception:
415
+ pass
416
+ return deleted
@@ -45,7 +45,7 @@ class AgentStepExecutor:
45
45
  ) -> AsyncGenerator[dict, None]:
46
46
  from core.react_engine import run_agent_step
47
47
  from core.agent_logger import AgentLogger
48
- from core.routes.agents import load_user_agents
48
+ from core.scale.context import resolve_agent
49
49
  print(f"DEBUG AGENT EXEC: agent_id={step.agent_id} step={step.id}", flush=True)
50
50
 
51
51
  from .context import build_origin_aware_context, snapshot_inputs
@@ -64,9 +64,7 @@ class AgentStepExecutor:
64
64
  "system_prompt_prefix": system_prompt_prefix}
65
65
 
66
66
  # ── Orchestrator-as-agent: delegate to a nested OrchestrationEngine ──
67
- target_agent = next(
68
- (a for a in load_user_agents() if a.get("id") == step.agent_id), None
69
- )
67
+ target_agent = await resolve_agent(step.agent_id) if step.agent_id else None
70
68
  if target_agent and target_agent.get("type") == "orchestrator":
71
69
  async for ev in self._execute_nested_orchestration(
72
70
  step, run, engine, transition, target_agent, prompt, inputs_snapshot
@@ -115,6 +113,7 @@ class AgentStepExecutor:
115
113
  raise
116
114
  finally:
117
115
  agent_log.run_end(_log_status)
116
+ agent_log.close()
118
117
 
119
118
  if final_response is None:
120
119
  raise RuntimeError(f"Agent step '{step.name}' ended without producing a final response")
@@ -277,7 +276,7 @@ class ToolStepExecutor:
277
276
  from core.config import load_settings
278
277
  from core.react_engine import parse_tool_call
279
278
  from core.tools import aggregate_all_tools
280
- from core.routes.agents import load_user_agents
279
+ from core.scale.context import resolve_agent, resolve_custom_tools
281
280
 
282
281
  if not step.forced_tool:
283
282
  yield {"type": "step_warning", "orch_step_id": step.id,
@@ -302,9 +301,8 @@ class ToolStepExecutor:
302
301
  mode = detect_mode_from_model(model)
303
302
 
304
303
  # Load only the forced tool's schema — aggregate all tools then filter to one
305
- agents = load_user_agents()
306
- active_agent = next((a for a in agents if a.get("id") == step.agent_id), agents[0] if agents else {})
307
- custom_tools = self._load_custom_tools()
304
+ active_agent = (await resolve_agent(step.agent_id) if step.agent_id else None) or {}
305
+ custom_tools = await resolve_custom_tools()
308
306
  all_tools, _, _, _ = await aggregate_all_tools(
309
307
  engine.server_module.agent_sessions, active_agent, custom_tools
310
308
  )
@@ -609,10 +607,10 @@ class ToolStepExecutor:
609
607
  except Exception:
610
608
  return resp.text or json.dumps({"error": f"Empty response (Status: {resp.status_code})"})
611
609
 
612
- def _load_custom_tools(self) -> list:
610
+ async def _load_custom_tools(self) -> list:
613
611
  try:
614
- from core.routes.tools import load_custom_tools
615
- return load_custom_tools()
612
+ from core.scale.context import resolve_custom_tools
613
+ return await resolve_custom_tools()
616
614
  except Exception:
617
615
  return []
618
616
 
@@ -529,8 +529,13 @@ async def run_agent_step(
529
529
  if max_turns is None:
530
530
  max_turns = MAX_TURNS
531
531
 
532
- # Resolve agent
533
- active_agent = agent_override if agent_override is not None else _resolve_agent_by_id(agent_id)
532
+ # Resolve agent — Postgres first (worker mode), JSON fallback
533
+ if agent_override is not None:
534
+ active_agent = agent_override
535
+ else:
536
+ from core.scale.context import resolve_agent as _ctx_resolve_agent
537
+ resolved = await _ctx_resolve_agent(agent_id) if agent_id else None
538
+ active_agent = resolved if resolved is not None else _resolve_agent_by_id(agent_id)
534
539
  agent_id_for_session = active_agent.get("id", agent_id or "unknown")
535
540
 
536
541
  # Build system prompt with repo and DB context injection
@@ -564,7 +569,8 @@ async def run_agent_step(
564
569
  tools_json = json.dumps(tools_override)
565
570
  print(f"DEBUG RUN_AGENT: start agent_id={agent_id_for_session}, tools_override={len(tools_override)} tools", flush=True)
566
571
  else:
567
- custom_tools = load_custom_tools()
572
+ from core.scale.context import resolve_custom_tools as _ctx_resolve_tools
573
+ custom_tools = await _ctx_resolve_tools()
568
574
  print(f"DEBUG RUN_AGENT: start agent_id={agent_id_for_session}, sessions={list(server_module.agent_sessions.keys())}", flush=True)
569
575
  all_tools, tool_schema_map, ollama_tools, tools_json = await aggregate_all_tools(
570
576
  server_module.agent_sessions, active_agent, custom_tools
@@ -1073,7 +1079,8 @@ async def run_agent_step(
1073
1079
  # ===== CUSTOM TOOLS (Webhook + Python) =====
1074
1080
 
1075
1081
  if tool_name not in server_module.tool_router:
1076
- custom_tools_list = load_custom_tools()
1082
+ from core.scale.context import resolve_custom_tools as _ctx_resolve_tools2
1083
+ custom_tools_list = await _ctx_resolve_tools2()
1077
1084
  target_tool = next((t for t in custom_tools_list if t["name"] == tool_name), None)
1078
1085
 
1079
1086
  if target_tool:
@@ -1479,8 +1486,10 @@ async def run_react_loop(request, server_module):
1479
1486
 
1480
1487
  yield {"type": "status", "message": "Processing your request..."}
1481
1488
 
1482
- # Resolve active agent
1483
- active_agent = _resolve_agent_by_id(request.agent_id)
1489
+ # Resolve active agent — Postgres first (worker mode), JSON fallback
1490
+ from core.scale.context import resolve_agent as _ctx_resolve_agent
1491
+ _resolved = await _ctx_resolve_agent(request.agent_id) if request.agent_id else None
1492
+ active_agent = _resolved if _resolved is not None else _resolve_agent_by_id(request.agent_id)
1484
1493
 
1485
1494
  # --- Orchestrator delegation ---
1486
1495
  if active_agent.get("type") == "orchestrator":
@@ -1537,3 +1546,4 @@ async def run_react_loop(request, server_module):
1537
1546
  raise
1538
1547
  finally:
1539
1548
  _agent_log.run_end(_log_status)
1549
+ _agent_log.close()