synapse-orch-ai 1.8.0 → 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.
Files changed (64) hide show
  1. package/backend/core/config.py +30 -0
  2. package/backend/core/llm_providers.py +8 -6
  3. package/backend/core/mcp_client.py +2 -2
  4. package/backend/core/models_orchestration.py +5 -3
  5. package/backend/core/openapi_import.py +207 -0
  6. package/backend/core/orchestration/engine.py +3 -2
  7. package/backend/core/orchestration/steps.py +6 -5
  8. package/backend/core/react_engine.py +4 -4
  9. package/backend/core/routes/tools.py +79 -6
  10. package/backend/core/scale/worker_server_module.py +2 -1
  11. package/backend/core/server.py +3 -2
  12. package/backend/core/tools.py +4 -3
  13. package/backend/requirements.txt +2 -1
  14. package/frontend-build/.next/BUILD_ID +1 -1
  15. package/frontend-build/.next/build-manifest.json +3 -3
  16. package/frontend-build/.next/prerender-manifest.json +3 -3
  17. package/frontend-build/.next/server/app/_global-error.html +1 -1
  18. package/frontend-build/.next/server/app/_global-error.rsc +1 -1
  19. package/frontend-build/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  20. package/frontend-build/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  21. package/frontend-build/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  22. package/frontend-build/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  23. package/frontend-build/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  24. package/frontend-build/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  25. package/frontend-build/.next/server/app/_not-found.html +1 -1
  26. package/frontend-build/.next/server/app/_not-found.rsc +2 -2
  27. package/frontend-build/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  28. package/frontend-build/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  29. package/frontend-build/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  30. package/frontend-build/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  31. package/frontend-build/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  32. package/frontend-build/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  33. package/frontend-build/.next/server/app/index.html +1 -1
  34. package/frontend-build/.next/server/app/index.rsc +2 -2
  35. package/frontend-build/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  36. package/frontend-build/.next/server/app/index.segments/_full.segment.rsc +2 -2
  37. package/frontend-build/.next/server/app/index.segments/_head.segment.rsc +1 -1
  38. package/frontend-build/.next/server/app/index.segments/_index.segment.rsc +2 -2
  39. package/frontend-build/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  40. package/frontend-build/.next/server/app/login/page_client-reference-manifest.js +1 -1
  41. package/frontend-build/.next/server/app/login.html +1 -1
  42. package/frontend-build/.next/server/app/login.rsc +2 -2
  43. package/frontend-build/.next/server/app/login.segments/_full.segment.rsc +2 -2
  44. package/frontend-build/.next/server/app/login.segments/_head.segment.rsc +1 -1
  45. package/frontend-build/.next/server/app/login.segments/_index.segment.rsc +2 -2
  46. package/frontend-build/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  47. package/frontend-build/.next/server/app/login.segments/login/__PAGE__.segment.rsc +1 -1
  48. package/frontend-build/.next/server/app/login.segments/login.segment.rsc +1 -1
  49. package/frontend-build/.next/server/app/page_client-reference-manifest.js +1 -1
  50. package/frontend-build/.next/server/app/settings/[tab]/page_client-reference-manifest.js +1 -1
  51. package/frontend-build/.next/server/chunks/ssr/_0b~n.nn._.js +42 -42
  52. package/frontend-build/.next/server/middleware-build-manifest.js +3 -3
  53. package/frontend-build/.next/server/pages/404.html +1 -1
  54. package/frontend-build/.next/server/pages/500.html +1 -1
  55. package/frontend-build/.next/server/server-reference-manifest.js +1 -1
  56. package/frontend-build/.next/server/server-reference-manifest.json +1 -1
  57. package/frontend-build/.next/static/chunks/0_1lnd0_nmhao.js +432 -0
  58. package/frontend-build/.next/static/chunks/166_fx4i~0.31.css +1 -0
  59. package/package.json +1 -1
  60. package/frontend-build/.next/static/chunks/01jwck3c_vyp2.css +0 -1
  61. package/frontend-build/.next/static/chunks/0lja9zdxjh.4l.js +0 -432
  62. /package/frontend-build/.next/static/{o8_DkJ-oTdtgwtT93jEXM → jOk_MqSfSl6c7iczA9pYW}/_buildManifest.js +0 -0
  63. /package/frontend-build/.next/static/{o8_DkJ-oTdtgwtT93jEXM → jOk_MqSfSl6c7iczA9pYW}/_clientMiddlewareManifest.js +0 -0
  64. /package/frontend-build/.next/static/{o8_DkJ-oTdtgwtT93jEXM → jOk_MqSfSl6c7iczA9pYW}/_ssgManifest.js +0 -0
@@ -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 = 180.0
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 = 180.0 # seconds per attempt (Claude can take >60s for complex prompts)
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 = 180.0 # seconds per attempt
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 = 180.0
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 = 180.0
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 = 180.0
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=60)
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 = 3600 # how long to wait for messaging response
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 = 300
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 = 30
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 30) * 60
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 300 # default 5 min
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=30)
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=30.0)
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=30.0)
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 300
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 300
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=30.0)
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=30.0)
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=60)
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
- @router.post("/api/tools/custom")
109
- async def create_custom_tool(tool: dict):
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
- if any(t['name'] == tool['name'] for t in tools):
112
- tools = [t if t['name'] != tool['name'] else tool for t in tools]
113
- else:
114
- tools.append(tool)
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=60)
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"
@@ -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
- _SESSION_READ_TIMEOUT = timedelta(seconds=60)
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@latest", "--browser", "chrome", "--output-dir", "data/vault/playwright"],
208
+ "args": ["-y", "@playwright/mcp", "--browser", "chrome", "--output-dir", "data/vault/playwright"],
208
209
  "env": env_dict,
209
210
  })
210
211
 
@@ -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() — 15s timeout prevents a single flaky
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(15):
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
@@ -26,4 +26,5 @@ croniter
26
26
  crawl4ai
27
27
  arq>=0.26
28
28
  redis[hiredis]>=5.0
29
- async-timeout>=4.0
29
+ async-timeout>=4.0
30
+ pyyaml>=6.0
@@ -1 +1 @@
1
- o8_DkJ-oTdtgwtT93jEXM
1
+ jOk_MqSfSl6c7iczA9pYW
@@ -7,9 +7,9 @@
7
7
  "static/chunks/03~yq9q893hmn.js"
8
8
  ],
9
9
  "lowPriorityFiles": [
10
- "static/o8_DkJ-oTdtgwtT93jEXM/_buildManifest.js",
11
- "static/o8_DkJ-oTdtgwtT93jEXM/_ssgManifest.js",
12
- "static/o8_DkJ-oTdtgwtT93jEXM/_clientMiddlewareManifest.js"
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",