synapse-orch-ai 1.8.0 → 1.8.2
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/config.py +30 -0
- package/backend/core/llm_providers.py +62 -13
- package/backend/core/mcp_client.py +2 -2
- package/backend/core/models_orchestration.py +5 -3
- package/backend/core/openapi_import.py +207 -0
- package/backend/core/orchestration/engine.py +3 -2
- package/backend/core/orchestration/steps.py +6 -5
- package/backend/core/react_engine.py +4 -4
- package/backend/core/routes/tools.py +79 -6
- package/backend/core/scale/worker_server_module.py +2 -1
- package/backend/core/server.py +3 -2
- package/backend/core/tools.py +4 -3
- package/backend/requirements.txt +2 -1
- 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 +42 -42
- package/frontend-build/.next/server/middleware-build-manifest.js +3 -3
- 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/166_fx4i~0.31.css +1 -0
- package/frontend-build/.next/static/chunks/16j4jub4qpyf1.js +432 -0
- package/package.json +1 -1
- package/frontend-build/.next/static/chunks/01jwck3c_vyp2.css +0 -1
- package/frontend-build/.next/static/chunks/0lja9zdxjh.4l.js +0 -432
- /package/frontend-build/.next/static/{o8_DkJ-oTdtgwtT93jEXM → 6T9aJqheS49PfQ_4MbqiJ}/_buildManifest.js +0 -0
- /package/frontend-build/.next/static/{o8_DkJ-oTdtgwtT93jEXM → 6T9aJqheS49PfQ_4MbqiJ}/_clientMiddlewareManifest.js +0 -0
- /package/frontend-build/.next/static/{o8_DkJ-oTdtgwtT93jEXM → 6T9aJqheS49PfQ_4MbqiJ}/_ssgManifest.js +0 -0
package/backend/core/config.py
CHANGED
|
@@ -18,6 +18,36 @@ SETTINGS_FILE = os.path.join(DATA_DIR, "settings.json")
|
|
|
18
18
|
CREDENTIALS_FILE = os.path.join(DATA_DIR, "credentials.json")
|
|
19
19
|
TOKEN_FILE = os.path.join(DATA_DIR, "token.json")
|
|
20
20
|
|
|
21
|
+
|
|
22
|
+
# ── Configurable timeouts ────────────────────────────────────────────────────
|
|
23
|
+
# Each is read from the environment, falling back to the value hardcoded before
|
|
24
|
+
# these were made configurable. Unset env → identical behavior to before.
|
|
25
|
+
|
|
26
|
+
def _env_float(name: str, default: float) -> float:
|
|
27
|
+
try:
|
|
28
|
+
return float(os.getenv(name, "") or default)
|
|
29
|
+
except (TypeError, ValueError):
|
|
30
|
+
return float(default)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _env_int(name: str, default: int) -> int:
|
|
34
|
+
try:
|
|
35
|
+
return int(os.getenv(name, "") or default)
|
|
36
|
+
except (TypeError, ValueError):
|
|
37
|
+
return int(default)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Timeouts (seconds, unless the name says otherwise).
|
|
41
|
+
MCP_SESSION_READ_TIMEOUT = _env_float("SYNAPSE_MCP_SESSION_READ_TIMEOUT", 60.0)
|
|
42
|
+
MCP_TOOL_CALL_TIMEOUT = _env_float("SYNAPSE_MCP_TOOL_CALL_TIMEOUT", 60.0)
|
|
43
|
+
MCP_LIST_TOOLS_TIMEOUT = _env_float("SYNAPSE_MCP_LIST_TOOLS_TIMEOUT", 15.0)
|
|
44
|
+
LLM_REQUEST_TIMEOUT = _env_float("SYNAPSE_LLM_TIMEOUT", 180.0)
|
|
45
|
+
HTTP_TOOL_TIMEOUT = _env_float("SYNAPSE_HTTP_TOOL_TIMEOUT", 30.0)
|
|
46
|
+
ORCH_STEP_TIMEOUT = _env_float("SYNAPSE_ORCH_STEP_TIMEOUT", 300.0)
|
|
47
|
+
ORCH_GLOBAL_TIMEOUT_MIN = _env_int("SYNAPSE_ORCH_GLOBAL_TIMEOUT_MINUTES", 30)
|
|
48
|
+
ORCH_HUMAN_TIMEOUT = _env_float("SYNAPSE_ORCH_HUMAN_TIMEOUT", 3600.0)
|
|
49
|
+
|
|
50
|
+
|
|
21
51
|
def load_settings():
|
|
22
52
|
default_settings = {
|
|
23
53
|
"agent_name": "Synapse",
|
|
@@ -11,6 +11,7 @@ CLI providers (cli.claude, cli.gemini, cli.codex) use locally authenticated CLI
|
|
|
11
11
|
No API key needed — tool calling is emulated via system prompt injection + <tool_call> parsing.
|
|
12
12
|
"""
|
|
13
13
|
import os
|
|
14
|
+
import re
|
|
14
15
|
import json
|
|
15
16
|
import tempfile
|
|
16
17
|
import asyncio
|
|
@@ -22,11 +23,50 @@ import botocore.exceptions
|
|
|
22
23
|
from botocore import UNSIGNED as _BEDROCK_UNSIGNED
|
|
23
24
|
from botocore.config import Config
|
|
24
25
|
|
|
26
|
+
from core.config import LLM_REQUEST_TIMEOUT
|
|
27
|
+
|
|
25
28
|
# Lock to guard the process-level AWS_BEARER_TOKEN_BEDROCK env var.
|
|
26
29
|
# Multiple threads (via asyncio.to_thread) could otherwise race on set/clear.
|
|
27
30
|
_aws_env_lock = threading.Lock()
|
|
28
31
|
|
|
29
32
|
|
|
33
|
+
# ─── CLI auth-failure detection ──────────────────────────────────────────────
|
|
34
|
+
# Specific, word-boundaried phrases only. A bare "auth" substring (the old
|
|
35
|
+
# behavior) false-positives on normal model output containing "authority",
|
|
36
|
+
# "authorization", "authorized", "author", etc. — which aborted otherwise
|
|
37
|
+
# successful CLI runs. Keep this list specific enough that it only matches real
|
|
38
|
+
# authentication failures, not incidental words in a model's response.
|
|
39
|
+
_AUTH_RE = re.compile(
|
|
40
|
+
r"(?:"
|
|
41
|
+
r"not logged in"
|
|
42
|
+
r"|please log in"
|
|
43
|
+
r"|log in to"
|
|
44
|
+
r"|sign in to"
|
|
45
|
+
r"|authentication required"
|
|
46
|
+
r"|not authenticated"
|
|
47
|
+
r"|authentication failed"
|
|
48
|
+
r"|invalid api key"
|
|
49
|
+
r"|expired api key"
|
|
50
|
+
r"|api key (?:is )?(?:invalid|expired|required)"
|
|
51
|
+
r"|session (?:has )?expired"
|
|
52
|
+
r"|expired session"
|
|
53
|
+
r"|\bunauthorized\b"
|
|
54
|
+
r"|\b401\b"
|
|
55
|
+
r"|/login"
|
|
56
|
+
r")",
|
|
57
|
+
re.IGNORECASE,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _match_auth(text: str) -> bool:
|
|
62
|
+
"""Return True only if `text` contains a specific auth-failure phrase.
|
|
63
|
+
|
|
64
|
+
Deliberately avoids bare substrings like "auth"/"login"/"expired" so normal
|
|
65
|
+
model output ("authority", "authorization", ...) is never flagged.
|
|
66
|
+
"""
|
|
67
|
+
return bool(text) and _AUTH_RE.search(text) is not None
|
|
68
|
+
|
|
69
|
+
|
|
30
70
|
# ─── Image helpers ──────────────────────────────────────────────────────────────
|
|
31
71
|
|
|
32
72
|
def _parse_data_uri(data_uri: str) -> tuple[str, str]:
|
|
@@ -343,11 +383,11 @@ async def call_cli_provider(
|
|
|
343
383
|
Raises:
|
|
344
384
|
LLMError: on timeout, auth failure, binary not found, or empty output
|
|
345
385
|
"""
|
|
346
|
-
import re
|
|
347
386
|
from core import usage_tracker
|
|
348
387
|
|
|
349
388
|
ANSI_ESCAPE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
|
350
|
-
|
|
389
|
+
# Auth-failure detection uses the module-level _match_auth() helper (specific
|
|
390
|
+
# phrases only). Rate-limit detection keeps its broad substring list below.
|
|
351
391
|
RATE_LIMIT_PATTERNS = ["429", "rate limit", "ratelimit", "quota", "too many requests", "capacity", "resource_exhausted", "ratelimitexceeded"]
|
|
352
392
|
|
|
353
393
|
# Split into base ("cli.<provider>") + variant, preserving dots in the
|
|
@@ -536,7 +576,7 @@ async def call_cli_provider(
|
|
|
536
576
|
f"CLI provider '{cli_model}' rate limit / capacity error.\n"
|
|
537
577
|
f"Details: {stderr_text[:400]}"
|
|
538
578
|
)
|
|
539
|
-
if
|
|
579
|
+
if _match_auth(stderr_text[-600:]):
|
|
540
580
|
raise LLMError(
|
|
541
581
|
f"CLI provider '{cli_model}' authentication failure detected.\n"
|
|
542
582
|
f"Run the CLI in your terminal to re-authenticate.\n"
|
|
@@ -568,8 +608,12 @@ async def call_cli_provider(
|
|
|
568
608
|
extracted = obj.get("result", "")
|
|
569
609
|
if isinstance(extracted, str) and extracted.strip():
|
|
570
610
|
clean = extracted.strip()
|
|
571
|
-
# Propagate JSON-level auth errors before the generic check below
|
|
572
|
-
|
|
611
|
+
# Propagate JSON-level auth errors before the generic check below.
|
|
612
|
+
# Gate on the structured is_error flag and scan only the message
|
|
613
|
+
# fields — not the stringified whole object — so unrelated keys
|
|
614
|
+
# can't trip a false positive.
|
|
615
|
+
err_text = " ".join(str(obj.get(k, "")) for k in ("result", "error", "subtype"))
|
|
616
|
+
if obj.get("is_error") and _match_auth(err_text):
|
|
573
617
|
raise LLMError(
|
|
574
618
|
f"CLI provider '{cli_model}' authentication failure.\n"
|
|
575
619
|
f"Run 'claude' in your terminal to re-authenticate.\n"
|
|
@@ -584,8 +628,13 @@ async def call_cli_provider(
|
|
|
584
628
|
sid_match = re.search(r"session[_\-]?id[:\s]+([a-zA-Z0-9_\-]{8,})", clean, re.IGNORECASE)
|
|
585
629
|
new_session_id = sid_match.group(1) if sid_match else None
|
|
586
630
|
|
|
587
|
-
# Auth failure check (also catches plain-text "Not logged in" from claude)
|
|
588
|
-
|
|
631
|
+
# Auth failure check (also catches plain-text "Not logged in" from claude).
|
|
632
|
+
# `clean` is the model's normal stdout on success, so only treat it as an
|
|
633
|
+
# auth failure when the process ALSO exited non-zero — a successful (exit-0)
|
|
634
|
+
# response is a valid answer, never an auth error, even if it happens to
|
|
635
|
+
# mention words like "authorization". Genuine auth failures exit non-zero
|
|
636
|
+
# (or, for claude JSON, are caught by the is_error block above).
|
|
637
|
+
if process.returncode != 0 and _match_auth(clean):
|
|
589
638
|
raise LLMError(
|
|
590
639
|
f"CLI provider '{cli_model}' is not authenticated.\n"
|
|
591
640
|
f"Run the CLI in your terminal and complete the login flow, then retry.\n"
|
|
@@ -649,7 +698,7 @@ async def call_openai(model, messages, api_key, tools=None, images=None, prompt_
|
|
|
649
698
|
str(messages[i].get("content", "")), images
|
|
650
699
|
)
|
|
651
700
|
break
|
|
652
|
-
OPENAI_TIMEOUT =
|
|
701
|
+
OPENAI_TIMEOUT = LLM_REQUEST_TIMEOUT
|
|
653
702
|
MAX_RETRIES = 5
|
|
654
703
|
BACKOFF_SCHEDULE = [5, 10, 20, 40, 80]
|
|
655
704
|
|
|
@@ -833,7 +882,7 @@ async def call_anthropic(model, messages, system, api_key, tools=None, images=No
|
|
|
833
882
|
break
|
|
834
883
|
import anthropic
|
|
835
884
|
|
|
836
|
-
ANTHROPIC_TIMEOUT =
|
|
885
|
+
ANTHROPIC_TIMEOUT = LLM_REQUEST_TIMEOUT # seconds per attempt (Claude can take >60s for complex prompts)
|
|
837
886
|
MAX_RETRIES = 5
|
|
838
887
|
|
|
839
888
|
# --- Input validation to prevent 400 errors ---
|
|
@@ -1079,7 +1128,7 @@ async def call_gemini(model, messages, system, api_key, tools=None, images=None,
|
|
|
1079
1128
|
from google import genai
|
|
1080
1129
|
from google.genai import types
|
|
1081
1130
|
|
|
1082
|
-
GEMINI_TIMEOUT =
|
|
1131
|
+
GEMINI_TIMEOUT = LLM_REQUEST_TIMEOUT # seconds per attempt
|
|
1083
1132
|
MAX_RETRIES = 5
|
|
1084
1133
|
|
|
1085
1134
|
if _gemini_client is None:
|
|
@@ -1169,7 +1218,7 @@ async def call_grok(model, messages, system, api_key, tools=None, images=None, p
|
|
|
1169
1218
|
Returns:
|
|
1170
1219
|
(response_text, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens)
|
|
1171
1220
|
"""
|
|
1172
|
-
GROK_TIMEOUT =
|
|
1221
|
+
GROK_TIMEOUT = LLM_REQUEST_TIMEOUT
|
|
1173
1222
|
MAX_RETRIES = 5
|
|
1174
1223
|
BACKOFF_SCHEDULE = [5, 10, 20, 40, 80]
|
|
1175
1224
|
|
|
@@ -1265,7 +1314,7 @@ async def call_deepseek(model, messages, system, api_key, tools=None, images=Non
|
|
|
1265
1314
|
# DeepSeek does not support vision — drop images with a warning
|
|
1266
1315
|
if images:
|
|
1267
1316
|
print(f"DEBUG: ⚠️ DeepSeek does not support images. {len(images)} image(s) will be dropped.", flush=True)
|
|
1268
|
-
DEEPSEEK_TIMEOUT =
|
|
1317
|
+
DEEPSEEK_TIMEOUT = LLM_REQUEST_TIMEOUT
|
|
1269
1318
|
MAX_RETRIES = 5
|
|
1270
1319
|
BACKOFF_SCHEDULE = [5, 10, 20, 40, 80]
|
|
1271
1320
|
|
|
@@ -1371,7 +1420,7 @@ async def call_v1_compatible(model, messages, system, base_url, api_key, tools=N
|
|
|
1371
1420
|
if not base_url or not base_url.strip():
|
|
1372
1421
|
raise LLMError("V1-compatible provider: base URL is not configured.")
|
|
1373
1422
|
|
|
1374
|
-
V1_TIMEOUT =
|
|
1423
|
+
V1_TIMEOUT = LLM_REQUEST_TIMEOUT
|
|
1375
1424
|
MAX_RETRIES = 5
|
|
1376
1425
|
BACKOFF_SCHEDULE = [5, 10, 20, 40, 80]
|
|
1377
1426
|
|
|
@@ -39,12 +39,12 @@ from mcp.client.stdio import stdio_client
|
|
|
39
39
|
from mcp.client.streamable_http import streamable_http_client
|
|
40
40
|
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
|
|
41
41
|
|
|
42
|
-
from core.config import DATA_DIR
|
|
42
|
+
from core.config import DATA_DIR, MCP_SESSION_READ_TIMEOUT
|
|
43
43
|
import core.mcp_oauth_state as oauth_state
|
|
44
44
|
|
|
45
45
|
# ── Constants ──────────────────────────────────────────────────────────────────
|
|
46
46
|
|
|
47
|
-
_SESSION_READ_TIMEOUT = timedelta(seconds=
|
|
47
|
+
_SESSION_READ_TIMEOUT = timedelta(seconds=MCP_SESSION_READ_TIMEOUT)
|
|
48
48
|
|
|
49
49
|
MCP_SERVERS_FILE = os.path.join(DATA_DIR, "mcp_servers.json")
|
|
50
50
|
MCP_TOKENS_DIR = os.path.join(DATA_DIR, "mcp_tokens")
|
|
@@ -6,6 +6,8 @@ from typing import Any
|
|
|
6
6
|
from enum import Enum
|
|
7
7
|
from pydantic import BaseModel
|
|
8
8
|
|
|
9
|
+
from core.config import ORCH_GLOBAL_TIMEOUT_MIN, ORCH_HUMAN_TIMEOUT, ORCH_STEP_TIMEOUT
|
|
10
|
+
|
|
9
11
|
|
|
10
12
|
class StepType(str, Enum):
|
|
11
13
|
AGENT = "agent"
|
|
@@ -74,7 +76,7 @@ class StepConfig(BaseModel):
|
|
|
74
76
|
human_prompt: str | None = None
|
|
75
77
|
human_fields: list[dict[str, str]] = [] # [{name, type, label}]
|
|
76
78
|
human_channel_id: str | None = None # messaging channel to notify (optional)
|
|
77
|
-
human_timeout_seconds: int =
|
|
79
|
+
human_timeout_seconds: int = int(ORCH_HUMAN_TIMEOUT) # how long to wait for messaging response
|
|
78
80
|
|
|
79
81
|
# I/O mapping
|
|
80
82
|
input_keys: list[str] = [] # Keys to pull from shared state as context
|
|
@@ -85,7 +87,7 @@ class StepConfig(BaseModel):
|
|
|
85
87
|
|
|
86
88
|
# Per-step guardrails
|
|
87
89
|
max_turns: int = 15
|
|
88
|
-
timeout_seconds: int =
|
|
90
|
+
timeout_seconds: int = int(ORCH_STEP_TIMEOUT)
|
|
89
91
|
allowed_tools: list[str] | None = None # Override agent's tools (narrows only)
|
|
90
92
|
|
|
91
93
|
# Response cache (skipped for AGENT steps — state-dependent behaviour)
|
|
@@ -126,7 +128,7 @@ class Orchestration(BaseModel):
|
|
|
126
128
|
# Global guardrails
|
|
127
129
|
max_total_turns: int = 100
|
|
128
130
|
max_total_cost_usd: float | None = None
|
|
129
|
-
timeout_minutes: int =
|
|
131
|
+
timeout_minutes: int = ORCH_GLOBAL_TIMEOUT_MIN
|
|
130
132
|
|
|
131
133
|
trigger: str = "manual" # "manual" | "scheduled"
|
|
132
134
|
created_at: str | None = None
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Convert an OpenAPI 3.x / Swagger 2.0 spec into Synapse custom-tool dicts.
|
|
3
|
+
|
|
4
|
+
Each spec operation (path + HTTP method) becomes one HTTP custom tool that plugs
|
|
5
|
+
directly into the existing executor in react_engine.py:
|
|
6
|
+
- path parameters stay as ``{param}`` placeholders in the URL — the executor
|
|
7
|
+
substitutes them from the call arguments;
|
|
8
|
+
- query parameters and the JSON request body are described in ``inputSchema`` so
|
|
9
|
+
the LLM supplies them (the executor routes them to query params for GET/DELETE
|
|
10
|
+
or to the JSON body for POST/PUT).
|
|
11
|
+
|
|
12
|
+
Header parameters are intentionally NOT surfaced as arguments: the executor cannot
|
|
13
|
+
route an argument into a request header, so exposing them would mis-send them as
|
|
14
|
+
query/body fields. Supply auth or other static headers via the ``headers`` argument
|
|
15
|
+
(e.g. an ``Authorization`` header entered once at import time).
|
|
16
|
+
"""
|
|
17
|
+
import re
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
_HTTP_METHODS = ("get", "post", "put", "patch", "delete", "head", "options")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _sanitize_name(raw: str) -> str:
|
|
24
|
+
"""Reduce an operationId / path to a snake_case tool name (alnum + underscore)."""
|
|
25
|
+
s = re.sub(r"[^0-9a-zA-Z]+", "_", raw or "").strip("_").lower()
|
|
26
|
+
if not s:
|
|
27
|
+
s = "tool"
|
|
28
|
+
if s[0].isdigit():
|
|
29
|
+
s = f"op_{s}"
|
|
30
|
+
return s
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _resolve_ref(spec: dict, node: Any, _seen: frozenset | None = None) -> Any:
|
|
34
|
+
"""Recursively resolve local ``$ref`` pointers (``#/...``) within a schema node.
|
|
35
|
+
|
|
36
|
+
External refs (URLs / file paths) can't be resolved from the spec alone, so
|
|
37
|
+
they collapse to ``{}``. A cycle guard prevents infinite recursion on
|
|
38
|
+
self-referential schemas.
|
|
39
|
+
"""
|
|
40
|
+
if _seen is None:
|
|
41
|
+
_seen = frozenset()
|
|
42
|
+
if isinstance(node, dict):
|
|
43
|
+
ref = node.get("$ref")
|
|
44
|
+
if isinstance(ref, str):
|
|
45
|
+
if not ref.startswith("#/") or ref in _seen:
|
|
46
|
+
return {}
|
|
47
|
+
target: Any = spec
|
|
48
|
+
for part in ref[2:].split("/"):
|
|
49
|
+
part = part.replace("~1", "/").replace("~0", "~")
|
|
50
|
+
if isinstance(target, dict) and part in target:
|
|
51
|
+
target = target[part]
|
|
52
|
+
else:
|
|
53
|
+
return {}
|
|
54
|
+
return _resolve_ref(spec, target, _seen | {ref})
|
|
55
|
+
return {k: _resolve_ref(spec, v, _seen) for k, v in node.items()}
|
|
56
|
+
if isinstance(node, list):
|
|
57
|
+
return [_resolve_ref(spec, v, _seen) for v in node]
|
|
58
|
+
return node
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _base_url_from_spec(spec: dict, override: str | None) -> str:
|
|
62
|
+
"""Resolve the server base URL: explicit override → OpenAPI 3.x → Swagger 2.0."""
|
|
63
|
+
if override:
|
|
64
|
+
return override.rstrip("/")
|
|
65
|
+
servers = spec.get("servers")
|
|
66
|
+
if isinstance(servers, list) and servers:
|
|
67
|
+
url = (servers[0] or {}).get("url", "")
|
|
68
|
+
if url:
|
|
69
|
+
return url.rstrip("/")
|
|
70
|
+
host = spec.get("host") # Swagger 2.0
|
|
71
|
+
if host:
|
|
72
|
+
schemes = spec.get("schemes") or ["https"]
|
|
73
|
+
scheme = schemes[0] if schemes else "https"
|
|
74
|
+
base_path = spec.get("basePath", "") or ""
|
|
75
|
+
return f"{scheme}://{host}{base_path}".rstrip("/")
|
|
76
|
+
return ""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _param_schema(spec: dict, param: dict) -> dict:
|
|
80
|
+
"""Extract the JSON-Schema for a single parameter (3.x ``schema`` or 2.0 inline)."""
|
|
81
|
+
if "schema" in param:
|
|
82
|
+
sch = _resolve_ref(spec, param["schema"])
|
|
83
|
+
else: # Swagger 2.0 puts type fields directly on the parameter
|
|
84
|
+
sch = {k: param[k] for k in ("type", "format", "items", "enum", "default")
|
|
85
|
+
if k in param}
|
|
86
|
+
if not isinstance(sch, dict) or not sch:
|
|
87
|
+
sch = {"type": "string"}
|
|
88
|
+
if param.get("description") and "description" not in sch:
|
|
89
|
+
sch = {**sch, "description": param["description"]}
|
|
90
|
+
return sch
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _build_input_schema(spec: dict, shared_params: list, op: dict) -> dict:
|
|
94
|
+
"""Build a JSON-Schema object from an operation's path/query params + body."""
|
|
95
|
+
props: dict = {}
|
|
96
|
+
required: list = []
|
|
97
|
+
params = [_resolve_ref(spec, p) for p in (list(shared_params) + list(op.get("parameters", [])))]
|
|
98
|
+
|
|
99
|
+
for p in params:
|
|
100
|
+
if not isinstance(p, dict):
|
|
101
|
+
continue
|
|
102
|
+
loc = p.get("in")
|
|
103
|
+
name = p.get("name")
|
|
104
|
+
if loc not in ("path", "query") or not name:
|
|
105
|
+
continue # header/cookie params can't be routed by the executor — skip
|
|
106
|
+
props[name] = _param_schema(spec, p)
|
|
107
|
+
if (p.get("required") or loc == "path") and name not in required:
|
|
108
|
+
required.append(name)
|
|
109
|
+
|
|
110
|
+
# Request body: OpenAPI 3.x (requestBody.content) then Swagger 2.0 (in: body).
|
|
111
|
+
def _merge_body(body_schema: dict, body_required: bool):
|
|
112
|
+
body_schema = _resolve_ref(spec, body_schema)
|
|
113
|
+
if not isinstance(body_schema, dict) or not body_schema:
|
|
114
|
+
return
|
|
115
|
+
if "properties" in body_schema:
|
|
116
|
+
for k, v in (body_schema.get("properties") or {}).items():
|
|
117
|
+
props[k] = v
|
|
118
|
+
for r in body_schema.get("required", []) or []:
|
|
119
|
+
if r not in required:
|
|
120
|
+
required.append(r)
|
|
121
|
+
else: # non-object body (array/scalar) — expose under a single 'body' field
|
|
122
|
+
props["body"] = body_schema
|
|
123
|
+
if body_required and "body" not in required:
|
|
124
|
+
required.append("body")
|
|
125
|
+
|
|
126
|
+
request_body = _resolve_ref(spec, op.get("requestBody", {})) if op.get("requestBody") else {}
|
|
127
|
+
if request_body:
|
|
128
|
+
content = request_body.get("content", {}) or {}
|
|
129
|
+
media = content.get("application/json") or (next(iter(content.values()), {}) if content else {})
|
|
130
|
+
_merge_body(media.get("schema", {}), bool(request_body.get("required")))
|
|
131
|
+
|
|
132
|
+
for p in params: # Swagger 2.0 body parameter
|
|
133
|
+
if isinstance(p, dict) and p.get("in") == "body":
|
|
134
|
+
_merge_body(p.get("schema", {}), bool(p.get("required")))
|
|
135
|
+
|
|
136
|
+
schema: dict = {"type": "object", "properties": props}
|
|
137
|
+
if required:
|
|
138
|
+
schema["required"] = required
|
|
139
|
+
return schema
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def parse_openapi_spec(
|
|
143
|
+
spec: dict,
|
|
144
|
+
*,
|
|
145
|
+
base_url: str | None = None,
|
|
146
|
+
headers: dict | None = None,
|
|
147
|
+
name_prefix: str = "",
|
|
148
|
+
) -> list[dict]:
|
|
149
|
+
"""Convert an OpenAPI 3.x / Swagger 2.0 spec into a list of custom-tool dicts.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
spec: the parsed spec (a dict, from JSON or YAML).
|
|
153
|
+
base_url: override for the server base URL (wins over ``servers``/``host``).
|
|
154
|
+
headers: static headers applied to every generated tool (e.g. auth).
|
|
155
|
+
name_prefix: prepended (sanitized) to every generated tool name.
|
|
156
|
+
|
|
157
|
+
Returns tool dicts in the same shape stored by ``POST /api/tools/custom``.
|
|
158
|
+
Raises ``ValueError`` if the document has no usable operations.
|
|
159
|
+
"""
|
|
160
|
+
if not isinstance(spec, dict):
|
|
161
|
+
raise ValueError("Spec must be a JSON/YAML object.")
|
|
162
|
+
paths = spec.get("paths")
|
|
163
|
+
if not isinstance(paths, dict) or not paths:
|
|
164
|
+
raise ValueError("Spec has no 'paths' — not a valid OpenAPI/Swagger document.")
|
|
165
|
+
|
|
166
|
+
base = _base_url_from_spec(spec, base_url)
|
|
167
|
+
headers = dict(headers or {})
|
|
168
|
+
prefix = f"{_sanitize_name(name_prefix)}_" if name_prefix else ""
|
|
169
|
+
|
|
170
|
+
tools: list[dict] = []
|
|
171
|
+
used_names: set = set()
|
|
172
|
+
|
|
173
|
+
for path, path_item in paths.items():
|
|
174
|
+
if isinstance(path_item, dict) and "$ref" in path_item:
|
|
175
|
+
path_item = _resolve_ref(spec, path_item)
|
|
176
|
+
if not isinstance(path_item, dict):
|
|
177
|
+
continue
|
|
178
|
+
shared_params = path_item.get("parameters", []) or []
|
|
179
|
+
|
|
180
|
+
for method, op in path_item.items():
|
|
181
|
+
if method.lower() not in _HTTP_METHODS or not isinstance(op, dict):
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
op_id = op.get("operationId")
|
|
185
|
+
name = prefix + _sanitize_name(op_id if op_id else f"{method}_{path}")
|
|
186
|
+
base_name, i = name, 2
|
|
187
|
+
while name in used_names:
|
|
188
|
+
name, i = f"{base_name}_{i}", i + 1
|
|
189
|
+
used_names.add(name)
|
|
190
|
+
|
|
191
|
+
summary = (op.get("summary") or "").strip()
|
|
192
|
+
description = (summary or op.get("description") or "").strip() or f"{method.upper()} {path}"
|
|
193
|
+
|
|
194
|
+
tools.append({
|
|
195
|
+
"name": name,
|
|
196
|
+
"generalName": (summary or name)[:80],
|
|
197
|
+
"description": description,
|
|
198
|
+
"tool_type": "http",
|
|
199
|
+
"method": method.upper(),
|
|
200
|
+
"url": f"{base}{path}",
|
|
201
|
+
"headers": dict(headers),
|
|
202
|
+
"inputSchema": _build_input_schema(spec, shared_params, op),
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
if not tools:
|
|
206
|
+
raise ValueError("No operations found in the spec.")
|
|
207
|
+
return tools
|
|
@@ -8,6 +8,7 @@ from typing import AsyncGenerator, Awaitable, Callable
|
|
|
8
8
|
|
|
9
9
|
import anyio
|
|
10
10
|
|
|
11
|
+
from core.config import ORCH_GLOBAL_TIMEOUT_MIN, ORCH_STEP_TIMEOUT
|
|
11
12
|
from core.models_orchestration import Orchestration, OrchestrationRun, StepConfig, StepType
|
|
12
13
|
from .state import SharedState
|
|
13
14
|
from .steps import STEP_EXECUTORS
|
|
@@ -100,7 +101,7 @@ class OrchestrationEngine:
|
|
|
100
101
|
total_turns = len(run.step_history)
|
|
101
102
|
logger = getattr(self, "logger", None)
|
|
102
103
|
loop_start_time = time.time()
|
|
103
|
-
timeout_seconds = (self.orch.timeout_minutes or
|
|
104
|
+
timeout_seconds = (self.orch.timeout_minutes or ORCH_GLOBAL_TIMEOUT_MIN) * 60
|
|
104
105
|
|
|
105
106
|
while run.current_step_id and run.status == "running":
|
|
106
107
|
# Checkpoint between steps — ensures MCP background tasks
|
|
@@ -154,7 +155,7 @@ class OrchestrationEngine:
|
|
|
154
155
|
break
|
|
155
156
|
|
|
156
157
|
step_start_time = time.time()
|
|
157
|
-
step_timeout = step.timeout_seconds or
|
|
158
|
+
step_timeout = step.timeout_seconds or ORCH_STEP_TIMEOUT # default 5 min
|
|
158
159
|
|
|
159
160
|
# Build and store TransitionContext so executors can access it
|
|
160
161
|
from .context import build_transition_context
|
|
@@ -14,6 +14,7 @@ from typing import AsyncGenerator, TYPE_CHECKING
|
|
|
14
14
|
|
|
15
15
|
import anyio
|
|
16
16
|
|
|
17
|
+
from core.config import HTTP_TOOL_TIMEOUT, MCP_TOOL_CALL_TIMEOUT, ORCH_STEP_TIMEOUT
|
|
17
18
|
from core.models_orchestration import StepConfig, StepType, OrchestrationRun
|
|
18
19
|
|
|
19
20
|
if TYPE_CHECKING:
|
|
@@ -451,7 +452,7 @@ class ToolStepExecutor:
|
|
|
451
452
|
session = server_module.agent_sessions.get(agent_name)
|
|
452
453
|
if session:
|
|
453
454
|
result = await session.call_tool(
|
|
454
|
-
actual_tool_name, tool_args, read_timeout_seconds=timedelta(seconds=
|
|
455
|
+
actual_tool_name, tool_args, read_timeout_seconds=timedelta(seconds=MCP_TOOL_CALL_TIMEOUT)
|
|
455
456
|
)
|
|
456
457
|
return result.content[0].text if result.content else ""
|
|
457
458
|
# Custom tools — Python or HTTP
|
|
@@ -597,10 +598,10 @@ class ToolStepExecutor:
|
|
|
597
598
|
remaining_args = {k: v for k, v in tool_args.items() if k not in consumed_keys}
|
|
598
599
|
params = {k: (json.dumps(v) if isinstance(v, (dict, list)) else str(v))
|
|
599
600
|
for k, v in remaining_args.items()}
|
|
600
|
-
resp = await client.request(method, url, params=params, headers=headers, timeout=
|
|
601
|
+
resp = await client.request(method, url, params=params, headers=headers, timeout=HTTP_TOOL_TIMEOUT)
|
|
601
602
|
else:
|
|
602
603
|
# POST / PUT – all args in body even if some were used in the URL
|
|
603
|
-
resp = await client.request(method, url, json=tool_args, headers=headers, timeout=
|
|
604
|
+
resp = await client.request(method, url, json=tool_args, headers=headers, timeout=HTTP_TOOL_TIMEOUT)
|
|
604
605
|
|
|
605
606
|
try:
|
|
606
607
|
return json.dumps(resp.json())
|
|
@@ -827,7 +828,7 @@ class ParallelStepExecutor:
|
|
|
827
828
|
continue
|
|
828
829
|
|
|
829
830
|
step_start = time.time()
|
|
830
|
-
sub_timeout = sub_step.timeout_seconds or
|
|
831
|
+
sub_timeout = sub_step.timeout_seconds or ORCH_STEP_TIMEOUT
|
|
831
832
|
print(f"DEBUG PARALLEL: ▶ sub-step '{sub_step.name}' ({sub_step.id}) timeout={sub_timeout}s", flush=True)
|
|
832
833
|
yield {"type": "step_start", "orch_step_id": sub_step.id,
|
|
833
834
|
"step_name": sub_step.name, "step_type": sub_step.type.value}
|
|
@@ -942,7 +943,7 @@ class LoopStepExecutor:
|
|
|
942
943
|
continue
|
|
943
944
|
|
|
944
945
|
step_start = time.time()
|
|
945
|
-
sub_timeout = sub_step.timeout_seconds or
|
|
946
|
+
sub_timeout = sub_step.timeout_seconds or ORCH_STEP_TIMEOUT
|
|
946
947
|
yield {"type": "step_start", "orch_step_id": sub_step.id,
|
|
947
948
|
"step_name": sub_step.name, "step_type": sub_step.type.value}
|
|
948
949
|
|
|
@@ -10,7 +10,7 @@ import time
|
|
|
10
10
|
|
|
11
11
|
import httpx
|
|
12
12
|
|
|
13
|
-
from core.config import load_settings
|
|
13
|
+
from core.config import load_settings, HTTP_TOOL_TIMEOUT, MCP_TOOL_CALL_TIMEOUT
|
|
14
14
|
from core.vault import maybe_vault, expand_vault_mentions
|
|
15
15
|
from core.compaction import maybe_compact
|
|
16
16
|
from core.session import (
|
|
@@ -1330,10 +1330,10 @@ async def run_agent_step(
|
|
|
1330
1330
|
remaining_args = {k: v for k, v in tool_args.items() if k not in consumed_keys}
|
|
1331
1331
|
params = {k: (json.dumps(v) if isinstance(v, (dict, list)) else str(v))
|
|
1332
1332
|
for k, v in remaining_args.items()}
|
|
1333
|
-
resp = await client.request(method, url, params=params, headers=headers, timeout=
|
|
1333
|
+
resp = await client.request(method, url, params=params, headers=headers, timeout=HTTP_TOOL_TIMEOUT)
|
|
1334
1334
|
else:
|
|
1335
1335
|
# POST / PUT – all args in body even if some were used in the URL
|
|
1336
|
-
resp = await client.request(method, url, json=tool_args, headers=headers, timeout=
|
|
1336
|
+
resp = await client.request(method, url, json=tool_args, headers=headers, timeout=HTTP_TOOL_TIMEOUT)
|
|
1337
1337
|
|
|
1338
1338
|
json_resp = None
|
|
1339
1339
|
try:
|
|
@@ -1416,7 +1416,7 @@ async def run_agent_step(
|
|
|
1416
1416
|
|
|
1417
1417
|
print(f"DEBUG MCP: ✓ ping OK [{round(time.time()-_mcp_t0,2)}s] — call_tool starting", flush=True)
|
|
1418
1418
|
|
|
1419
|
-
_timeout = timedelta(seconds=
|
|
1419
|
+
_timeout = timedelta(seconds=MCP_TOOL_CALL_TIMEOUT)
|
|
1420
1420
|
|
|
1421
1421
|
# Checkpoint again right before the actual call_tool —
|
|
1422
1422
|
# gives background transport tasks one more scheduling
|
|
@@ -15,6 +15,7 @@ from pydantic import BaseModel
|
|
|
15
15
|
from core.models import AddMCPServerRequest
|
|
16
16
|
from core.config import DATA_DIR
|
|
17
17
|
from core.json_store import JsonStore
|
|
18
|
+
from core.openapi_import import parse_openapi_spec
|
|
18
19
|
import core.mcp_oauth_state as oauth_state
|
|
19
20
|
|
|
20
21
|
router = APIRouter()
|
|
@@ -105,17 +106,89 @@ async def get_available_tools():
|
|
|
105
106
|
return {"tools": all_tools}
|
|
106
107
|
|
|
107
108
|
|
|
108
|
-
|
|
109
|
-
|
|
109
|
+
def _upsert_custom_tools(new_tools: list[dict]) -> None:
|
|
110
|
+
"""Upsert tools by name into the custom-tools store, preserving existing order."""
|
|
110
111
|
tools = load_custom_tools()
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
112
|
+
for nt in new_tools:
|
|
113
|
+
if any(t['name'] == nt['name'] for t in tools):
|
|
114
|
+
tools = [t if t['name'] != nt['name'] else nt for t in tools]
|
|
115
|
+
else:
|
|
116
|
+
tools.append(nt)
|
|
115
117
|
save_custom_tools(tools)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@router.post("/api/tools/custom")
|
|
121
|
+
async def create_custom_tool(tool: dict):
|
|
122
|
+
_upsert_custom_tools([tool])
|
|
116
123
|
return {"status": "success", "tool": tool}
|
|
117
124
|
|
|
118
125
|
|
|
126
|
+
@router.post("/api/tools/custom/bulk")
|
|
127
|
+
async def create_custom_tools_bulk(payload: dict):
|
|
128
|
+
"""Upsert many tools at once. Body: {"tools": [ {tool}, ... ]}."""
|
|
129
|
+
tools = payload.get("tools")
|
|
130
|
+
if not isinstance(tools, list) or not tools:
|
|
131
|
+
raise HTTPException(status_code=400, detail="Body must include a non-empty 'tools' list.")
|
|
132
|
+
for t in tools:
|
|
133
|
+
if not isinstance(t, dict) or not t.get("name"):
|
|
134
|
+
raise HTTPException(status_code=400, detail="Each tool must be an object with a 'name'.")
|
|
135
|
+
_upsert_custom_tools(tools)
|
|
136
|
+
return {"status": "success", "imported": len(tools)}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class OpenAPIImportRequest(BaseModel):
|
|
140
|
+
spec: str # raw JSON or YAML text of the spec
|
|
141
|
+
base_url: Optional[str] = None # override the server base URL
|
|
142
|
+
headers: Optional[dict] = None # static headers applied to every tool (e.g. auth)
|
|
143
|
+
name_prefix: str = "" # prepended to every generated tool name
|
|
144
|
+
save: bool = False # False → preview only; True → upsert all generated tools
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _parse_spec_text(text: str) -> dict:
|
|
148
|
+
"""Parse spec text as JSON, falling back to YAML (if PyYAML is available)."""
|
|
149
|
+
text = (text or "").strip()
|
|
150
|
+
if not text:
|
|
151
|
+
raise HTTPException(status_code=400, detail="Empty spec.")
|
|
152
|
+
try:
|
|
153
|
+
return json.loads(text)
|
|
154
|
+
except json.JSONDecodeError:
|
|
155
|
+
pass
|
|
156
|
+
try:
|
|
157
|
+
import yaml # optional dependency; specs are often YAML
|
|
158
|
+
except ImportError:
|
|
159
|
+
raise HTTPException(
|
|
160
|
+
status_code=400,
|
|
161
|
+
detail="Spec is not valid JSON and PyYAML is not installed to parse YAML. Paste JSON instead.",
|
|
162
|
+
)
|
|
163
|
+
try:
|
|
164
|
+
data = yaml.safe_load(text)
|
|
165
|
+
except Exception as e:
|
|
166
|
+
raise HTTPException(status_code=400, detail=f"Could not parse spec as JSON or YAML: {e}")
|
|
167
|
+
if not isinstance(data, dict):
|
|
168
|
+
raise HTTPException(status_code=400, detail="Spec did not parse to an object.")
|
|
169
|
+
return data
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@router.post("/api/tools/import/openapi")
|
|
173
|
+
async def import_openapi(req: OpenAPIImportRequest):
|
|
174
|
+
"""Convert an OpenAPI 3.x / Swagger 2.0 spec into HTTP custom tools.
|
|
175
|
+
|
|
176
|
+
With save=False (default) returns the generated tools as a preview so the user
|
|
177
|
+
can pick a subset; with save=True upserts all of them.
|
|
178
|
+
"""
|
|
179
|
+
spec = _parse_spec_text(req.spec)
|
|
180
|
+
try:
|
|
181
|
+
generated = parse_openapi_spec(
|
|
182
|
+
spec, base_url=req.base_url, headers=req.headers, name_prefix=req.name_prefix,
|
|
183
|
+
)
|
|
184
|
+
except ValueError as e:
|
|
185
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
186
|
+
|
|
187
|
+
if req.save:
|
|
188
|
+
_upsert_custom_tools(generated)
|
|
189
|
+
return {"tools": generated, "count": len(generated), "saved": req.save}
|
|
190
|
+
|
|
191
|
+
|
|
119
192
|
@router.delete("/api/tools/custom/{tool_name}")
|
|
120
193
|
async def delete_custom_tool(tool_name: str):
|
|
121
194
|
tools = load_custom_tools()
|