abstractcore 2.6.9__py3-none-any.whl → 2.9.0__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 (46) hide show
  1. abstractcore/apps/summarizer.py +69 -27
  2. abstractcore/architectures/detection.py +190 -25
  3. abstractcore/assets/architecture_formats.json +129 -6
  4. abstractcore/assets/model_capabilities.json +803 -141
  5. abstractcore/config/main.py +2 -2
  6. abstractcore/config/manager.py +3 -1
  7. abstractcore/events/__init__.py +7 -1
  8. abstractcore/mcp/__init__.py +30 -0
  9. abstractcore/mcp/client.py +213 -0
  10. abstractcore/mcp/factory.py +64 -0
  11. abstractcore/mcp/naming.py +28 -0
  12. abstractcore/mcp/stdio_client.py +336 -0
  13. abstractcore/mcp/tool_source.py +164 -0
  14. abstractcore/processing/__init__.py +2 -2
  15. abstractcore/processing/basic_deepsearch.py +1 -1
  16. abstractcore/processing/basic_summarizer.py +379 -93
  17. abstractcore/providers/anthropic_provider.py +91 -10
  18. abstractcore/providers/base.py +540 -16
  19. abstractcore/providers/huggingface_provider.py +17 -8
  20. abstractcore/providers/lmstudio_provider.py +170 -25
  21. abstractcore/providers/mlx_provider.py +13 -10
  22. abstractcore/providers/ollama_provider.py +42 -26
  23. abstractcore/providers/openai_compatible_provider.py +87 -22
  24. abstractcore/providers/openai_provider.py +12 -9
  25. abstractcore/providers/streaming.py +201 -39
  26. abstractcore/providers/vllm_provider.py +78 -21
  27. abstractcore/server/app.py +116 -30
  28. abstractcore/structured/retry.py +20 -7
  29. abstractcore/tools/__init__.py +46 -24
  30. abstractcore/tools/abstractignore.py +166 -0
  31. abstractcore/tools/arg_canonicalizer.py +61 -0
  32. abstractcore/tools/common_tools.py +2443 -742
  33. abstractcore/tools/core.py +109 -13
  34. abstractcore/tools/handler.py +17 -3
  35. abstractcore/tools/parser.py +894 -159
  36. abstractcore/tools/registry.py +122 -18
  37. abstractcore/tools/syntax_rewriter.py +68 -6
  38. abstractcore/tools/tag_rewriter.py +186 -1
  39. abstractcore/utils/jsonish.py +111 -0
  40. abstractcore/utils/version.py +1 -1
  41. {abstractcore-2.6.9.dist-info → abstractcore-2.9.0.dist-info}/METADATA +55 -2
  42. {abstractcore-2.6.9.dist-info → abstractcore-2.9.0.dist-info}/RECORD +46 -37
  43. {abstractcore-2.6.9.dist-info → abstractcore-2.9.0.dist-info}/WHEEL +0 -0
  44. {abstractcore-2.6.9.dist-info → abstractcore-2.9.0.dist-info}/entry_points.txt +0 -0
  45. {abstractcore-2.6.9.dist-info → abstractcore-2.9.0.dist-info}/licenses/LICENSE +0 -0
  46. {abstractcore-2.6.9.dist-info → abstractcore-2.9.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,336 @@
1
+ from __future__ import annotations
2
+
3
+ import itertools
4
+ import json
5
+ import os
6
+ import subprocess
7
+ import threading
8
+ import time
9
+ from collections import deque
10
+ from dataclasses import dataclass
11
+ from importlib import metadata
12
+ from typing import Any, Deque, Dict, List, Optional, Sequence
13
+
14
+ from .client import McpError, McpJsonRpcRequest, McpProtocolError, McpRpcError
15
+
16
+
17
+ _DEFAULT_PROTOCOL_VERSION = "2025-11-25"
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class McpStdioServerParameters:
22
+ command: List[str]
23
+ cwd: Optional[str] = None
24
+ env: Optional[Dict[str, str]] = None
25
+
26
+
27
+ class McpStdioClient:
28
+ """A minimal MCP JSON-RPC client using stdio (spawn a subprocess).
29
+
30
+ This client is synchronous and intentionally small: it focuses on the tools surface
31
+ (`tools/list`, `tools/call`) and includes a best-effort MCP initialization handshake.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ *,
37
+ command: Sequence[str],
38
+ cwd: Optional[str] = None,
39
+ env: Optional[Dict[str, str]] = None,
40
+ timeout_s: Optional[float] = 30.0,
41
+ protocol_version: Optional[str] = None,
42
+ client_name: str = "abstractcore.mcp",
43
+ client_version: Optional[str] = None,
44
+ ) -> None:
45
+ cmd = [str(c) for c in (command or []) if str(c).strip()]
46
+ if not cmd:
47
+ raise ValueError("McpStdioClient requires a non-empty command")
48
+
49
+ self._timeout_s = float(timeout_s) if timeout_s is not None else None
50
+ self._id_iter = itertools.count(1)
51
+ self._init_attempted = False
52
+ self._initialized = False
53
+
54
+ self._proc = subprocess.Popen(
55
+ cmd,
56
+ stdin=subprocess.PIPE,
57
+ stdout=subprocess.PIPE,
58
+ stderr=subprocess.PIPE,
59
+ text=True,
60
+ encoding="utf-8",
61
+ bufsize=1,
62
+ cwd=str(cwd) if cwd else None,
63
+ env=self._merge_env(env),
64
+ )
65
+ if self._proc.stdin is None or self._proc.stdout is None:
66
+ raise McpError("Failed to start MCP stdio subprocess with pipes")
67
+
68
+ self._lock = threading.Lock()
69
+ self._cond = threading.Condition(self._lock)
70
+ self._responses: Dict[int, Dict[str, Any]] = {}
71
+ self._global_error: Optional[Dict[str, Any]] = None
72
+ self._closed = False
73
+ self._stderr_tail: Deque[str] = deque(maxlen=200)
74
+
75
+ self._protocol_version = str(protocol_version).strip() if protocol_version else _DEFAULT_PROTOCOL_VERSION
76
+ self._client_name = str(client_name or "abstractcore.mcp").strip() or "abstractcore.mcp"
77
+ self._client_version = str(client_version).strip() if client_version else self._default_client_version()
78
+
79
+ self._stdout_thread = threading.Thread(target=self._read_stdout_loop, daemon=True)
80
+ self._stderr_thread = threading.Thread(target=self._read_stderr_loop, daemon=True)
81
+ self._stdout_thread.start()
82
+ self._stderr_thread.start()
83
+
84
+ @staticmethod
85
+ def _default_client_version() -> str:
86
+ for pkg in ("abstractcore", "AbstractCore"):
87
+ try:
88
+ v = str(metadata.version(pkg) or "").strip()
89
+ except Exception:
90
+ v = ""
91
+ if v:
92
+ return v
93
+ return "0.0.0"
94
+
95
+ @staticmethod
96
+ def _merge_env(env: Optional[Dict[str, str]]) -> Optional[Dict[str, str]]:
97
+ if env is None:
98
+ return None
99
+ merged = dict(os.environ)
100
+ for k, v in env.items():
101
+ if not isinstance(k, str) or not k.strip():
102
+ continue
103
+ if v is None:
104
+ continue
105
+ merged[str(k)] = str(v)
106
+ return merged
107
+
108
+ def close(self) -> None:
109
+ with self._cond:
110
+ if self._closed:
111
+ return
112
+ self._closed = True
113
+ self._cond.notify_all()
114
+
115
+ try:
116
+ if self._proc.stdin:
117
+ try:
118
+ self._proc.stdin.close()
119
+ except Exception:
120
+ pass
121
+ if self._proc.stdout:
122
+ try:
123
+ self._proc.stdout.close()
124
+ except Exception:
125
+ pass
126
+ finally:
127
+ try:
128
+ self._proc.terminate()
129
+ except Exception:
130
+ pass
131
+ try:
132
+ self._proc.wait(timeout=2)
133
+ except Exception:
134
+ try:
135
+ self._proc.kill()
136
+ except Exception:
137
+ pass
138
+
139
+ def __enter__(self) -> "McpStdioClient":
140
+ return self
141
+
142
+ def __exit__(self, exc_type, exc, tb) -> None:
143
+ self.close()
144
+
145
+ def _read_stdout_loop(self) -> None:
146
+ try:
147
+ assert self._proc.stdout is not None
148
+ for line in self._proc.stdout:
149
+ text = (line or "").strip()
150
+ if not text:
151
+ continue
152
+ try:
153
+ msg = json.loads(text)
154
+ except Exception:
155
+ continue
156
+ if not isinstance(msg, dict):
157
+ continue
158
+
159
+ msg_id = msg.get("id")
160
+ if msg_id is None:
161
+ # JSON-RPC error may legally have id=null; surface as a global error.
162
+ if msg.get("error") is not None:
163
+ with self._cond:
164
+ self._global_error = dict(msg)
165
+ self._cond.notify_all()
166
+ continue
167
+ try:
168
+ mid = int(msg_id)
169
+ except Exception:
170
+ continue
171
+
172
+ with self._cond:
173
+ self._responses[mid] = dict(msg)
174
+ self._cond.notify_all()
175
+ finally:
176
+ with self._cond:
177
+ self._closed = True
178
+ self._cond.notify_all()
179
+
180
+ def _read_stderr_loop(self) -> None:
181
+ try:
182
+ if self._proc.stderr is None:
183
+ return
184
+ for line in self._proc.stderr:
185
+ text = (line or "").rstrip()
186
+ if not text:
187
+ continue
188
+ with self._cond:
189
+ self._stderr_tail.append(text)
190
+ except Exception:
191
+ return
192
+
193
+ def _send(self, payload: Dict[str, Any]) -> None:
194
+ if self._proc.stdin is None:
195
+ raise McpError("MCP stdio process stdin is closed")
196
+ try:
197
+ self._proc.stdin.write(json.dumps(payload, ensure_ascii=False) + "\n")
198
+ self._proc.stdin.flush()
199
+ except Exception as e:
200
+ raise McpError(f"Failed to write to MCP stdio process: {e}") from e
201
+
202
+ def _wait_for(self, req_id: int) -> Dict[str, Any]:
203
+ deadline: Optional[float]
204
+ if self._timeout_s is None:
205
+ deadline = None
206
+ else:
207
+ deadline = time.time() + self._timeout_s
208
+
209
+ with self._cond:
210
+ while True:
211
+ if req_id in self._responses:
212
+ return self._responses.pop(req_id)
213
+ if self._global_error is not None:
214
+ err = self._global_error
215
+ self._global_error = None
216
+ raise McpProtocolError(f"MCP stdio global error: {err}")
217
+ if self._closed:
218
+ tail = "\n".join(list(self._stderr_tail)[-20:])
219
+ raise McpError(f"MCP stdio process closed unexpectedly.\n\nstderr tail:\n{tail}")
220
+
221
+ if deadline is None:
222
+ self._cond.wait(timeout=0.25)
223
+ continue
224
+
225
+ remaining = deadline - time.time()
226
+ if remaining <= 0:
227
+ tail = "\n".join(list(self._stderr_tail)[-20:])
228
+ raise McpError(f"MCP stdio request timed out after {self._timeout_s}s.\n\nstderr tail:\n{tail}")
229
+ self._cond.wait(timeout=min(0.25, remaining))
230
+
231
+ def _request_no_init(self, *, method: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
232
+ mid = str(method or "").strip()
233
+ if not mid:
234
+ raise ValueError("MCP request requires a non-empty method")
235
+
236
+ req = McpJsonRpcRequest(jsonrpc="2.0", id=next(self._id_iter), method=mid, params=params)
237
+ self._send(req.to_dict())
238
+ resp = self._wait_for(req.id)
239
+
240
+ if resp.get("jsonrpc") != "2.0":
241
+ raise McpProtocolError("MCP response missing jsonrpc='2.0'")
242
+
243
+ if "error" in resp and resp["error"] is not None:
244
+ err = resp["error"]
245
+ if isinstance(err, dict):
246
+ raise McpRpcError(
247
+ code=int(err.get("code") or 0),
248
+ message=str(err.get("message") or "Unknown error"),
249
+ data=err.get("data"),
250
+ )
251
+ raise McpRpcError(code=-32000, message=str(err), data=None)
252
+
253
+ resp_id = resp.get("id")
254
+ if resp_id is None:
255
+ raise McpProtocolError("MCP response missing id")
256
+ if str(resp_id) != str(req.id):
257
+ raise McpProtocolError(f"MCP response id mismatch (expected {req.id}, got {resp_id})")
258
+
259
+ result = resp.get("result")
260
+ if not isinstance(result, dict):
261
+ raise McpProtocolError("MCP response missing result object")
262
+ return result
263
+
264
+ def _ensure_initialized(self) -> None:
265
+ if self._initialized or self._init_attempted:
266
+ return
267
+ self._init_attempted = True
268
+
269
+ params: Dict[str, Any] = {
270
+ "protocolVersion": self._protocol_version,
271
+ # Match the MCP reference client's envelope shape (server-side validators often
272
+ # expect these keys to exist even when the values are null).
273
+ "capabilities": {
274
+ "experimental": None,
275
+ "sampling": None,
276
+ "elicitation": None,
277
+ "roots": None,
278
+ "tasks": None,
279
+ },
280
+ "clientInfo": {"name": self._client_name, "version": self._client_version or "0.0.0"},
281
+ }
282
+
283
+ try:
284
+ self._request_no_init(method="initialize", params=params)
285
+ # Best-effort "initialized" notification.
286
+ try:
287
+ self.notify(method="initialized", params={})
288
+ except Exception:
289
+ pass
290
+ self._initialized = True
291
+ except McpRpcError as e:
292
+ # Some non-conformant servers may not implement initialize; allow continuing.
293
+ if int(getattr(e, "code", 0)) == -32601:
294
+ self._initialized = True
295
+ return
296
+ raise
297
+
298
+ def notify(self, *, method: str, params: Optional[Dict[str, Any]] = None) -> None:
299
+ mid = str(method or "").strip()
300
+ if not mid:
301
+ raise ValueError("MCP notify requires a non-empty method")
302
+ payload: Dict[str, Any] = {"jsonrpc": "2.0", "method": mid}
303
+ if params is not None:
304
+ payload["params"] = params
305
+ self._send(payload)
306
+
307
+ def request(self, *, method: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
308
+ if str(method or "").strip() != "initialize":
309
+ self._ensure_initialized()
310
+ return self._request_no_init(method=method, params=params)
311
+
312
+ def list_tools(self, *, cursor: Optional[str] = None) -> List[Dict[str, Any]]:
313
+ params: Optional[Dict[str, Any]] = None
314
+ if cursor is not None:
315
+ params = {"cursor": str(cursor)}
316
+
317
+ result = self.request(method="tools/list", params=params)
318
+ tools = result.get("tools")
319
+ if not isinstance(tools, list):
320
+ raise McpProtocolError("MCP tools/list result missing tools list")
321
+ out: List[Dict[str, Any]] = []
322
+ for t in tools:
323
+ if isinstance(t, dict):
324
+ out.append(t)
325
+ return out
326
+
327
+ def call_tool(self, *, name: str, arguments: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
328
+ tool_name = str(name or "").strip()
329
+ if not tool_name:
330
+ raise ValueError("MCP tools/call requires a non-empty tool name")
331
+ args = dict(arguments or {})
332
+
333
+ result = self.request(method="tools/call", params={"name": tool_name, "arguments": args})
334
+ if not isinstance(result, dict):
335
+ raise McpProtocolError("MCP tools/call result must be an object")
336
+ return result
@@ -0,0 +1,164 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Dict, List, Optional, Protocol, Set
5
+
6
+ from .naming import namespaced_tool_name
7
+
8
+
9
+ def _first_non_empty_line(text: Optional[str]) -> str:
10
+ if not text:
11
+ return ""
12
+ for line in str(text).splitlines():
13
+ s = line.strip()
14
+ if s:
15
+ return s
16
+ return ""
17
+
18
+
19
+ def _normalize_one_line(text: Optional[str]) -> str:
20
+ return " ".join(str(text or "").split()).strip()
21
+
22
+
23
+ def _short_description(text: Optional[str], *, max_chars: int = 200) -> str:
24
+ one = _normalize_one_line(_first_non_empty_line(text))
25
+ if not one:
26
+ return ""
27
+ if len(one) <= max_chars:
28
+ return one
29
+ if max_chars <= 3:
30
+ return one[:max_chars]
31
+ return one[: max_chars - 3].rstrip() + "..."
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class McpServerInfo:
36
+ server_id: str
37
+ url: str
38
+ transport: str = "streamable_http"
39
+ version: Optional[str] = None
40
+
41
+
42
+ class McpToolClient(Protocol):
43
+ def list_tools(self, *, cursor: Optional[str] = None) -> List[Dict[str, Any]]: ...
44
+
45
+ def call_tool(self, *, name: str, arguments: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: ...
46
+
47
+ def close(self) -> None: ...
48
+
49
+
50
+ def _normalize_mcp_parameters(input_schema: Any) -> Dict[str, Any]:
51
+ """Convert an MCP inputSchema into AbstractCore's compact parameter map.
52
+
53
+ AbstractCore's tool prompts and native tool formatting treat a parameter as required
54
+ when its schema dict omits a `default`. Therefore, for MCP schemas:
55
+ - required properties => ensure `default` is absent
56
+ - optional properties => set `default` to None (unless a default is already provided)
57
+ """
58
+ if not isinstance(input_schema, dict):
59
+ return {}
60
+
61
+ props = input_schema.get("properties")
62
+ if not isinstance(props, dict):
63
+ return {}
64
+
65
+ required_raw = input_schema.get("required")
66
+ required: Set[str] = set()
67
+ if isinstance(required_raw, list):
68
+ required = {str(x) for x in required_raw if isinstance(x, str) and x.strip()}
69
+
70
+ out: Dict[str, Any] = {}
71
+ for key, schema in props.items():
72
+ if not isinstance(key, str) or not key.strip():
73
+ continue
74
+ meta: Dict[str, Any]
75
+ if isinstance(schema, dict):
76
+ meta = dict(schema)
77
+ else:
78
+ meta = {}
79
+
80
+ if key in required:
81
+ meta.pop("default", None)
82
+ else:
83
+ meta.setdefault("default", None)
84
+
85
+ out[key] = meta
86
+
87
+ return out
88
+
89
+
90
+ def mcp_tool_to_abstractcore_tool_spec(
91
+ tool: Dict[str, Any],
92
+ *,
93
+ server: McpServerInfo,
94
+ ) -> Dict[str, Any]:
95
+ """Convert a single MCP tool entry into an AbstractCore tool spec dict."""
96
+ raw_name = tool.get("name")
97
+ name = str(raw_name or "").strip()
98
+ if not name:
99
+ raise ValueError("MCP tool is missing a valid name")
100
+
101
+ raw_description = tool.get("description") or tool.get("title") or ""
102
+ description = _short_description(str(raw_description or ""), max_chars=200)
103
+ if not description:
104
+ description = _short_description(f"MCP tool '{name}'", max_chars=200) or f"MCP tool '{name}'"
105
+
106
+ parameters = _normalize_mcp_parameters(tool.get("inputSchema"))
107
+
108
+ return {
109
+ "name": namespaced_tool_name(server_id=server.server_id, tool_name=name),
110
+ "description": description,
111
+ "parameters": parameters,
112
+ "tags": ["mcp", f"mcp_server:{server.server_id}"],
113
+ "origin": {
114
+ "type": "mcp",
115
+ "server_id": server.server_id,
116
+ "tool_name": name,
117
+ "transport": server.transport,
118
+ "url": server.url,
119
+ "version": server.version,
120
+ },
121
+ }
122
+
123
+
124
+ class McpToolSource:
125
+ """Discover tools from an MCP server and return AbstractCore-compatible tool specs."""
126
+
127
+ def __init__(
128
+ self,
129
+ *,
130
+ server_id: str,
131
+ client: McpToolClient,
132
+ transport: str = "streamable_http",
133
+ origin_url: Optional[str] = None,
134
+ ) -> None:
135
+ sid = str(server_id or "").strip()
136
+ if not sid:
137
+ raise ValueError("McpToolSource requires a non-empty server_id")
138
+ transport_norm = str(transport or "").strip() or "streamable_http"
139
+
140
+ url = str(origin_url or "").strip() if origin_url else ""
141
+ if not url:
142
+ candidate = getattr(client, "url", None)
143
+ if isinstance(candidate, str) and candidate.strip():
144
+ url = candidate.strip()
145
+ if not url:
146
+ url = f"{transport_norm}://{sid}"
147
+
148
+ self._server = McpServerInfo(server_id=sid, url=url, transport=transport_norm)
149
+ self._client = client
150
+
151
+ @property
152
+ def server(self) -> McpServerInfo:
153
+ return self._server
154
+
155
+ def list_tool_specs(self) -> List[Dict[str, Any]]:
156
+ tools = self._client.list_tools()
157
+ specs: List[Dict[str, Any]] = []
158
+ for t in tools:
159
+ try:
160
+ specs.append(mcp_tool_to_abstractcore_tool_spec(t, server=self._server))
161
+ except Exception:
162
+ # Skip invalid tool entries; listing should be best-effort.
163
+ continue
164
+ return specs
@@ -5,14 +5,14 @@ Basic text processing capabilities built on top of AbstractCore,
5
5
  demonstrating how to leverage the core infrastructure for real-world tasks.
6
6
  """
7
7
 
8
- from .basic_summarizer import BasicSummarizer, SummaryStyle, SummaryLength
8
+ from .basic_summarizer import BasicSummarizer, SummaryStyle, SummaryLength, CompressionMode
9
9
  from .basic_extractor import BasicExtractor
10
10
  from .basic_judge import BasicJudge, JudgmentCriteria, Assessment, create_judge
11
11
  from .basic_deepsearch import BasicDeepSearch, ResearchReport, ResearchFinding, ResearchPlan, ResearchSubTask
12
12
  from .basic_intent import BasicIntentAnalyzer, IntentType, IntentDepth, IntentContext, IdentifiedIntent, IntentAnalysisOutput
13
13
 
14
14
  __all__ = [
15
- 'BasicSummarizer', 'SummaryStyle', 'SummaryLength',
15
+ 'BasicSummarizer', 'SummaryStyle', 'SummaryLength', 'CompressionMode',
16
16
  'BasicExtractor',
17
17
  'BasicJudge', 'JudgmentCriteria', 'Assessment', 'create_judge',
18
18
  'BasicDeepSearch', 'ResearchReport', 'ResearchFinding', 'ResearchPlan', 'ResearchSubTask',
@@ -757,7 +757,7 @@ Avoid generic terms like "qubit" alone (which returns lab instruments) - be spec
757
757
 
758
758
  try:
759
759
  logger.debug(f"🌐 Fetching content from URL {i+1}: {url}")
760
- content = fetch_url(url, timeout=15)
760
+ content = fetch_url(url, timeout=15, include_full_content=self.full_text_extraction)
761
761
 
762
762
  if "Error" in content or len(content) < 100:
763
763
  logger.debug(f"⚠️ Skipping URL due to fetch error or short content: {url}")