AbstractRuntime 0.2.0__py3-none-any.whl → 0.4.1__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.
- abstractruntime/__init__.py +83 -3
- abstractruntime/core/config.py +82 -2
- abstractruntime/core/event_keys.py +62 -0
- abstractruntime/core/models.py +17 -1
- abstractruntime/core/policy.py +74 -3
- abstractruntime/core/runtime.py +3334 -28
- abstractruntime/core/vars.py +103 -2
- abstractruntime/evidence/__init__.py +10 -0
- abstractruntime/evidence/recorder.py +325 -0
- abstractruntime/history_bundle.py +772 -0
- abstractruntime/integrations/abstractcore/__init__.py +6 -0
- abstractruntime/integrations/abstractcore/constants.py +19 -0
- abstractruntime/integrations/abstractcore/default_tools.py +258 -0
- abstractruntime/integrations/abstractcore/effect_handlers.py +2622 -32
- abstractruntime/integrations/abstractcore/embeddings_client.py +69 -0
- abstractruntime/integrations/abstractcore/factory.py +149 -16
- abstractruntime/integrations/abstractcore/llm_client.py +891 -55
- abstractruntime/integrations/abstractcore/mcp_worker.py +587 -0
- abstractruntime/integrations/abstractcore/observability.py +80 -0
- abstractruntime/integrations/abstractcore/session_attachments.py +946 -0
- abstractruntime/integrations/abstractcore/summarizer.py +154 -0
- abstractruntime/integrations/abstractcore/tool_executor.py +509 -31
- abstractruntime/integrations/abstractcore/workspace_scoped_tools.py +561 -0
- abstractruntime/integrations/abstractmemory/__init__.py +3 -0
- abstractruntime/integrations/abstractmemory/effect_handlers.py +946 -0
- abstractruntime/memory/__init__.py +21 -0
- abstractruntime/memory/active_context.py +751 -0
- abstractruntime/memory/active_memory.py +452 -0
- abstractruntime/memory/compaction.py +105 -0
- abstractruntime/memory/kg_packets.py +164 -0
- abstractruntime/memory/memact_composer.py +175 -0
- abstractruntime/memory/recall_levels.py +163 -0
- abstractruntime/memory/token_budget.py +86 -0
- abstractruntime/rendering/__init__.py +17 -0
- abstractruntime/rendering/agent_trace_report.py +256 -0
- abstractruntime/rendering/json_stringify.py +136 -0
- abstractruntime/scheduler/scheduler.py +93 -2
- abstractruntime/storage/__init__.py +7 -2
- abstractruntime/storage/artifacts.py +175 -32
- abstractruntime/storage/base.py +17 -1
- abstractruntime/storage/commands.py +339 -0
- abstractruntime/storage/in_memory.py +41 -1
- abstractruntime/storage/json_files.py +210 -14
- abstractruntime/storage/observable.py +136 -0
- abstractruntime/storage/offloading.py +433 -0
- abstractruntime/storage/sqlite.py +836 -0
- abstractruntime/visualflow_compiler/__init__.py +29 -0
- abstractruntime/visualflow_compiler/adapters/__init__.py +11 -0
- abstractruntime/visualflow_compiler/adapters/agent_adapter.py +126 -0
- abstractruntime/visualflow_compiler/adapters/context_adapter.py +109 -0
- abstractruntime/visualflow_compiler/adapters/control_adapter.py +615 -0
- abstractruntime/visualflow_compiler/adapters/effect_adapter.py +1051 -0
- abstractruntime/visualflow_compiler/adapters/event_adapter.py +307 -0
- abstractruntime/visualflow_compiler/adapters/function_adapter.py +97 -0
- abstractruntime/visualflow_compiler/adapters/memact_adapter.py +114 -0
- abstractruntime/visualflow_compiler/adapters/subflow_adapter.py +74 -0
- abstractruntime/visualflow_compiler/adapters/variable_adapter.py +316 -0
- abstractruntime/visualflow_compiler/compiler.py +3832 -0
- abstractruntime/visualflow_compiler/flow.py +247 -0
- abstractruntime/visualflow_compiler/visual/__init__.py +13 -0
- abstractruntime/visualflow_compiler/visual/agent_ids.py +29 -0
- abstractruntime/visualflow_compiler/visual/builtins.py +1376 -0
- abstractruntime/visualflow_compiler/visual/code_executor.py +214 -0
- abstractruntime/visualflow_compiler/visual/executor.py +2804 -0
- abstractruntime/visualflow_compiler/visual/models.py +211 -0
- abstractruntime/workflow_bundle/__init__.py +52 -0
- abstractruntime/workflow_bundle/models.py +236 -0
- abstractruntime/workflow_bundle/packer.py +317 -0
- abstractruntime/workflow_bundle/reader.py +87 -0
- abstractruntime/workflow_bundle/registry.py +587 -0
- abstractruntime-0.4.1.dist-info/METADATA +177 -0
- abstractruntime-0.4.1.dist-info/RECORD +86 -0
- abstractruntime-0.4.1.dist-info/entry_points.txt +2 -0
- abstractruntime-0.2.0.dist-info/METADATA +0 -163
- abstractruntime-0.2.0.dist-info/RECORD +0 -32
- {abstractruntime-0.2.0.dist-info → abstractruntime-0.4.1.dist-info}/WHEEL +0 -0
- {abstractruntime-0.2.0.dist-info → abstractruntime-0.4.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -13,6 +13,11 @@ pause until the host resumes with the tool results.
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
15
|
from dataclasses import asdict, is_dataclass
|
|
16
|
+
import inspect
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
import threading
|
|
20
|
+
import uuid
|
|
16
21
|
from typing import Any, Callable, Dict, List, Optional, Protocol, Sequence
|
|
17
22
|
|
|
18
23
|
from .logging import get_logger
|
|
@@ -24,6 +29,54 @@ class ToolExecutor(Protocol):
|
|
|
24
29
|
def execute(self, *, tool_calls: List[Dict[str, Any]]) -> Dict[str, Any]: ...
|
|
25
30
|
|
|
26
31
|
|
|
32
|
+
def _normalize_timeout_s(value: Optional[float]) -> Optional[float]:
|
|
33
|
+
if value is None:
|
|
34
|
+
return None
|
|
35
|
+
try:
|
|
36
|
+
f = float(value)
|
|
37
|
+
except Exception:
|
|
38
|
+
return None
|
|
39
|
+
# Contract: non-positive values are treated as "unlimited".
|
|
40
|
+
return None if f <= 0 else f
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _call_with_timeout(func: Callable[[], Any], *, timeout_s: Optional[float]) -> tuple[bool, Any, Optional[str]]:
|
|
44
|
+
"""Execute a callable with a best-effort timeout.
|
|
45
|
+
|
|
46
|
+
Important limitation (Python semantics): we cannot forcibly stop a running function
|
|
47
|
+
without process isolation. On timeout we return an error, but the underlying callable
|
|
48
|
+
may still finish later (daemon thread).
|
|
49
|
+
"""
|
|
50
|
+
timeout_s = _normalize_timeout_s(timeout_s)
|
|
51
|
+
if timeout_s is None:
|
|
52
|
+
try:
|
|
53
|
+
return True, func(), None
|
|
54
|
+
except Exception as e:
|
|
55
|
+
return False, None, str(e)
|
|
56
|
+
|
|
57
|
+
result: Dict[str, Any] = {"done": False, "ok": False, "value": None, "error": None}
|
|
58
|
+
|
|
59
|
+
def _runner() -> None:
|
|
60
|
+
try:
|
|
61
|
+
result["value"] = func()
|
|
62
|
+
result["ok"] = True
|
|
63
|
+
except Exception as e:
|
|
64
|
+
result["error"] = str(e)
|
|
65
|
+
result["ok"] = False
|
|
66
|
+
finally:
|
|
67
|
+
result["done"] = True
|
|
68
|
+
|
|
69
|
+
t = threading.Thread(target=_runner, daemon=True)
|
|
70
|
+
t.start()
|
|
71
|
+
t.join(timeout_s)
|
|
72
|
+
|
|
73
|
+
if not result.get("done", False):
|
|
74
|
+
return False, None, f"Tool execution timed out after {timeout_s}s"
|
|
75
|
+
if result.get("ok", False):
|
|
76
|
+
return True, result.get("value"), None
|
|
77
|
+
return False, None, str(result.get("error") or "Tool execution failed")
|
|
78
|
+
|
|
79
|
+
|
|
27
80
|
class MappingToolExecutor:
|
|
28
81
|
"""Executes tool calls using an explicit {tool_name -> callable} mapping.
|
|
29
82
|
|
|
@@ -31,11 +84,12 @@ class MappingToolExecutor:
|
|
|
31
84
|
host/runtime process and is never persisted inside RunState.
|
|
32
85
|
"""
|
|
33
86
|
|
|
34
|
-
def __init__(self, tool_map: Dict[str, Callable[..., Any]]):
|
|
87
|
+
def __init__(self, tool_map: Dict[str, Callable[..., Any]], *, timeout_s: Optional[float] = None):
|
|
35
88
|
self._tool_map = dict(tool_map)
|
|
89
|
+
self._timeout_s = _normalize_timeout_s(timeout_s)
|
|
36
90
|
|
|
37
91
|
@classmethod
|
|
38
|
-
def from_tools(cls, tools: Sequence[Callable[..., Any]]) -> "MappingToolExecutor":
|
|
92
|
+
def from_tools(cls, tools: Sequence[Callable[..., Any]], *, timeout_s: Optional[float] = None) -> "MappingToolExecutor":
|
|
39
93
|
tool_map: Dict[str, Callable[..., Any]] = {}
|
|
40
94
|
for t in tools:
|
|
41
95
|
tool_def = getattr(t, "_tool_definition", None)
|
|
@@ -55,21 +109,216 @@ class MappingToolExecutor:
|
|
|
55
109
|
|
|
56
110
|
tool_map[name] = func
|
|
57
111
|
|
|
58
|
-
return cls(tool_map)
|
|
112
|
+
return cls(tool_map, timeout_s=timeout_s)
|
|
113
|
+
|
|
114
|
+
def set_timeout_s(self, timeout_s: Optional[float]) -> None:
|
|
115
|
+
self._timeout_s = _normalize_timeout_s(timeout_s)
|
|
59
116
|
|
|
60
117
|
def execute(self, *, tool_calls: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
61
118
|
results: List[Dict[str, Any]] = []
|
|
62
119
|
|
|
120
|
+
def _loads_dict_like(value: Any) -> Optional[Dict[str, Any]]:
|
|
121
|
+
if value is None:
|
|
122
|
+
return None
|
|
123
|
+
if isinstance(value, dict):
|
|
124
|
+
return dict(value)
|
|
125
|
+
if not isinstance(value, str):
|
|
126
|
+
return None
|
|
127
|
+
text = value.strip()
|
|
128
|
+
if not text:
|
|
129
|
+
return None
|
|
130
|
+
try:
|
|
131
|
+
parsed = json.loads(text)
|
|
132
|
+
except Exception:
|
|
133
|
+
return None
|
|
134
|
+
return parsed if isinstance(parsed, dict) else None
|
|
135
|
+
|
|
136
|
+
def _unwrap_wrapper_args(kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
137
|
+
"""Unwrap common wrapper shapes like {"name":..., "arguments":{...}}.
|
|
138
|
+
|
|
139
|
+
Some models emit tool kwargs wrapped inside an "arguments" object and may
|
|
140
|
+
mistakenly place real kwargs alongside wrapper fields. We unwrap and merge
|
|
141
|
+
(inner args take precedence).
|
|
142
|
+
"""
|
|
143
|
+
current: Dict[str, Any] = dict(kwargs or {})
|
|
144
|
+
wrapper_keys = {"name", "arguments", "call_id", "id"}
|
|
145
|
+
for _ in range(4):
|
|
146
|
+
inner = current.get("arguments")
|
|
147
|
+
inner_dict = _loads_dict_like(inner)
|
|
148
|
+
if not isinstance(inner_dict, dict):
|
|
149
|
+
break
|
|
150
|
+
extras = {k: v for k, v in current.items() if k not in wrapper_keys}
|
|
151
|
+
merged = dict(inner_dict)
|
|
152
|
+
for k, v in extras.items():
|
|
153
|
+
merged.setdefault(k, v)
|
|
154
|
+
current = merged
|
|
155
|
+
return current
|
|
156
|
+
|
|
157
|
+
def _filter_kwargs(func: Callable[..., Any], kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
158
|
+
"""Best-effort filtering of unexpected kwargs for callables without **kwargs."""
|
|
159
|
+
try:
|
|
160
|
+
sig = inspect.signature(func)
|
|
161
|
+
except Exception:
|
|
162
|
+
return kwargs
|
|
163
|
+
|
|
164
|
+
params = list(sig.parameters.values())
|
|
165
|
+
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params):
|
|
166
|
+
return kwargs
|
|
167
|
+
|
|
168
|
+
allowed = {
|
|
169
|
+
p.name
|
|
170
|
+
for p in params
|
|
171
|
+
if p.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY)
|
|
172
|
+
}
|
|
173
|
+
return {k: v for k, v in kwargs.items() if k in allowed}
|
|
174
|
+
|
|
175
|
+
def _normalize_key(key: str) -> str:
|
|
176
|
+
# Lowercase and remove common separators so `file_path`, `filePath`,
|
|
177
|
+
# `file-path`, `file path` all normalize to the same token.
|
|
178
|
+
return re.sub(r"[\s_\-]+", "", str(key or "").strip().lower())
|
|
179
|
+
|
|
180
|
+
_SYNONYM_ALIASES: Dict[str, List[str]] = {
|
|
181
|
+
# Common semantic drift across many tools
|
|
182
|
+
"path": ["file_path", "directory_path", "path"],
|
|
183
|
+
# Common CLI/media naming drift
|
|
184
|
+
"filename": ["file_path"],
|
|
185
|
+
"filepath": ["file_path"],
|
|
186
|
+
"dir": ["directory_path", "path"],
|
|
187
|
+
"directory": ["directory_path", "path"],
|
|
188
|
+
"folder": ["directory_path", "path"],
|
|
189
|
+
"query": ["pattern", "query"],
|
|
190
|
+
"regex": ["pattern", "regex"],
|
|
191
|
+
# Range drift (used by multiple tools)
|
|
192
|
+
"start": ["start_line", "start"],
|
|
193
|
+
"end": ["end_line", "end"],
|
|
194
|
+
"startlineoneindexed": ["start_line"],
|
|
195
|
+
"endlineoneindexedinclusive": ["end_line"],
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
def _canonicalize_kwargs(func: Callable[..., Any], kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
199
|
+
"""Best-effort canonicalization of kwarg names.
|
|
200
|
+
|
|
201
|
+
Strategy:
|
|
202
|
+
- Unwrap common wrapper shapes (nested `arguments`)
|
|
203
|
+
- Map keys by normalized form (case + separators)
|
|
204
|
+
- Apply a small, tool-agnostic synonym table (path/query/start/end)
|
|
205
|
+
- Finally, filter unexpected kwargs for callables without **kwargs
|
|
206
|
+
"""
|
|
207
|
+
if not isinstance(kwargs, dict) or not kwargs:
|
|
208
|
+
return {}
|
|
209
|
+
|
|
210
|
+
# 1) Unwrap wrapper shapes early.
|
|
211
|
+
current = _unwrap_wrapper_args(kwargs)
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
sig = inspect.signature(func)
|
|
215
|
+
except Exception:
|
|
216
|
+
return current
|
|
217
|
+
|
|
218
|
+
params = list(sig.parameters.values())
|
|
219
|
+
allowed_names = {
|
|
220
|
+
p.name
|
|
221
|
+
for p in params
|
|
222
|
+
if p.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY)
|
|
223
|
+
}
|
|
224
|
+
norm_to_param = { _normalize_key(n): n for n in allowed_names }
|
|
225
|
+
|
|
226
|
+
out: Dict[str, Any] = dict(current)
|
|
227
|
+
|
|
228
|
+
# 2) Normalized (morphological) key mapping.
|
|
229
|
+
for k in list(out.keys()):
|
|
230
|
+
if k in allowed_names:
|
|
231
|
+
continue
|
|
232
|
+
nk = _normalize_key(k)
|
|
233
|
+
target = norm_to_param.get(nk)
|
|
234
|
+
if target and target not in out:
|
|
235
|
+
out[target] = out.pop(k)
|
|
236
|
+
|
|
237
|
+
# 3) Synonym mapping (semantic).
|
|
238
|
+
for k in list(out.keys()):
|
|
239
|
+
if k in allowed_names:
|
|
240
|
+
continue
|
|
241
|
+
nk = _normalize_key(k)
|
|
242
|
+
candidates = _SYNONYM_ALIASES.get(nk, [])
|
|
243
|
+
for cand in candidates:
|
|
244
|
+
if cand in allowed_names and cand not in out:
|
|
245
|
+
out[cand] = out.pop(k)
|
|
246
|
+
break
|
|
247
|
+
|
|
248
|
+
# 4) Filter unexpected kwargs when callable doesn't accept **kwargs.
|
|
249
|
+
return _filter_kwargs(func, out)
|
|
250
|
+
|
|
251
|
+
def _error_from_output(value: Any) -> Optional[str]:
|
|
252
|
+
"""Detect tool failures reported as string outputs (instead of exceptions)."""
|
|
253
|
+
# Structured tool outputs may explicitly report failure without raising.
|
|
254
|
+
# Only treat as error when the tool declares failure.
|
|
255
|
+
if isinstance(value, dict):
|
|
256
|
+
success = value.get("success")
|
|
257
|
+
ok = value.get("ok")
|
|
258
|
+
if success is False or ok is False:
|
|
259
|
+
err = value.get("error") or value.get("message") or "Tool reported failure"
|
|
260
|
+
text = str(err).strip()
|
|
261
|
+
return text or "Tool reported failure"
|
|
262
|
+
return None
|
|
263
|
+
if not isinstance(value, str):
|
|
264
|
+
return None
|
|
265
|
+
text = value.strip()
|
|
266
|
+
if not text:
|
|
267
|
+
return None
|
|
268
|
+
if text.startswith("Error:"):
|
|
269
|
+
cleaned = text[len("Error:") :].strip()
|
|
270
|
+
return cleaned or text
|
|
271
|
+
if text.startswith(("❌", "🚫", "⏰")):
|
|
272
|
+
cleaned = text.lstrip("❌🚫⏰").strip()
|
|
273
|
+
if cleaned.startswith("Error:"):
|
|
274
|
+
cleaned = cleaned[len("Error:") :].strip()
|
|
275
|
+
return cleaned or text
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
def _append_result(*, call_id: str, runtime_call_id: Optional[str], name: str, output: Any) -> None:
|
|
279
|
+
error = _error_from_output(output)
|
|
280
|
+
if error is not None:
|
|
281
|
+
# Preserve structured outputs for provenance/evidence. For string-only error outputs
|
|
282
|
+
# (the historical convention), keep output empty and store the message in `error`.
|
|
283
|
+
output_json = None if isinstance(output, str) else _jsonable(output)
|
|
284
|
+
results.append(
|
|
285
|
+
{
|
|
286
|
+
"call_id": call_id,
|
|
287
|
+
"runtime_call_id": runtime_call_id,
|
|
288
|
+
"name": name,
|
|
289
|
+
"success": False,
|
|
290
|
+
"output": output_json,
|
|
291
|
+
"error": error,
|
|
292
|
+
}
|
|
293
|
+
)
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
results.append(
|
|
297
|
+
{
|
|
298
|
+
"call_id": call_id,
|
|
299
|
+
"runtime_call_id": runtime_call_id,
|
|
300
|
+
"name": name,
|
|
301
|
+
"success": True,
|
|
302
|
+
"output": _jsonable(output),
|
|
303
|
+
"error": None,
|
|
304
|
+
}
|
|
305
|
+
)
|
|
306
|
+
|
|
63
307
|
for tc in tool_calls:
|
|
64
308
|
name = str(tc.get("name", "") or "")
|
|
65
|
-
|
|
309
|
+
raw_arguments = tc.get("arguments") or {}
|
|
310
|
+
arguments = dict(raw_arguments) if isinstance(raw_arguments, dict) else (_loads_dict_like(raw_arguments) or {})
|
|
66
311
|
call_id = str(tc.get("call_id") or "")
|
|
312
|
+
runtime_call_id = tc.get("runtime_call_id")
|
|
313
|
+
runtime_call_id_str = str(runtime_call_id).strip() if runtime_call_id is not None else ""
|
|
314
|
+
runtime_call_id_out = runtime_call_id_str or None
|
|
67
315
|
|
|
68
316
|
func = self._tool_map.get(name)
|
|
69
317
|
if func is None:
|
|
70
318
|
results.append(
|
|
71
319
|
{
|
|
72
320
|
"call_id": call_id,
|
|
321
|
+
"runtime_call_id": runtime_call_id_out,
|
|
73
322
|
"name": name,
|
|
74
323
|
"success": False,
|
|
75
324
|
"output": None,
|
|
@@ -78,25 +327,30 @@ class MappingToolExecutor:
|
|
|
78
327
|
)
|
|
79
328
|
continue
|
|
80
329
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
330
|
+
arguments = _canonicalize_kwargs(func, arguments)
|
|
331
|
+
|
|
332
|
+
def _invoke() -> Any:
|
|
333
|
+
try:
|
|
334
|
+
return func(**arguments)
|
|
335
|
+
except TypeError:
|
|
336
|
+
# Retry once with sanitized kwargs for common wrapper/extra-arg failures.
|
|
337
|
+
filtered = _canonicalize_kwargs(func, arguments)
|
|
338
|
+
if filtered != arguments:
|
|
339
|
+
return func(**filtered)
|
|
340
|
+
raise
|
|
341
|
+
|
|
342
|
+
ok, output, err = _call_with_timeout(_invoke, timeout_s=self._timeout_s)
|
|
343
|
+
if ok:
|
|
344
|
+
_append_result(call_id=call_id, runtime_call_id=runtime_call_id_out, name=name, output=output)
|
|
345
|
+
else:
|
|
93
346
|
results.append(
|
|
94
347
|
{
|
|
95
348
|
"call_id": call_id,
|
|
349
|
+
"runtime_call_id": runtime_call_id_out,
|
|
96
350
|
"name": name,
|
|
97
351
|
"success": False,
|
|
98
352
|
"output": None,
|
|
99
|
-
"error": str(
|
|
353
|
+
"error": str(err or "Tool execution failed"),
|
|
100
354
|
}
|
|
101
355
|
)
|
|
102
356
|
|
|
@@ -129,29 +383,55 @@ def _jsonable(value: Any) -> Any:
|
|
|
129
383
|
class AbstractCoreToolExecutor:
|
|
130
384
|
"""Executes tool calls using AbstractCore's global tool registry."""
|
|
131
385
|
|
|
386
|
+
def __init__(self, *, timeout_s: Optional[float] = None):
|
|
387
|
+
self._timeout_s = _normalize_timeout_s(timeout_s)
|
|
388
|
+
|
|
389
|
+
def set_timeout_s(self, timeout_s: Optional[float]) -> None:
|
|
390
|
+
self._timeout_s = _normalize_timeout_s(timeout_s)
|
|
391
|
+
|
|
132
392
|
def execute(self, *, tool_calls: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
133
393
|
from abstractcore.tools.core import ToolCall
|
|
134
|
-
from abstractcore.tools.registry import
|
|
394
|
+
from abstractcore.tools.registry import execute_tool
|
|
135
395
|
|
|
136
|
-
calls = [
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
396
|
+
calls: list[ToolCall] = []
|
|
397
|
+
runtime_call_ids: list[Optional[str]] = []
|
|
398
|
+
for tc in tool_calls:
|
|
399
|
+
calls.append(
|
|
400
|
+
ToolCall(
|
|
401
|
+
name=str(tc.get("name")),
|
|
402
|
+
arguments=dict(tc.get("arguments") or {}),
|
|
403
|
+
call_id=tc.get("call_id"),
|
|
404
|
+
)
|
|
141
405
|
)
|
|
142
|
-
|
|
143
|
-
|
|
406
|
+
runtime_call_id = tc.get("runtime_call_id")
|
|
407
|
+
runtime_call_id_str = str(runtime_call_id).strip() if runtime_call_id is not None else ""
|
|
408
|
+
runtime_call_ids.append(runtime_call_id_str or None)
|
|
144
409
|
|
|
145
|
-
results = execute_tools(calls)
|
|
146
410
|
normalized = []
|
|
147
|
-
for call,
|
|
411
|
+
for call, runtime_call_id in zip(calls, runtime_call_ids):
|
|
412
|
+
ok, out, err = _call_with_timeout(lambda c=call: execute_tool(c), timeout_s=self._timeout_s)
|
|
413
|
+
if ok:
|
|
414
|
+
r = out
|
|
415
|
+
normalized.append(
|
|
416
|
+
{
|
|
417
|
+
"call_id": getattr(r, "call_id", "") if r is not None else "",
|
|
418
|
+
"runtime_call_id": runtime_call_id,
|
|
419
|
+
"name": getattr(call, "name", ""),
|
|
420
|
+
"success": bool(getattr(r, "success", False)) if r is not None else True,
|
|
421
|
+
"output": _jsonable(getattr(r, "output", None)) if r is not None else None,
|
|
422
|
+
"error": getattr(r, "error", None) if r is not None else None,
|
|
423
|
+
}
|
|
424
|
+
)
|
|
425
|
+
continue
|
|
426
|
+
|
|
148
427
|
normalized.append(
|
|
149
428
|
{
|
|
150
|
-
"call_id": getattr(
|
|
429
|
+
"call_id": str(getattr(call, "call_id", "") or ""),
|
|
430
|
+
"runtime_call_id": runtime_call_id,
|
|
151
431
|
"name": getattr(call, "name", ""),
|
|
152
|
-
"success":
|
|
153
|
-
"output":
|
|
154
|
-
"error":
|
|
432
|
+
"success": False,
|
|
433
|
+
"output": None,
|
|
434
|
+
"error": str(err or "Tool execution failed"),
|
|
155
435
|
}
|
|
156
436
|
)
|
|
157
437
|
|
|
@@ -166,3 +446,201 @@ class PassthroughToolExecutor:
|
|
|
166
446
|
|
|
167
447
|
def execute(self, *, tool_calls: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
168
448
|
return {"mode": self._mode, "tool_calls": _jsonable(tool_calls)}
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _mcp_result_to_output(result: Any) -> Any:
|
|
452
|
+
if not isinstance(result, dict):
|
|
453
|
+
return _jsonable(result)
|
|
454
|
+
|
|
455
|
+
content = result.get("content")
|
|
456
|
+
if isinstance(content, list):
|
|
457
|
+
texts: list[str] = []
|
|
458
|
+
for item in content:
|
|
459
|
+
if not isinstance(item, dict):
|
|
460
|
+
continue
|
|
461
|
+
if item.get("type") != "text":
|
|
462
|
+
continue
|
|
463
|
+
text = item.get("text")
|
|
464
|
+
if isinstance(text, str) and text.strip():
|
|
465
|
+
texts.append(text.strip())
|
|
466
|
+
if texts:
|
|
467
|
+
joined = "\n".join(texts).strip()
|
|
468
|
+
if joined:
|
|
469
|
+
try:
|
|
470
|
+
return _jsonable(json.loads(joined))
|
|
471
|
+
except Exception:
|
|
472
|
+
return joined
|
|
473
|
+
|
|
474
|
+
return _jsonable(result)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _mcp_result_to_error(result: Any) -> Optional[str]:
|
|
478
|
+
if not isinstance(result, dict):
|
|
479
|
+
return None
|
|
480
|
+
output = _mcp_result_to_output(result)
|
|
481
|
+
|
|
482
|
+
# MCP-native error flag.
|
|
483
|
+
if result.get("isError") is True:
|
|
484
|
+
if isinstance(output, str) and output.strip():
|
|
485
|
+
return output.strip()
|
|
486
|
+
return "MCP tool call reported error"
|
|
487
|
+
|
|
488
|
+
# Some real MCP servers return error strings inside content while leaving `isError=false`.
|
|
489
|
+
# Match the local executor's convention for string error outputs.
|
|
490
|
+
if isinstance(output, str):
|
|
491
|
+
text = output.strip()
|
|
492
|
+
if not text:
|
|
493
|
+
return None
|
|
494
|
+
if text.startswith("Error:"):
|
|
495
|
+
cleaned = text[len("Error:") :].strip()
|
|
496
|
+
return cleaned or text
|
|
497
|
+
if text.startswith(("❌", "🚫", "⏰")):
|
|
498
|
+
cleaned = text.lstrip("❌🚫⏰").strip()
|
|
499
|
+
if cleaned.startswith("Error:"):
|
|
500
|
+
cleaned = cleaned[len("Error:") :].strip()
|
|
501
|
+
return cleaned or text
|
|
502
|
+
if text.lower().startswith("traceback"):
|
|
503
|
+
return text
|
|
504
|
+
return None
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
class McpToolExecutor:
|
|
508
|
+
"""Executes tool calls remotely via an MCP server (Streamable HTTP / JSON-RPC)."""
|
|
509
|
+
|
|
510
|
+
def __init__(
|
|
511
|
+
self,
|
|
512
|
+
*,
|
|
513
|
+
server_id: str,
|
|
514
|
+
mcp_url: str,
|
|
515
|
+
timeout_s: Optional[float] = 30.0,
|
|
516
|
+
mcp_client: Optional[Any] = None,
|
|
517
|
+
):
|
|
518
|
+
self._server_id = str(server_id or "").strip()
|
|
519
|
+
if not self._server_id:
|
|
520
|
+
raise ValueError("McpToolExecutor requires a non-empty server_id")
|
|
521
|
+
self._mcp_url = str(mcp_url or "").strip()
|
|
522
|
+
if not self._mcp_url:
|
|
523
|
+
raise ValueError("McpToolExecutor requires a non-empty mcp_url")
|
|
524
|
+
self._timeout_s = _normalize_timeout_s(timeout_s)
|
|
525
|
+
self._mcp_client = mcp_client
|
|
526
|
+
|
|
527
|
+
def execute(self, *, tool_calls: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
528
|
+
from abstractcore.mcp import McpClient, parse_namespaced_tool_name
|
|
529
|
+
|
|
530
|
+
results: List[Dict[str, Any]] = []
|
|
531
|
+
client = self._mcp_client or McpClient(url=self._mcp_url, timeout_s=self._timeout_s)
|
|
532
|
+
close_client = self._mcp_client is None
|
|
533
|
+
try:
|
|
534
|
+
for tc in tool_calls:
|
|
535
|
+
name = str(tc.get("name", "") or "")
|
|
536
|
+
call_id = str(tc.get("call_id") or "")
|
|
537
|
+
runtime_call_id = tc.get("runtime_call_id")
|
|
538
|
+
runtime_call_id_str = str(runtime_call_id).strip() if runtime_call_id is not None else ""
|
|
539
|
+
runtime_call_id_out = runtime_call_id_str or None
|
|
540
|
+
raw_arguments = tc.get("arguments") or {}
|
|
541
|
+
arguments = dict(raw_arguments) if isinstance(raw_arguments, dict) else {}
|
|
542
|
+
|
|
543
|
+
remote_name = name
|
|
544
|
+
parsed = parse_namespaced_tool_name(name)
|
|
545
|
+
if parsed is not None:
|
|
546
|
+
server_id, tool_name = parsed
|
|
547
|
+
if server_id != self._server_id:
|
|
548
|
+
results.append(
|
|
549
|
+
{
|
|
550
|
+
"call_id": call_id,
|
|
551
|
+
"runtime_call_id": runtime_call_id_out,
|
|
552
|
+
"name": name,
|
|
553
|
+
"success": False,
|
|
554
|
+
"output": None,
|
|
555
|
+
"error": f"MCP tool '{name}' targets server '{server_id}', expected '{self._server_id}'",
|
|
556
|
+
}
|
|
557
|
+
)
|
|
558
|
+
continue
|
|
559
|
+
remote_name = tool_name
|
|
560
|
+
|
|
561
|
+
try:
|
|
562
|
+
mcp_result = client.call_tool(name=remote_name, arguments=arguments)
|
|
563
|
+
err = _mcp_result_to_error(mcp_result)
|
|
564
|
+
if err is not None:
|
|
565
|
+
results.append(
|
|
566
|
+
{
|
|
567
|
+
"call_id": call_id,
|
|
568
|
+
"runtime_call_id": runtime_call_id_out,
|
|
569
|
+
"name": name,
|
|
570
|
+
"success": False,
|
|
571
|
+
"output": None,
|
|
572
|
+
"error": err,
|
|
573
|
+
}
|
|
574
|
+
)
|
|
575
|
+
continue
|
|
576
|
+
results.append(
|
|
577
|
+
{
|
|
578
|
+
"call_id": call_id,
|
|
579
|
+
"runtime_call_id": runtime_call_id_out,
|
|
580
|
+
"name": name,
|
|
581
|
+
"success": True,
|
|
582
|
+
"output": _mcp_result_to_output(mcp_result),
|
|
583
|
+
"error": None,
|
|
584
|
+
}
|
|
585
|
+
)
|
|
586
|
+
except Exception as e:
|
|
587
|
+
results.append(
|
|
588
|
+
{
|
|
589
|
+
"call_id": call_id,
|
|
590
|
+
"runtime_call_id": runtime_call_id_out,
|
|
591
|
+
"name": name,
|
|
592
|
+
"success": False,
|
|
593
|
+
"output": None,
|
|
594
|
+
"error": str(e),
|
|
595
|
+
}
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
finally:
|
|
599
|
+
if close_client:
|
|
600
|
+
try:
|
|
601
|
+
client.close()
|
|
602
|
+
except Exception:
|
|
603
|
+
pass
|
|
604
|
+
|
|
605
|
+
return {"mode": "executed", "results": results}
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
class DelegatingMcpToolExecutor:
|
|
609
|
+
"""Delegates tool calls to an MCP server by returning a durable JOB wait payload.
|
|
610
|
+
|
|
611
|
+
This executor does not execute tools directly; it packages the tool calls plus
|
|
612
|
+
MCP endpoint metadata into a `WAITING` state so an external worker can execute
|
|
613
|
+
them and resume the run with results.
|
|
614
|
+
"""
|
|
615
|
+
|
|
616
|
+
def __init__(
|
|
617
|
+
self,
|
|
618
|
+
*,
|
|
619
|
+
server_id: str,
|
|
620
|
+
mcp_url: str,
|
|
621
|
+
transport: str = "streamable_http",
|
|
622
|
+
wait_key_factory: Optional[Callable[[], str]] = None,
|
|
623
|
+
):
|
|
624
|
+
self._server_id = str(server_id or "").strip()
|
|
625
|
+
if not self._server_id:
|
|
626
|
+
raise ValueError("DelegatingMcpToolExecutor requires a non-empty server_id")
|
|
627
|
+
self._mcp_url = str(mcp_url or "").strip()
|
|
628
|
+
if not self._mcp_url:
|
|
629
|
+
raise ValueError("DelegatingMcpToolExecutor requires a non-empty mcp_url")
|
|
630
|
+
self._transport = str(transport or "").strip() or "streamable_http"
|
|
631
|
+
self._wait_key_factory = wait_key_factory or (lambda: f"mcp_job:{uuid.uuid4().hex}")
|
|
632
|
+
|
|
633
|
+
def execute(self, *, tool_calls: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
634
|
+
return {
|
|
635
|
+
"mode": "delegated",
|
|
636
|
+
"wait_reason": "job",
|
|
637
|
+
"wait_key": self._wait_key_factory(),
|
|
638
|
+
"tool_calls": _jsonable(tool_calls),
|
|
639
|
+
"details": {
|
|
640
|
+
"protocol": "mcp",
|
|
641
|
+
"transport": self._transport,
|
|
642
|
+
"url": self._mcp_url,
|
|
643
|
+
"server_id": self._server_id,
|
|
644
|
+
"tool_name_prefix": f"mcp::{self._server_id}::",
|
|
645
|
+
},
|
|
646
|
+
}
|