synapse-orch-ai 1.7.9 → 1.8.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/backend/core/config.py +30 -0
- package/backend/core/llm_providers.py +8 -6
- 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/0_1lnd0_nmhao.js +432 -0
- package/frontend-build/.next/static/chunks/166_fx4i~0.31.css +1 -0
- package/package.json +2 -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/{CtKxA7uWQn6TQSXzzBH0h → jOk_MqSfSl6c7iczA9pYW}/_buildManifest.js +0 -0
- /package/frontend-build/.next/static/{CtKxA7uWQn6TQSXzzBH0h → jOk_MqSfSl6c7iczA9pYW}/_clientMiddlewareManifest.js +0 -0
- /package/frontend-build/.next/static/{CtKxA7uWQn6TQSXzzBH0h → jOk_MqSfSl6c7iczA9pYW}/_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",
|
|
@@ -22,6 +22,8 @@ import botocore.exceptions
|
|
|
22
22
|
from botocore import UNSIGNED as _BEDROCK_UNSIGNED
|
|
23
23
|
from botocore.config import Config
|
|
24
24
|
|
|
25
|
+
from core.config import LLM_REQUEST_TIMEOUT
|
|
26
|
+
|
|
25
27
|
# Lock to guard the process-level AWS_BEARER_TOKEN_BEDROCK env var.
|
|
26
28
|
# Multiple threads (via asyncio.to_thread) could otherwise race on set/clear.
|
|
27
29
|
_aws_env_lock = threading.Lock()
|
|
@@ -649,7 +651,7 @@ async def call_openai(model, messages, api_key, tools=None, images=None, prompt_
|
|
|
649
651
|
str(messages[i].get("content", "")), images
|
|
650
652
|
)
|
|
651
653
|
break
|
|
652
|
-
OPENAI_TIMEOUT =
|
|
654
|
+
OPENAI_TIMEOUT = LLM_REQUEST_TIMEOUT
|
|
653
655
|
MAX_RETRIES = 5
|
|
654
656
|
BACKOFF_SCHEDULE = [5, 10, 20, 40, 80]
|
|
655
657
|
|
|
@@ -833,7 +835,7 @@ async def call_anthropic(model, messages, system, api_key, tools=None, images=No
|
|
|
833
835
|
break
|
|
834
836
|
import anthropic
|
|
835
837
|
|
|
836
|
-
ANTHROPIC_TIMEOUT =
|
|
838
|
+
ANTHROPIC_TIMEOUT = LLM_REQUEST_TIMEOUT # seconds per attempt (Claude can take >60s for complex prompts)
|
|
837
839
|
MAX_RETRIES = 5
|
|
838
840
|
|
|
839
841
|
# --- Input validation to prevent 400 errors ---
|
|
@@ -1079,7 +1081,7 @@ async def call_gemini(model, messages, system, api_key, tools=None, images=None,
|
|
|
1079
1081
|
from google import genai
|
|
1080
1082
|
from google.genai import types
|
|
1081
1083
|
|
|
1082
|
-
GEMINI_TIMEOUT =
|
|
1084
|
+
GEMINI_TIMEOUT = LLM_REQUEST_TIMEOUT # seconds per attempt
|
|
1083
1085
|
MAX_RETRIES = 5
|
|
1084
1086
|
|
|
1085
1087
|
if _gemini_client is None:
|
|
@@ -1169,7 +1171,7 @@ async def call_grok(model, messages, system, api_key, tools=None, images=None, p
|
|
|
1169
1171
|
Returns:
|
|
1170
1172
|
(response_text, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens)
|
|
1171
1173
|
"""
|
|
1172
|
-
GROK_TIMEOUT =
|
|
1174
|
+
GROK_TIMEOUT = LLM_REQUEST_TIMEOUT
|
|
1173
1175
|
MAX_RETRIES = 5
|
|
1174
1176
|
BACKOFF_SCHEDULE = [5, 10, 20, 40, 80]
|
|
1175
1177
|
|
|
@@ -1265,7 +1267,7 @@ async def call_deepseek(model, messages, system, api_key, tools=None, images=Non
|
|
|
1265
1267
|
# DeepSeek does not support vision — drop images with a warning
|
|
1266
1268
|
if images:
|
|
1267
1269
|
print(f"DEBUG: ⚠️ DeepSeek does not support images. {len(images)} image(s) will be dropped.", flush=True)
|
|
1268
|
-
DEEPSEEK_TIMEOUT =
|
|
1270
|
+
DEEPSEEK_TIMEOUT = LLM_REQUEST_TIMEOUT
|
|
1269
1271
|
MAX_RETRIES = 5
|
|
1270
1272
|
BACKOFF_SCHEDULE = [5, 10, 20, 40, 80]
|
|
1271
1273
|
|
|
@@ -1371,7 +1373,7 @@ async def call_v1_compatible(model, messages, system, base_url, api_key, tools=N
|
|
|
1371
1373
|
if not base_url or not base_url.strip():
|
|
1372
1374
|
raise LLMError("V1-compatible provider: base URL is not configured.")
|
|
1373
1375
|
|
|
1374
|
-
V1_TIMEOUT =
|
|
1376
|
+
V1_TIMEOUT = LLM_REQUEST_TIMEOUT
|
|
1375
1377
|
MAX_RETRIES = 5
|
|
1376
1378
|
BACKOFF_SCHEDULE = [5, 10, 20, 40, 80]
|
|
1377
1379
|
|
|
@@ -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()
|
|
@@ -40,13 +40,14 @@ class WorkerServerModule:
|
|
|
40
40
|
from mcp import ClientSession, StdioServerParameters
|
|
41
41
|
from mcp.client.stdio import stdio_client
|
|
42
42
|
from datetime import timedelta
|
|
43
|
+
from core.config import MCP_SESSION_READ_TIMEOUT
|
|
43
44
|
|
|
44
45
|
instance = cls()
|
|
45
46
|
disabled = set(disabled_mcp_names or [])
|
|
46
47
|
exit_stack = AsyncExitStack()
|
|
47
48
|
instance._exit_stack = exit_stack
|
|
48
49
|
|
|
49
|
-
_SESSION_READ_TIMEOUT = timedelta(seconds=
|
|
50
|
+
_SESSION_READ_TIMEOUT = timedelta(seconds=MCP_SESSION_READ_TIMEOUT)
|
|
50
51
|
|
|
51
52
|
# --- Python-native MCP tools (always available on workers) ---
|
|
52
53
|
TOOLS_DIR = Path(__file__).resolve().parent.parent.parent / "tools"
|
package/backend/core/server.py
CHANGED
|
@@ -18,7 +18,8 @@ from mcp.client.stdio import stdio_client
|
|
|
18
18
|
|
|
19
19
|
# Default timeout for all MCP session requests — prevents any call from hanging
|
|
20
20
|
# forever. Per-request read_timeout_seconds overrides this when supplied.
|
|
21
|
-
|
|
21
|
+
from core.config import MCP_SESSION_READ_TIMEOUT
|
|
22
|
+
_SESSION_READ_TIMEOUT = timedelta(seconds=MCP_SESSION_READ_TIMEOUT)
|
|
22
23
|
|
|
23
24
|
try:
|
|
24
25
|
from core.memory import MemoryStore
|
|
@@ -204,7 +205,7 @@ def _build_native_mcp_servers() -> list[dict]:
|
|
|
204
205
|
servers.append({
|
|
205
206
|
"name": "Browser Automation",
|
|
206
207
|
"command": _NPX_CMD,
|
|
207
|
-
"args": ["-y", "@playwright/mcp
|
|
208
|
+
"args": ["-y", "@playwright/mcp", "--browser", "chrome", "--output-dir", "data/vault/playwright"],
|
|
208
209
|
"env": env_dict,
|
|
209
210
|
})
|
|
210
211
|
|
package/backend/core/tools.py
CHANGED
|
@@ -9,7 +9,7 @@ import zoneinfo
|
|
|
9
9
|
|
|
10
10
|
import anyio
|
|
11
11
|
|
|
12
|
-
from core.config import load_settings
|
|
12
|
+
from core.config import load_settings, MCP_LIST_TOOLS_TIMEOUT
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
# Tools auto-injected per agent type. These bypass the agent's tools[] array.
|
|
@@ -118,10 +118,11 @@ async def aggregate_all_tools(agent_sessions, active_agent, custom_tools_list):
|
|
|
118
118
|
print(f"DEBUG: 📦 Using cached tools for '{session_name}' ({len(session_tools)} tools)", flush=True)
|
|
119
119
|
else:
|
|
120
120
|
try:
|
|
121
|
-
# Bounded list_tools() —
|
|
121
|
+
# Bounded list_tools() — timeout (default 15s, override via
|
|
122
|
+
# SYNAPSE_MCP_LIST_TOOLS_TIMEOUT) prevents a single flaky
|
|
122
123
|
# session from blocking all subsequent tool aggregation.
|
|
123
124
|
print(f"DEBUG: 🔄 Fetching tools for '{session_name}'...", flush=True)
|
|
124
|
-
with anyio.fail_after(
|
|
125
|
+
with anyio.fail_after(MCP_LIST_TOOLS_TIMEOUT):
|
|
125
126
|
result = await session.list_tools()
|
|
126
127
|
session_tools = result.tools
|
|
127
128
|
_session_tools_cache[session_name] = session_tools
|
package/backend/requirements.txt
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
jOk_MqSfSl6c7iczA9pYW
|
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
"static/chunks/03~yq9q893hmn.js"
|
|
8
8
|
],
|
|
9
9
|
"lowPriorityFiles": [
|
|
10
|
-
"static/
|
|
11
|
-
"static/
|
|
12
|
-
"static/
|
|
10
|
+
"static/jOk_MqSfSl6c7iczA9pYW/_buildManifest.js",
|
|
11
|
+
"static/jOk_MqSfSl6c7iczA9pYW/_ssgManifest.js",
|
|
12
|
+
"static/jOk_MqSfSl6c7iczA9pYW/_clientMiddlewareManifest.js"
|
|
13
13
|
],
|
|
14
14
|
"rootMainFiles": [
|
|
15
15
|
"static/chunks/0qmh881w772ef.js",
|