synapse-framework 0.2.4__py3-none-any.whl

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 (54) hide show
  1. synapse/__init__.py +207 -0
  2. synapse/app.py +942 -0
  3. synapse/cli.py +88 -0
  4. synapse/config.py +103 -0
  5. synapse/connectors/__init__.py +29 -0
  6. synapse/connectors/base.py +222 -0
  7. synapse/connectors/factory.py +202 -0
  8. synapse/connectors/gmail.py +292 -0
  9. synapse/connectors/mcp_wrapper.py +153 -0
  10. synapse/connectors/notion.py +161 -0
  11. synapse/connectors/photos.py +141 -0
  12. synapse/database/__init__.py +19 -0
  13. synapse/database/base.py +63 -0
  14. synapse/database/chroma.py +175 -0
  15. synapse/database/memory.py +132 -0
  16. synapse/database/pgvector.py +99 -0
  17. synapse/database/qdrant.py +138 -0
  18. synapse/embeddings/__init__.py +21 -0
  19. synapse/embeddings/base.py +262 -0
  20. synapse/export/__init__.py +14 -0
  21. synapse/export/mcp_exporter.py +202 -0
  22. synapse/graph/__init__.py +21 -0
  23. synapse/graph/base.py +130 -0
  24. synapse/graph/memory.py +20 -0
  25. synapse/graph/neo4j_store.py +176 -0
  26. synapse/graph/networkx_store.py +221 -0
  27. synapse/inspector/__init__.py +5 -0
  28. synapse/inspector/template.py +530 -0
  29. synapse/models/__init__.py +73 -0
  30. synapse/models/base.py +95 -0
  31. synapse/models/gemini.py +328 -0
  32. synapse/models/ollama.py +322 -0
  33. synapse/models/openai.py +289 -0
  34. synapse/oauth/__init__.py +7 -0
  35. synapse/oauth/google.py +194 -0
  36. synapse/oauth/notion.py +35 -0
  37. synapse/rag/__init__.py +8 -0
  38. synapse/rag/chunker.py +219 -0
  39. synapse/rag/hybrid_rag.py +122 -0
  40. synapse/resource/__init__.py +5 -0
  41. synapse/resource/resource.py +304 -0
  42. synapse/resources/__init__.py +5 -0
  43. synapse/skills/__init__.py +5 -0
  44. synapse/skills/skill.py +321 -0
  45. synapse/storage/__init__.py +24 -0
  46. synapse/storage/graph_exporter.py +624 -0
  47. synapse/storage/graph_store.py +162 -0
  48. synapse/storage/vector_store.py +103 -0
  49. synapse_framework-0.2.4.dist-info/METADATA +415 -0
  50. synapse_framework-0.2.4.dist-info/RECORD +54 -0
  51. synapse_framework-0.2.4.dist-info/WHEEL +5 -0
  52. synapse_framework-0.2.4.dist-info/entry_points.txt +2 -0
  53. synapse_framework-0.2.4.dist-info/licenses/LICENSE +21 -0
  54. synapse_framework-0.2.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,328 @@
1
+ """Google Gemini API model provider for Synapse."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ import re
9
+ from collections.abc import AsyncGenerator
10
+ from typing import Any
11
+
12
+ import httpx
13
+
14
+ from synapse.config import DEFAULT_GEMINI_MODEL, DEFAULT_TEMPERATURE
15
+ from synapse.models.base import BaseModelProvider
16
+
17
+
18
+ class GeminiModel(BaseModelProvider):
19
+ """Google Gemini language model provider (e.g. gemini-3.5-flash, gemini-flash-latest).
20
+
21
+ Attributes:
22
+ api_key: Google AI Studio API key (reads from GEMINI_API_KEY if omitted).
23
+ model_name: Model identifier (default: 'gemini-3.5-flash').
24
+ temperature: Default sampling temperature.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ api_key: str | None = None,
30
+ model: str = DEFAULT_GEMINI_MODEL,
31
+ temperature: float = DEFAULT_TEMPERATURE,
32
+ timeout: float = 60.0,
33
+ ) -> None:
34
+ """Initializes the Gemini model provider.
35
+
36
+ Args:
37
+ api_key: Google Gemini API key. If None, reads from GEMINI_API_KEY.
38
+ model: Gemini model identifier. Defaults to DEFAULT_GEMINI_MODEL ('gemini-3.5-flash').
39
+ temperature: Sampling temperature. Defaults to 0.1.
40
+ timeout: HTTP request timeout in seconds. Defaults to 60.0.
41
+ """
42
+ super().__init__(model_name=model, temperature=temperature)
43
+ self.api_key = api_key or os.getenv("GEMINI_API_KEY", "")
44
+ self.base_url = "https://generativelanguage.googleapis.com/v1beta/models"
45
+ self.timeout = timeout
46
+
47
+ def _convert_messages(
48
+ self, messages: list[dict[str, str]]
49
+ ) -> tuple[str | None, list[dict[str, Any]]]:
50
+ system_instruction: str | None = None
51
+ contents: list[dict[str, Any]] = []
52
+
53
+ for msg in messages:
54
+ role = msg.get("role", "user")
55
+ content = msg.get("content", "")
56
+ if role == "system":
57
+ system_instruction = content
58
+ else:
59
+ gemini_role = "model" if role == "assistant" else "user"
60
+ contents.append({
61
+ "role": gemini_role,
62
+ "parts": [{"text": content}],
63
+ })
64
+
65
+ return system_instruction, contents
66
+
67
+ async def chat_stream(
68
+ self,
69
+ messages: list[dict[str, str]],
70
+ temperature: float | None = None,
71
+ ) -> AsyncGenerator[str, None]:
72
+ """Streams response tokens from Google Gemini via streamGenerateContent."""
73
+ temp = self.temperature if temperature is None else temperature
74
+ sys_inst, contents = self._convert_messages(messages)
75
+
76
+ payload: dict[str, Any] = {
77
+ "contents": contents,
78
+ "generationConfig": {"temperature": temp},
79
+ }
80
+ if sys_inst:
81
+ payload["systemInstruction"] = {"parts": [{"text": sys_inst}]}
82
+
83
+ url = f"{self.base_url}/{self.model_name}:streamGenerateContent?key={self.api_key}&alt=sse"
84
+
85
+ for attempt in range(4):
86
+ try:
87
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
88
+ async with client.stream("POST", url, json=payload) as stream:
89
+ if stream.status_code in (400, 401, 403):
90
+ yield "Gemini authentication failed: Please verify your Google Gemini API key."
91
+ return
92
+ if stream.status_code in (503, 429) and attempt < 3:
93
+ await asyncio.sleep(1.0 * (attempt + 1))
94
+ continue
95
+ if stream.status_code != 200:
96
+ err_body = await stream.aread()
97
+ err_msg = ""
98
+ try:
99
+ err_data = json.loads(err_body.decode("utf-8", errors="replace"))
100
+ err_msg = err_data.get("error", {}).get("message", "")
101
+ except Exception:
102
+ pass # nosec B110
103
+ if stream.status_code == 429:
104
+ yield "⚠️ Google Gemini Rate Limit (HTTP 429): Free tier limit of 5 requests/minute reached. Please wait ~30 seconds for the quota window to reset, or check your quota at https://ai.google.dev."
105
+ return
106
+ yield f"Gemini API returned HTTP {stream.status_code}: {err_msg or 'Request failed'}."
107
+ return
108
+
109
+ async for line in stream.aiter_lines():
110
+ if not line or not line.startswith("data: "):
111
+ continue
112
+ data_str = line[6:].strip()
113
+ try:
114
+ data = json.loads(data_str)
115
+ candidates = data.get("candidates", [])
116
+ if candidates:
117
+ parts = (
118
+ candidates[0]
119
+ .get("content", {})
120
+ .get("parts", [])
121
+ )
122
+ for part in parts:
123
+ text = part.get("text", "")
124
+ if text:
125
+ yield text
126
+ except json.JSONDecodeError:
127
+ continue
128
+ return
129
+ except (httpx.ConnectError, httpx.ConnectTimeout):
130
+ if attempt < 2:
131
+ await asyncio.sleep(0.6)
132
+ continue
133
+ yield "Cannot connect to Gemini API endpoint. Please check your internet connection."
134
+ return
135
+ except Exception as e:
136
+ if attempt < 2:
137
+ await asyncio.sleep(0.6)
138
+ continue
139
+ yield f"Gemini error: {e}"
140
+ return
141
+
142
+ async def generate(
143
+ self,
144
+ prompt: str,
145
+ temperature: float | None = None,
146
+ ) -> str:
147
+ """Generates a non-streaming response for a prompt string."""
148
+ temp = self.temperature if temperature is None else temperature
149
+ payload: dict[str, Any] = {
150
+ "contents": [{"role": "user", "parts": [{"text": prompt}]}],
151
+ "generationConfig": {"temperature": temp},
152
+ }
153
+
154
+ url = f"{self.base_url}/{self.model_name}:generateContent?key={self.api_key}"
155
+
156
+ for attempt in range(4):
157
+ try:
158
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
159
+ res = await client.post(url, json=payload)
160
+ if res.status_code in (503, 429) and attempt < 3:
161
+ await asyncio.sleep(1.0 * (attempt + 1))
162
+ continue
163
+ res.raise_for_status()
164
+ data = res.json()
165
+ candidates = data.get("candidates", [])
166
+ if candidates:
167
+ parts = candidates[0].get("content", {}).get("parts", [])
168
+ return "".join(p.get("text", "") for p in parts).strip()
169
+ return ""
170
+ except Exception as e:
171
+ if attempt < 3:
172
+ await asyncio.sleep(1.0)
173
+ continue
174
+ return f"[Error from Gemini: {e}]"
175
+ return ""
176
+
177
+
178
+ async def extract_triples(self, text: str) -> list[dict[str, str]]:
179
+ """Extracts relational triples using Gemini structured prompting."""
180
+ clean_text = text.strip()
181
+ if not clean_text:
182
+ return []
183
+
184
+ prompt = (
185
+ "You are an information extraction system. Extract key relational triples from the text.\n"
186
+ "Valid types: Person, Email, Project, Tool, Topic, Default.\n"
187
+ "Return ONLY a valid JSON list of objects without explanations or markdown backticks.\n\n"
188
+ "Example format:\n"
189
+ '[{"source": "Sarah", "source_type": "Person", "relation": "DISCUSSES", "target": "Synapse", "target_type": "Project"}]\n\n'
190
+ f"TEXT:\n{clean_text}\n\n"
191
+ "JSON:"
192
+ )
193
+
194
+ try:
195
+ raw_text = await self.generate(prompt, temperature=0.0)
196
+ cleaned = re.sub(
197
+ r"^```(?:json)?\s*|\s*```$",
198
+ "",
199
+ raw_text,
200
+ flags=re.MULTILINE,
201
+ ).strip()
202
+ match = re.search(r"\[.*\]", cleaned, re.DOTALL)
203
+ if match:
204
+ cleaned = match.group(0)
205
+ parsed = json.loads(cleaned)
206
+
207
+ if isinstance(parsed, list):
208
+ valid_triples: list[dict[str, str]] = []
209
+ for item in parsed:
210
+ if (
211
+ isinstance(item, dict)
212
+ and "source" in item
213
+ and "relation" in item
214
+ and "target" in item
215
+ ):
216
+ valid_triples.append({
217
+ "source": str(item["source"]).strip(),
218
+ "relation": (
219
+ str(item["relation"])
220
+ .strip()
221
+ .upper()
222
+ .replace(" ", "_")
223
+ ),
224
+ "target": str(item["target"]).strip(),
225
+ "source_type": (
226
+ str(item.get("source_type", "Default"))
227
+ .strip()
228
+ .title()
229
+ ),
230
+ "target_type": (
231
+ str(item.get("target_type", "Default"))
232
+ .strip()
233
+ .title()
234
+ ),
235
+ })
236
+ return valid_triples
237
+ except Exception:
238
+ return []
239
+
240
+ return []
241
+
242
+ async def decide_tool_calls(
243
+ self,
244
+ user_message: str,
245
+ candidate_tools: list[dict[str, Any]],
246
+ fallback_calls: list[dict[str, Any]] | None = None,
247
+ ) -> list[dict[str, Any]]:
248
+ """Decides multi-tool routing using Gemini reasoning."""
249
+ text = (user_message or "").strip()
250
+ if not text or not candidate_tools:
251
+ return []
252
+
253
+ catalog_lines = []
254
+ valid_keys: dict[tuple[str, str], dict[str, Any]] = {}
255
+ for idx, t in enumerate(candidate_tools, start=1):
256
+ srv = str(t.get("server", "")).strip()
257
+ name = str(t.get("name", "")).strip()
258
+ desc = str(t.get("description", "")).strip()
259
+ schema = json.dumps(t.get("inputSchema", {}).get("properties", {}))
260
+ catalog_lines.append(
261
+ f'{idx}. server="{srv}", tool="{name}" — {desc}\n arguments schema: {schema}'
262
+ )
263
+ valid_keys[(srv, name)] = t
264
+ valid_keys[("", name)] = t
265
+
266
+ catalog_text = "\n".join(catalog_lines)
267
+ tool_prompt = (
268
+ "You are a multi-tool router for a personal AI assistant.\n"
269
+ "Available Tools:\n"
270
+ f"{catalog_text}\n\n"
271
+ "Decide which tool(s) must be called to answer the user's message.\n"
272
+ "- Only call a tool if the user's message actually requires fetching data from that service.\n"
273
+ "- For Gmail unread queries, include 'is:unread' in the Gmail query (e.g. 'label:INBOX is:unread').\n"
274
+ "Return ONLY valid JSON without markdown backticks in this exact format:\n"
275
+ '{"tool_calls": [{"server": "gmail", "tool": "search_emails", "arguments": {"query": "label:INBOX is:unread", "max_results": 5}}]}\n'
276
+ "or if no tool is needed:\n"
277
+ '{"tool_calls": []}\n\n'
278
+ f"USER MESSAGE: {text}\n"
279
+ "JSON:"
280
+ )
281
+
282
+ try:
283
+ raw_text = await self.generate(tool_prompt, temperature=0.0)
284
+ if not raw_text or raw_text.startswith("[Error from Gemini:"):
285
+ return []
286
+
287
+ cleaned = re.sub(
288
+ r"^```(?:json)?\s*|\s*```$",
289
+ "",
290
+ raw_text,
291
+ flags=re.MULTILINE,
292
+ ).strip()
293
+ match = re.search(r"\{.*\}", cleaned, re.DOTALL)
294
+ if match:
295
+ cleaned = match.group(0)
296
+ parsed = json.loads(cleaned)
297
+
298
+ if isinstance(parsed, dict):
299
+ if parsed.get("call_tool") is False:
300
+ return []
301
+ raw_calls = parsed.get("tool_calls")
302
+ if isinstance(raw_calls, list):
303
+ if len(raw_calls) == 0:
304
+ return []
305
+ validated: list[dict[str, Any]] = []
306
+ for item in raw_calls:
307
+ if not isinstance(item, dict):
308
+ continue
309
+ srv = str(item.get("server") or "").strip()
310
+ tl = str(item.get("tool") or item.get("name") or "").strip()
311
+ spec = valid_keys.get((srv, tl)) or valid_keys.get(("", tl))
312
+ if not spec:
313
+ continue
314
+ resolved_srv = str(spec.get("server") or srv)
315
+ resolved_tl = str(spec.get("name") or tl)
316
+ args = item.get("arguments")
317
+ if not isinstance(args, dict):
318
+ args = {}
319
+ validated.append({
320
+ "server": resolved_srv,
321
+ "tool": resolved_tl,
322
+ "arguments": args,
323
+ })
324
+ return validated
325
+ except (json.JSONDecodeError, KeyError, TypeError, ValueError, httpx.HTTPError):
326
+ pass # nosec B110
327
+
328
+ return list(fallback_calls or [])
@@ -0,0 +1,322 @@
1
+ """Local Ollama model provider for Synapse."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from collections.abc import AsyncGenerator
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ from synapse.config import (
13
+ DEFAULT_OLLAMA_BASE_URL,
14
+ DEFAULT_OLLAMA_MODEL,
15
+ DEFAULT_TEMPERATURE,
16
+ )
17
+ from synapse.models.base import BaseModelProvider
18
+
19
+
20
+ class OllamaModel(BaseModelProvider):
21
+ """Local Ollama LLM provider (e.g. Qwen 2.5, Llama 3, Mistral).
22
+
23
+ Attributes:
24
+ base_url: Base HTTP address of the running Ollama daemon.
25
+ model_name: Target model tag (default: 'qwen2.5:0.5b').
26
+ temperature: Default sampling temperature.
27
+ num_ctx: Context window size configured for Ollama requests.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ model: str = DEFAULT_OLLAMA_MODEL,
33
+ base_url: str = DEFAULT_OLLAMA_BASE_URL,
34
+ temperature: float = DEFAULT_TEMPERATURE,
35
+ num_ctx: int = 4096,
36
+ timeout: float = 60.0,
37
+ ) -> None:
38
+ """Initializes the Ollama model provider.
39
+
40
+ Args:
41
+ model: Model tag registered in Ollama. Defaults to 'qwen2.5:0.5b'.
42
+ base_url: Base URL of the Ollama server. Defaults to 'http://localhost:11434'.
43
+ temperature: Sampling temperature. Defaults to 0.1.
44
+ num_ctx: Context window size. Defaults to 4096.
45
+ timeout: HTTP request timeout in seconds. Defaults to 60.0.
46
+ """
47
+ super().__init__(model_name=model, temperature=temperature)
48
+ self.base_url = base_url.rstrip("/")
49
+ self.num_ctx = num_ctx
50
+ self.timeout = timeout
51
+
52
+ async def chat_stream(
53
+ self,
54
+ messages: list[dict[str, str]],
55
+ temperature: float | None = None,
56
+ ) -> AsyncGenerator[str, None]:
57
+ """Streams response tokens from the local Ollama instance.
58
+
59
+ Args:
60
+ messages: List of chat messages with 'role' and 'content'.
61
+ temperature: Optional temperature override.
62
+
63
+ Yields:
64
+ Token strings as they are generated.
65
+ """
66
+ temp = self.temperature if temperature is None else temperature
67
+ payload: dict[str, Any] = {
68
+ "model": self.model_name,
69
+ "messages": messages,
70
+ "stream": True,
71
+ "options": {
72
+ "temperature": temp,
73
+ "num_ctx": self.num_ctx,
74
+ },
75
+ }
76
+
77
+ try:
78
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
79
+ async with client.stream(
80
+ "POST",
81
+ f"{self.base_url}/api/chat",
82
+ json=payload,
83
+ ) as stream:
84
+ if stream.status_code != 200:
85
+ yield (
86
+ f"Ollama returned HTTP {stream.status_code}. "
87
+ f"Please verify that model '{self.model_name}' is downloaded (`ollama pull {self.model_name}`)."
88
+ )
89
+ return
90
+
91
+ async for chunk in stream.aiter_lines():
92
+ if not chunk:
93
+ continue
94
+ try:
95
+ data = json.loads(chunk)
96
+ token = data.get("message", {}).get("content", "")
97
+ if token:
98
+ yield token
99
+ except (json.JSONDecodeError, KeyError):
100
+ continue
101
+ except (httpx.ConnectError, httpx.ConnectTimeout):
102
+ yield (
103
+ f"Cannot reach Ollama at {self.base_url}. "
104
+ f"Please ensure the Ollama daemon is running (`ollama serve`), "
105
+ f"or switch to an alternative model provider in Settings."
106
+ )
107
+ except Exception as e:
108
+ yield f"Ollama generation error: {e}"
109
+
110
+ async def generate(
111
+ self,
112
+ prompt: str,
113
+ temperature: float | None = None,
114
+ format: str | None = None,
115
+ ) -> str:
116
+ """Generates a complete non-streaming response from Ollama.
117
+
118
+ Args:
119
+ prompt: Text prompt string.
120
+ temperature: Optional temperature override.
121
+ format: Optional output format (e.g. 'json').
122
+
123
+ Returns:
124
+ The complete response text string.
125
+ """
126
+ temp = self.temperature if temperature is None else temperature
127
+ payload: dict[str, Any] = {
128
+ "model": self.model_name,
129
+ "prompt": prompt,
130
+ "stream": False,
131
+ "options": {
132
+ "temperature": temp,
133
+ "num_ctx": self.num_ctx,
134
+ },
135
+ }
136
+ if format:
137
+ payload["format"] = format
138
+
139
+ try:
140
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
141
+ res = await client.post(
142
+ f"{self.base_url}/api/generate",
143
+ json=payload,
144
+ )
145
+ res.raise_for_status()
146
+ data = res.json()
147
+ return str(data.get("response", "")).strip()
148
+ except (httpx.ConnectError, httpx.ConnectTimeout):
149
+ return f"[Error: Cannot reach Ollama at {self.base_url}. Ensure Ollama is running.]"
150
+ except Exception as e:
151
+ return f"[Error from Ollama: {e}]"
152
+
153
+ async def extract_triples(self, text: str) -> list[dict[str, str]]:
154
+ """Extracts typed knowledge graph triples from text using Ollama.
155
+
156
+ Args:
157
+ text: Unstructured text content.
158
+
159
+ Returns:
160
+ A list of dictionary triples.
161
+ """
162
+ clean_text = text.strip()
163
+ if not clean_text:
164
+ return []
165
+
166
+ prompt = (
167
+ "You are an information extraction system. Extract key relational triples from the text.\n"
168
+ "Valid types: Person, Email, Project, Tool, Topic, Default.\n"
169
+ "Return ONLY a valid JSON list of objects without explanations or markdown backticks.\n\n"
170
+ "Example format:\n"
171
+ '[{"source": "Sarah", "source_type": "Person", "relation": "DISCUSSES", "target": "Synapse", "target_type": "Project"}]\n\n'
172
+ f"TEXT:\n{clean_text}\n\n"
173
+ "JSON:"
174
+ )
175
+
176
+ try:
177
+ raw_text = await self.generate(prompt, temperature=0.0, format="json")
178
+ if not raw_text or raw_text.startswith("[Error:"):
179
+ return []
180
+ cleaned = re.sub(
181
+ r"^```(?:json)?\s*|\s*```$",
182
+ "",
183
+ raw_text,
184
+ flags=re.MULTILINE,
185
+ ).strip()
186
+ match = re.search(r"(\[.*\]|\{.*\})", cleaned, re.DOTALL)
187
+ if match:
188
+ cleaned = match.group(0)
189
+ parsed = json.loads(cleaned)
190
+ if isinstance(parsed, dict):
191
+ parsed = parsed.get("triples") or parsed.get("data") or (list(parsed.values())[0] if parsed.values() and isinstance(list(parsed.values())[0], list) else [])
192
+
193
+ if isinstance(parsed, list):
194
+ valid_triples: list[dict[str, str]] = []
195
+ for item in parsed:
196
+ if (
197
+ isinstance(item, dict)
198
+ and "source" in item
199
+ and "relation" in item
200
+ and "target" in item
201
+ ):
202
+ valid_triples.append({
203
+ "source": str(item["source"]).strip(),
204
+ "relation": (
205
+ str(item["relation"])
206
+ .strip()
207
+ .upper()
208
+ .replace(" ", "_")
209
+ ),
210
+ "target": str(item["target"]).strip(),
211
+ "source_type": (
212
+ str(item.get("source_type", "Default"))
213
+ .strip()
214
+ .title()
215
+ ),
216
+ "target_type": (
217
+ str(item.get("target_type", "Default"))
218
+ .strip()
219
+ .title()
220
+ ),
221
+ })
222
+ return valid_triples
223
+ except Exception:
224
+ return []
225
+
226
+ return []
227
+
228
+ async def decide_tool_calls(
229
+ self,
230
+ user_message: str,
231
+ candidate_tools: list[dict[str, Any]],
232
+ fallback_calls: list[dict[str, Any]] | None = None,
233
+ ) -> list[dict[str, Any]]:
234
+ """Determines which candidate MCP tool(s) to execute using Ollama.
235
+
236
+ Args:
237
+ user_message: Natural-language message from the user.
238
+ candidate_tools: List of tool schema descriptors across connectors.
239
+ fallback_calls: Fallback calls used if model outputs invalid JSON.
240
+
241
+ Returns:
242
+ A list of validated tool call dictionaries.
243
+ """
244
+ text = (user_message or "").strip()
245
+ if not text or not candidate_tools:
246
+ return []
247
+
248
+ catalog_lines: list[str] = []
249
+ valid_keys: dict[tuple[str, str], dict[str, Any]] = {}
250
+ for idx, t in enumerate(candidate_tools, start=1):
251
+ srv = str(t.get("server", "")).strip()
252
+ name = str(t.get("name", "")).strip()
253
+ desc = str(t.get("description", "")).strip()
254
+ schema = json.dumps(t.get("inputSchema", {}).get("properties", {}))
255
+ catalog_lines.append(
256
+ f'{idx}. server="{srv}", tool="{name}" — {desc}\n arguments schema: {schema}'
257
+ )
258
+ valid_keys[(srv, name)] = t
259
+ valid_keys[("", name)] = t
260
+
261
+ catalog_text = "\n".join(catalog_lines)
262
+ tool_prompt = (
263
+ "You are a multi-tool router for a personal AI assistant.\n"
264
+ "Available Tools:\n"
265
+ f"{catalog_text}\n\n"
266
+ "Decide which tool(s) must be called to answer the user's message.\n"
267
+ "- Only call a tool if the user's message actually requires fetching data from that service.\n"
268
+ "- For Gmail unread queries, include 'is:unread' in the Gmail query (e.g. 'label:INBOX is:unread').\n"
269
+ "Return ONLY valid JSON without markdown backticks in this exact format:\n"
270
+ '{"tool_calls": [{"server": "gmail", "tool": "search_emails", "arguments": {"query": "label:INBOX is:unread", "max_results": 5}}]}\n'
271
+ "or if no tool is needed:\n"
272
+ '{"tool_calls": []}\n\n'
273
+ f"USER MESSAGE: {text}\n"
274
+ "JSON:"
275
+ )
276
+
277
+ try:
278
+ raw_text = await self.generate(tool_prompt, temperature=0.0, format="json")
279
+ if not raw_text or raw_text.startswith("[Error:"):
280
+ return []
281
+ cleaned = re.sub(
282
+ r"^```(?:json)?\s*|\s*```$",
283
+ "",
284
+ raw_text,
285
+ flags=re.MULTILINE,
286
+ ).strip()
287
+ match = re.search(r"\{.*\}", cleaned, re.DOTALL)
288
+ if match:
289
+ cleaned = match.group(0)
290
+ parsed = json.loads(cleaned)
291
+
292
+ if isinstance(parsed, dict):
293
+ if parsed.get("call_tool") is False:
294
+ return []
295
+ raw_calls = parsed.get("tool_calls")
296
+ if isinstance(raw_calls, list):
297
+ if len(raw_calls) == 0:
298
+ return []
299
+ validated: list[dict[str, Any]] = []
300
+ for item in raw_calls:
301
+ if not isinstance(item, dict):
302
+ continue
303
+ srv = str(item.get("server") or "").strip()
304
+ tl = str(item.get("tool") or item.get("name") or "").strip()
305
+ spec = valid_keys.get((srv, tl)) or valid_keys.get(("", tl))
306
+ if not spec:
307
+ continue
308
+ resolved_srv = str(spec.get("server") or srv)
309
+ resolved_tl = str(spec.get("name") or tl)
310
+ args = item.get("arguments")
311
+ if not isinstance(args, dict):
312
+ args = {}
313
+ validated.append({
314
+ "server": resolved_srv,
315
+ "tool": resolved_tl,
316
+ "arguments": args,
317
+ })
318
+ return validated
319
+ except (json.JSONDecodeError, KeyError, TypeError, ValueError, httpx.HTTPError):
320
+ pass # nosec B110
321
+
322
+ return list(fallback_calls or [])