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.
Files changed (77) hide show
  1. abstractruntime/__init__.py +83 -3
  2. abstractruntime/core/config.py +82 -2
  3. abstractruntime/core/event_keys.py +62 -0
  4. abstractruntime/core/models.py +17 -1
  5. abstractruntime/core/policy.py +74 -3
  6. abstractruntime/core/runtime.py +3334 -28
  7. abstractruntime/core/vars.py +103 -2
  8. abstractruntime/evidence/__init__.py +10 -0
  9. abstractruntime/evidence/recorder.py +325 -0
  10. abstractruntime/history_bundle.py +772 -0
  11. abstractruntime/integrations/abstractcore/__init__.py +6 -0
  12. abstractruntime/integrations/abstractcore/constants.py +19 -0
  13. abstractruntime/integrations/abstractcore/default_tools.py +258 -0
  14. abstractruntime/integrations/abstractcore/effect_handlers.py +2622 -32
  15. abstractruntime/integrations/abstractcore/embeddings_client.py +69 -0
  16. abstractruntime/integrations/abstractcore/factory.py +149 -16
  17. abstractruntime/integrations/abstractcore/llm_client.py +891 -55
  18. abstractruntime/integrations/abstractcore/mcp_worker.py +587 -0
  19. abstractruntime/integrations/abstractcore/observability.py +80 -0
  20. abstractruntime/integrations/abstractcore/session_attachments.py +946 -0
  21. abstractruntime/integrations/abstractcore/summarizer.py +154 -0
  22. abstractruntime/integrations/abstractcore/tool_executor.py +509 -31
  23. abstractruntime/integrations/abstractcore/workspace_scoped_tools.py +561 -0
  24. abstractruntime/integrations/abstractmemory/__init__.py +3 -0
  25. abstractruntime/integrations/abstractmemory/effect_handlers.py +946 -0
  26. abstractruntime/memory/__init__.py +21 -0
  27. abstractruntime/memory/active_context.py +751 -0
  28. abstractruntime/memory/active_memory.py +452 -0
  29. abstractruntime/memory/compaction.py +105 -0
  30. abstractruntime/memory/kg_packets.py +164 -0
  31. abstractruntime/memory/memact_composer.py +175 -0
  32. abstractruntime/memory/recall_levels.py +163 -0
  33. abstractruntime/memory/token_budget.py +86 -0
  34. abstractruntime/rendering/__init__.py +17 -0
  35. abstractruntime/rendering/agent_trace_report.py +256 -0
  36. abstractruntime/rendering/json_stringify.py +136 -0
  37. abstractruntime/scheduler/scheduler.py +93 -2
  38. abstractruntime/storage/__init__.py +7 -2
  39. abstractruntime/storage/artifacts.py +175 -32
  40. abstractruntime/storage/base.py +17 -1
  41. abstractruntime/storage/commands.py +339 -0
  42. abstractruntime/storage/in_memory.py +41 -1
  43. abstractruntime/storage/json_files.py +210 -14
  44. abstractruntime/storage/observable.py +136 -0
  45. abstractruntime/storage/offloading.py +433 -0
  46. abstractruntime/storage/sqlite.py +836 -0
  47. abstractruntime/visualflow_compiler/__init__.py +29 -0
  48. abstractruntime/visualflow_compiler/adapters/__init__.py +11 -0
  49. abstractruntime/visualflow_compiler/adapters/agent_adapter.py +126 -0
  50. abstractruntime/visualflow_compiler/adapters/context_adapter.py +109 -0
  51. abstractruntime/visualflow_compiler/adapters/control_adapter.py +615 -0
  52. abstractruntime/visualflow_compiler/adapters/effect_adapter.py +1051 -0
  53. abstractruntime/visualflow_compiler/adapters/event_adapter.py +307 -0
  54. abstractruntime/visualflow_compiler/adapters/function_adapter.py +97 -0
  55. abstractruntime/visualflow_compiler/adapters/memact_adapter.py +114 -0
  56. abstractruntime/visualflow_compiler/adapters/subflow_adapter.py +74 -0
  57. abstractruntime/visualflow_compiler/adapters/variable_adapter.py +316 -0
  58. abstractruntime/visualflow_compiler/compiler.py +3832 -0
  59. abstractruntime/visualflow_compiler/flow.py +247 -0
  60. abstractruntime/visualflow_compiler/visual/__init__.py +13 -0
  61. abstractruntime/visualflow_compiler/visual/agent_ids.py +29 -0
  62. abstractruntime/visualflow_compiler/visual/builtins.py +1376 -0
  63. abstractruntime/visualflow_compiler/visual/code_executor.py +214 -0
  64. abstractruntime/visualflow_compiler/visual/executor.py +2804 -0
  65. abstractruntime/visualflow_compiler/visual/models.py +211 -0
  66. abstractruntime/workflow_bundle/__init__.py +52 -0
  67. abstractruntime/workflow_bundle/models.py +236 -0
  68. abstractruntime/workflow_bundle/packer.py +317 -0
  69. abstractruntime/workflow_bundle/reader.py +87 -0
  70. abstractruntime/workflow_bundle/registry.py +587 -0
  71. abstractruntime-0.4.1.dist-info/METADATA +177 -0
  72. abstractruntime-0.4.1.dist-info/RECORD +86 -0
  73. abstractruntime-0.4.1.dist-info/entry_points.txt +2 -0
  74. abstractruntime-0.2.0.dist-info/METADATA +0 -163
  75. abstractruntime-0.2.0.dist-info/RECORD +0 -32
  76. {abstractruntime-0.2.0.dist-info → abstractruntime-0.4.1.dist-info}/WHEEL +0 -0
  77. {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
- arguments = dict(tc.get("arguments") or {})
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
- try:
82
- output = func(**arguments)
83
- results.append(
84
- {
85
- "call_id": call_id,
86
- "name": name,
87
- "success": True,
88
- "output": _jsonable(output),
89
- "error": None,
90
- }
91
- )
92
- except Exception as e:
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(e),
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 execute_tools
394
+ from abstractcore.tools.registry import execute_tool
135
395
 
136
- calls = [
137
- ToolCall(
138
- name=str(tc.get("name")),
139
- arguments=dict(tc.get("arguments") or {}),
140
- call_id=tc.get("call_id"),
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
- for tc in tool_calls
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, r in zip(calls, results):
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(r, "call_id", ""),
429
+ "call_id": str(getattr(call, "call_id", "") or ""),
430
+ "runtime_call_id": runtime_call_id,
151
431
  "name": getattr(call, "name", ""),
152
- "success": bool(getattr(r, "success", False)),
153
- "output": _jsonable(getattr(r, "output", None)),
154
- "error": getattr(r, "error", None),
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
+ }