synapse-orch-ai 1.6.6 → 1.7.1
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/README.md +28 -1
- package/backend/core/agent_logger.py +114 -26
- package/backend/core/config.py +40 -10
- package/backend/core/llm_providers.py +13 -5
- package/backend/core/models.py +20 -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/data.py +58 -9
- 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 +306 -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 +314 -10
- 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 → 06h113nhz_q9o.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 → -0bSwm8mQKkWfZUVOyM4q}/_buildManifest.js +0 -0
- /package/frontend-build/.next/static/{m4R7kM-i8HghwNcddY-ZF → -0bSwm8mQKkWfZUVOyM4q}/_clientMiddlewareManifest.js +0 -0
- /package/frontend-build/.next/static/{m4R7kM-i8HghwNcddY-ZF → -0bSwm8mQKkWfZUVOyM4q}/_ssgManifest.js +0 -0
package/README.md
CHANGED
|
@@ -75,12 +75,38 @@ Then open `http://localhost:3000`. See the [Docker guide](https://docs.synapseor
|
|
|
75
75
|
|
|
76
76
|
---
|
|
77
77
|
|
|
78
|
+
## Scale Mode
|
|
79
|
+
|
|
80
|
+
Run unlimited agents and orchestrations concurrently. When you need to go beyond a single process, the distributed scale layer handles the load:
|
|
81
|
+
|
|
82
|
+
- **Redis Cluster** — job queue, SSE event streams, pub/sub cancellation signals, auto-failover
|
|
83
|
+
- **ARQ worker fleet** — 1 to 100+ independent workers, each running up to 20 concurrent orchestrations; autoscale with KEDA on queue depth
|
|
84
|
+
- **PgBouncer** — multiplexes hundreds of worker connections into a small, stable Postgres pool
|
|
85
|
+
- **S3 artifact storage** — stream large file outputs directly to AWS S3, Cloudflare R2, or MinIO
|
|
86
|
+
- **Multi-tenant quotas** — per-team or per-customer concurrent run limits with HTTP 429 enforcement
|
|
87
|
+
- **Per-step checkpoint recovery** — worker crashes don't lose jobs; the next worker resumes from the last completed step
|
|
88
|
+
|
|
89
|
+
Three Docker images — pull only what you need:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
docker pull synapseorchai/synapse-ai:latest # full app (standalone mode)
|
|
93
|
+
docker pull synapseorchai/synapse-ai-api-server:latest # stateless API server (scale mode)
|
|
94
|
+
docker pull synapseorchai/synapse-ai-worker:latest # worker process (scale mode, run as many as needed)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The `docker-compose.yml` in the repo spins up the full stack. Production K8s manifests are in `infra/k8s/`.
|
|
98
|
+
|
|
99
|
+
📖 [**Scale Mode docs →**](https://docs.synapseorch.com/scale/overview)
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
78
103
|
## What Makes Synapse Different
|
|
79
104
|
|
|
80
105
|
- **Multi-Model Orchestrations** — Run a different LLM at every step. Use a fast model for routing, a powerful one for analysis. You control where the compute goes.
|
|
81
106
|
- **Deterministic DAG Execution** — Orchestrations follow the exact path you designed. No hallucinated detours.
|
|
82
107
|
- **Turn Anything Into a Tool** — Python scripts, REST APIs, webhooks, MCP servers, or entire orchestrations — all become agent-callable tools.
|
|
83
108
|
- **Human-in-the-Loop** — Pause workflows for human review. Resumable across restarts. Connect via UI, Slack, Telegram, or any messaging channel.
|
|
109
|
+
- **Scales to Millions of Requests** — The distributed scale layer separates API servers, Redis job queues, and independent worker processes so you can run any number of agents or orchestrations concurrently. Start on one machine, grow to a Kubernetes cluster — the V2 API never changes.
|
|
84
110
|
- **Local-First, No Lock-In** — Full local operation with Ollama. Mix local and cloud models freely. Your data stays yours.
|
|
85
111
|
- **Built-In Scheduling & Messaging** — Cron-based automation with results pushed to Slack, Discord, Telegram, Teams, or WhatsApp.
|
|
86
112
|
- **14+ LLM Providers** — Cloud, local, and CLI providers including Ollama, OpenAI, Anthropic, Gemini, xAI, DeepSeek, AWS Bedrock, and more.
|
|
@@ -122,7 +148,8 @@ https://github.com/user-attachments/assets/282cc99d-cdea-4ad0-b648-f22112c6e295
|
|
|
122
148
|
| **AI Builder** | A meta-agent that designs and materializes orchestrations from natural language. [Docs →](https://docs.synapseorch.com/orchestrations/ai-builder) |
|
|
123
149
|
| **Schedules** | Cron/interval automation with messaging notifications. [Docs →](https://docs.synapseorch.com/integrations/scheduling) |
|
|
124
150
|
| **Messaging** | Slack, Discord, Telegram, Teams, WhatsApp — with multi-agent mode. [Docs →](https://docs.synapseorch.com/integrations/messaging) |
|
|
125
|
-
| **
|
|
151
|
+
| **Scale Mode** | Distributed execution layer: Redis job queue, independent worker fleet, per-step Postgres checkpoints, S3 artifact storage, and multi-tenant quotas. [Docs →](https://docs.synapseorch.com/scale/overview) |
|
|
152
|
+
| **V2 API** | Stable versioned REST API for building products on top of Synapse — enqueue, stream, cancel, webhooks. [Docs →](https://docs.synapseorch.com/api/overview) |
|
|
126
153
|
| **Vault** | Persistent file storage shared across agents and sessions. [Docs →](https://docs.synapseorch.com/vault) |
|
|
127
154
|
|
|
128
155
|
---
|
|
@@ -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
|
@@ -39,6 +39,10 @@ def load_settings():
|
|
|
39
39
|
"huggingface_token": "",
|
|
40
40
|
"huggingface_models": "",
|
|
41
41
|
"huggingface_max_new_tokens": 1024,
|
|
42
|
+
"anthropic_cli_models": "",
|
|
43
|
+
"gemini_cli_models": "",
|
|
44
|
+
"codex_cli_models": "",
|
|
45
|
+
"github_copilot_cli_models": "",
|
|
42
46
|
"bedrock_api_key": "",
|
|
43
47
|
"bedrock_inference_profile": "",
|
|
44
48
|
"embedding_model": "",
|
|
@@ -77,19 +81,45 @@ def load_settings():
|
|
|
77
81
|
"login_enabled": False,
|
|
78
82
|
"login_username": "",
|
|
79
83
|
"login_password_hash": "",
|
|
84
|
+
# Scale / distributed execution
|
|
85
|
+
"redis_url": "",
|
|
86
|
+
"scale_postgres_url": "",
|
|
87
|
+
"scale_mode_enabled": False,
|
|
88
|
+
"scale_auto_sync": False,
|
|
89
|
+
"worker_concurrency": 10,
|
|
90
|
+
"otlp_endpoint": "",
|
|
91
|
+
"metrics_token": "",
|
|
92
|
+
"max_global_queue_depth": 1_000_000,
|
|
93
|
+
"rate_limit_per_tenant_rps": 1000,
|
|
94
|
+
"pgbouncer_mode": False,
|
|
95
|
+
"num_queue_shards": 1,
|
|
80
96
|
}
|
|
81
97
|
|
|
82
98
|
if not os.path.exists(SETTINGS_FILE):
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
99
|
+
file_settings = {}
|
|
100
|
+
else:
|
|
101
|
+
try:
|
|
102
|
+
with open(SETTINGS_FILE, 'r') as f:
|
|
103
|
+
file_settings = json.load(f)
|
|
104
|
+
except Exception as e:
|
|
105
|
+
print(f"DEBUG: Error loading settings: {e}")
|
|
106
|
+
file_settings = {}
|
|
107
|
+
|
|
108
|
+
settings = {**default_settings, **file_settings}
|
|
109
|
+
|
|
110
|
+
# In scale worker mode, inject_llm_env() populates SYNAPSE_SETTING_* env vars
|
|
111
|
+
# from Postgres. Overlay them here so all callers of load_settings() see the
|
|
112
|
+
# Postgres-sourced values without needing access to the local settings.json.
|
|
113
|
+
_prefix = "SYNAPSE_SETTING_"
|
|
114
|
+
for _env_key, _env_val in os.environ.items():
|
|
115
|
+
if _env_key.startswith(_prefix):
|
|
116
|
+
_setting_key = _env_key[len(_prefix):].lower()
|
|
117
|
+
try:
|
|
118
|
+
settings[_setting_key] = json.loads(_env_val)
|
|
119
|
+
except Exception:
|
|
120
|
+
settings[_setting_key] = _env_val
|
|
121
|
+
|
|
122
|
+
return settings
|
|
93
123
|
|
|
94
124
|
|
|
95
125
|
def get_or_create_jwt_secret() -> str:
|
|
@@ -347,11 +347,16 @@ async def call_cli_provider(
|
|
|
347
347
|
AUTH_PATTERNS = ["login", "authenticate", "auth", "expired", "sign in", "api key", "unauthorized"]
|
|
348
348
|
RATE_LIMIT_PATTERNS = ["429", "rate limit", "ratelimit", "quota", "too many requests", "capacity", "resource_exhausted", "ratelimitexceeded"]
|
|
349
349
|
|
|
350
|
-
|
|
350
|
+
# Split into base ("cli.<provider>") + variant, preserving dots in the
|
|
351
|
+
# variant (e.g. "cli.codex.gpt-5.4" → variant "gpt-5.4"). The base is matched
|
|
352
|
+
# case-insensitively; the variant is kept verbatim since model names can be
|
|
353
|
+
# case-sensitive and are passed straight through to the CLI's -m/--model flag.
|
|
354
|
+
parts = cli_model.split(".", 2)
|
|
351
355
|
if len(parts) >= 2:
|
|
352
|
-
base_cli = f"{parts[0]}.{parts[1]}"
|
|
356
|
+
base_cli = f"{parts[0]}.{parts[1]}".lower()
|
|
353
357
|
else:
|
|
354
358
|
base_cli = cli_model.lower()
|
|
359
|
+
variant = parts[2] if len(parts) > 2 else None
|
|
355
360
|
|
|
356
361
|
base_cmd = _CLI_COMMANDS.get(base_cli)
|
|
357
362
|
if not base_cmd:
|
|
@@ -365,8 +370,7 @@ async def call_cli_provider(
|
|
|
365
370
|
cmd.extend(["--resume", cli_session_id])
|
|
366
371
|
|
|
367
372
|
# ── Dynamic Model & Options Parsing ──
|
|
368
|
-
if
|
|
369
|
-
variant = parts[2]
|
|
373
|
+
if variant:
|
|
370
374
|
if base_cli == "cli.claude":
|
|
371
375
|
# Extract base model by removing any thinking suffixes
|
|
372
376
|
clean_variant = variant.replace('-thinking', '').replace('-max', '').replace('-high', '')
|
|
@@ -577,10 +581,14 @@ async def call_cli_provider(
|
|
|
577
581
|
)
|
|
578
582
|
|
|
579
583
|
if not clean:
|
|
584
|
+
# Report the TAIL of stderr, not the head. CLIs like codex echo a
|
|
585
|
+
# startup banner (version/workdir/model) to the head of stderr and
|
|
586
|
+
# write the actual failure (e.g. an invalid_request_error for an
|
|
587
|
+
# unsupported model) at the tail — so [:200] would hide the real cause.
|
|
580
588
|
raise LLMError(
|
|
581
589
|
f"CLI provider '{cli_model}' returned empty output. "
|
|
582
590
|
f"Return code: {process.returncode}. "
|
|
583
|
-
f"stderr: {stderr_text[:
|
|
591
|
+
f"stderr: {stderr_text[-600:]}"
|
|
584
592
|
)
|
|
585
593
|
|
|
586
594
|
print(f"DEBUG: ✅ CLI '{cli_model}' response ({len(clean)} chars)", flush=True)
|
package/backend/core/models.py
CHANGED
|
@@ -90,6 +90,13 @@ class Settings(BaseModel):
|
|
|
90
90
|
local_compatible_embed_models: str = "" # Comma-separated embedding model names for local v1-compatible
|
|
91
91
|
huggingface_token: str = "" # Optional HF access token (required for gated models like Llama, Gemma)
|
|
92
92
|
huggingface_models: str = "" # Comma- or newline-separated HF model IDs (e.g. Qwen/Qwen2.5-7B-Instruct)
|
|
93
|
+
# Comma-separated custom model names for the local CLI session providers.
|
|
94
|
+
# Each entry becomes a selectable cli.<provider>.<name> model (e.g. "gpt-5.4"
|
|
95
|
+
# → cli.codex.gpt-5.4), passed to the CLI via its -m/--model flag at call time.
|
|
96
|
+
anthropic_cli_models: str = ""
|
|
97
|
+
gemini_cli_models: str = ""
|
|
98
|
+
codex_cli_models: str = ""
|
|
99
|
+
github_copilot_cli_models: str = ""
|
|
93
100
|
huggingface_max_new_tokens: int = 1024 # Max tokens to generate per HF call
|
|
94
101
|
# Transform step Python execution runtime: "docker" (sandboxed, default) or
|
|
95
102
|
# "host" (unsandboxed subprocess on host — needed for torch/GPU workloads,
|
|
@@ -124,6 +131,19 @@ class Settings(BaseModel):
|
|
|
124
131
|
login_username: str = ""
|
|
125
132
|
login_password_hash: str = ""
|
|
126
133
|
|
|
134
|
+
# Scale / distributed execution settings
|
|
135
|
+
redis_url: str = ""
|
|
136
|
+
scale_postgres_url: str = ""
|
|
137
|
+
scale_mode_enabled: bool = False
|
|
138
|
+
scale_auto_sync: bool = False
|
|
139
|
+
worker_concurrency: int = 10
|
|
140
|
+
otlp_endpoint: str = ""
|
|
141
|
+
metrics_token: str = ""
|
|
142
|
+
max_global_queue_depth: int = 1_000_000
|
|
143
|
+
rate_limit_per_tenant_rps: int = 1000
|
|
144
|
+
pgbouncer_mode: bool = False
|
|
145
|
+
num_queue_shards: int = 1
|
|
146
|
+
|
|
127
147
|
|
|
128
148
|
class PersonalAddress(BaseModel):
|
|
129
149
|
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
|