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.
Files changed (64) hide show
  1. package/backend/core/config.py +30 -0
  2. package/backend/core/llm_providers.py +62 -13
  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/166_fx4i~0.31.css +1 -0
  58. package/frontend-build/.next/static/chunks/16j4jub4qpyf1.js +432 -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 → 6T9aJqheS49PfQ_4MbqiJ}/_buildManifest.js +0 -0
  63. /package/frontend-build/.next/static/{o8_DkJ-oTdtgwtT93jEXM → 6T9aJqheS49PfQ_4MbqiJ}/_clientMiddlewareManifest.js +0 -0
  64. /package/frontend-build/.next/static/{o8_DkJ-oTdtgwtT93jEXM → 6T9aJqheS49PfQ_4MbqiJ}/_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",
@@ -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
- AUTH_PATTERNS = ["login", "authenticate", "auth", "expired", "sign in", "api key", "unauthorized"]
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 any(p in lower_err for p in AUTH_PATTERNS):
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
- if obj.get("is_error") and any(p in str(obj).lower() for p in AUTH_PATTERNS):
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
- if any(p in clean.lower() for p in AUTH_PATTERNS):
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 = 180.0
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 = 180.0 # seconds per attempt (Claude can take >60s for complex prompts)
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 = 180.0 # seconds per attempt
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 = 180.0
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 = 180.0
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 = 180.0
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=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()