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.
- package/backend/core/agent_logger.py +114 -26
- package/backend/core/config.py +36 -10
- package/backend/core/models.py +13 -0
- package/backend/core/orchestration/engine.py +38 -4
- package/backend/core/orchestration/logger.py +104 -25
- package/backend/core/orchestration/steps.py +9 -11
- package/backend/core/react_engine.py +16 -6
- package/backend/core/routes/api_v2.py +742 -0
- package/backend/core/routes/orchestrations.py +72 -10
- package/backend/core/routes/scale.py +701 -0
- package/backend/core/routes/tools.py +39 -0
- package/backend/core/routes/vault.py +207 -30
- package/backend/core/s3_storage.py +149 -0
- package/backend/core/scale/__init__.py +0 -0
- package/backend/core/scale/config.py +104 -0
- package/backend/core/scale/context.py +92 -0
- package/backend/core/scale/db.py +56 -0
- package/backend/core/scale/event_bridge.py +229 -0
- package/backend/core/scale/heartbeat.py +121 -0
- package/backend/core/scale/llm_keys.py +73 -0
- package/backend/core/scale/metrics.py +113 -0
- package/backend/core/scale/models_db.py +230 -0
- package/backend/core/scale/pubsub.py +175 -0
- package/backend/core/scale/state_pg.py +157 -0
- package/backend/core/scale/sync.py +305 -0
- package/backend/core/scale/telemetry.py +101 -0
- package/backend/core/scale/webhook.py +60 -0
- package/backend/core/scale/worker.py +787 -0
- package/backend/core/scale/worker_engine_adapter.py +274 -0
- package/backend/core/scale/worker_server_module.py +232 -0
- package/backend/core/schedule_logger.py +117 -29
- package/backend/core/scheduler.py +2 -0
- package/backend/core/server.py +89 -12
- package/backend/core/tools_registry.py +43 -0
- package/backend/core/vault.py +74 -5
- package/backend/requirements.txt +6 -1
- package/backend/tools/code_search.py +10 -0
- package/backend/tools/file_reader.py +190 -0
- package/backend/tools/sandbox.py +123 -11
- package/backend/tools/web_scraper.py +33 -7
- package/frontend-build/.next/BUILD_ID +1 -1
- package/frontend-build/.next/build-manifest.json +3 -3
- package/frontend-build/.next/prerender-manifest.json +3 -3
- package/frontend-build/.next/server/app/_global-error.html +1 -1
- package/frontend-build/.next/server/app/_global-error.rsc +1 -1
- package/frontend-build/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/frontend-build/.next/server/app/_not-found.html +1 -1
- package/frontend-build/.next/server/app/_not-found.rsc +2 -2
- package/frontend-build/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/frontend-build/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/frontend-build/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/frontend-build/.next/server/app/index.html +1 -1
- package/frontend-build/.next/server/app/index.rsc +2 -2
- package/frontend-build/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/frontend-build/.next/server/app/index.segments/_full.segment.rsc +2 -2
- package/frontend-build/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/frontend-build/.next/server/app/index.segments/_index.segment.rsc +2 -2
- package/frontend-build/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/frontend-build/.next/server/app/login/page_client-reference-manifest.js +1 -1
- package/frontend-build/.next/server/app/login.html +1 -1
- package/frontend-build/.next/server/app/login.rsc +2 -2
- package/frontend-build/.next/server/app/login.segments/_full.segment.rsc +2 -2
- package/frontend-build/.next/server/app/login.segments/_head.segment.rsc +1 -1
- package/frontend-build/.next/server/app/login.segments/_index.segment.rsc +2 -2
- package/frontend-build/.next/server/app/login.segments/_tree.segment.rsc +2 -2
- package/frontend-build/.next/server/app/login.segments/login/__PAGE__.segment.rsc +1 -1
- package/frontend-build/.next/server/app/login.segments/login.segment.rsc +1 -1
- package/frontend-build/.next/server/app/page_client-reference-manifest.js +1 -1
- package/frontend-build/.next/server/app/settings/[tab]/page_client-reference-manifest.js +1 -1
- package/frontend-build/.next/server/chunks/ssr/_0b~n.nn._.js +313 -9
- package/frontend-build/.next/server/chunks/ssr/_0rwng5.._.js +1 -1
- package/frontend-build/.next/server/middleware-build-manifest.js +3 -3
- package/frontend-build/.next/server/middleware-manifest.json +5 -5
- package/frontend-build/.next/server/pages/404.html +1 -1
- package/frontend-build/.next/server/pages/500.html +1 -1
- package/frontend-build/.next/server/server-reference-manifest.js +1 -1
- package/frontend-build/.next/server/server-reference-manifest.json +1 -1
- package/frontend-build/.next/static/chunks/01jwck3c_vyp2.css +1 -0
- package/frontend-build/.next/static/chunks/{02k3e9vyxajlw.js → 0560sc9qpz__k.js} +1 -1
- package/frontend-build/.next/static/chunks/{0_8po4-o2i03b.js → 12b2i0--ts715.js} +328 -24
- package/package.json +1 -1
- package/frontend-build/.next/static/chunks/06ydx~dmu5o7z.css +0 -1
- /package/frontend-build/.next/static/{m4R7kM-i8HghwNcddY-ZF → dhHv31QYFDvYA6L4M3Kdb}/_buildManifest.js +0 -0
- /package/frontend-build/.next/static/{m4R7kM-i8HghwNcddY-ZF → dhHv31QYFDvYA6L4M3Kdb}/_clientMiddlewareManifest.js +0 -0
- /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
|
|
216
|
-
return
|
|
217
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
255
|
-
|
|
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
|
package/backend/core/config.py
CHANGED
|
@@ -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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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:
|
package/backend/core/models.py
CHANGED
|
@@ -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__(
|
|
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
|
-
|
|
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
|
|
299
|
-
return
|
|
300
|
-
|
|
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
|
-
|
|
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
|
|
360
|
+
for f in files:
|
|
308
361
|
run_id = f.stem
|
|
309
|
-
|
|
362
|
+
local_ids.add(run_id)
|
|
310
363
|
try:
|
|
311
364
|
head = f.read_text(encoding="utf-8", errors="replace")[:1000]
|
|
312
|
-
|
|
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
|
-
|
|
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
|
-
|
|
337
|
-
|
|
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.
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
306
|
-
|
|
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.
|
|
615
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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()
|