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
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from importlib import metadata
|
|
10
|
+
from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Set, Tuple
|
|
11
|
+
|
|
12
|
+
from .default_tools import get_default_toolsets
|
|
13
|
+
from .tool_executor import MappingToolExecutor
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _ansi(enabled: bool, code: str) -> str:
|
|
17
|
+
if not enabled:
|
|
18
|
+
return ""
|
|
19
|
+
return f"\033[{code}m"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _truncate(text: str, *, limit: int) -> str:
|
|
23
|
+
s = "" if text is None else str(text)
|
|
24
|
+
if limit <= 0 or len(s) <= limit:
|
|
25
|
+
return s
|
|
26
|
+
#[WARNING:TRUNCATION] bounded preview for stderr log lines (never used for durable data)
|
|
27
|
+
return s[:limit] + "…"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _log_worker_event(tag: str, message: str) -> None:
|
|
31
|
+
"""Write a human-friendly worker log line to stderr (never stdout)."""
|
|
32
|
+
enabled = hasattr(sys.stderr, "isatty") and sys.stderr.isatty()
|
|
33
|
+
ts = time.strftime("%H:%M:%S")
|
|
34
|
+
tag_norm = str(tag or "").strip() or "WORKER"
|
|
35
|
+
|
|
36
|
+
if tag_norm == "RECEIVING_COMMANDS":
|
|
37
|
+
color = _ansi(enabled, "38;5;39") # blue
|
|
38
|
+
elif tag_norm == "RETURNING_RESULTS":
|
|
39
|
+
color = _ansi(enabled, "38;5;208") # orange
|
|
40
|
+
else:
|
|
41
|
+
color = _ansi(enabled, "2") # dim
|
|
42
|
+
reset = _ansi(enabled, "0")
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
sys.stderr.write(f"{color}[{tag_norm}]{reset} {ts} {message}\n")
|
|
46
|
+
sys.stderr.flush()
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _preview_json(value: Any, *, limit: int) -> str:
|
|
52
|
+
try:
|
|
53
|
+
return _truncate(json.dumps(value, ensure_ascii=False, sort_keys=True), limit=limit)
|
|
54
|
+
except Exception:
|
|
55
|
+
return _truncate(str(value), limit=limit)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _summarize_jsonrpc_request(req: Any, *, limit: int) -> str:
|
|
59
|
+
if not isinstance(req, dict):
|
|
60
|
+
return f"rpc invalid type={type(req).__name__} preview={_preview_json(req, limit=limit)}"
|
|
61
|
+
|
|
62
|
+
req_id = req.get("id")
|
|
63
|
+
method = str(req.get("method") or "").strip() or "<missing>"
|
|
64
|
+
|
|
65
|
+
summary = f"rpc method={method} id={_truncate(str(req_id), limit=64)}"
|
|
66
|
+
params = req.get("params")
|
|
67
|
+
params_obj = params if isinstance(params, dict) else {}
|
|
68
|
+
if method == "tools/call":
|
|
69
|
+
name = str(params_obj.get("name") or "").strip() or "<missing>"
|
|
70
|
+
arguments = params_obj.get("arguments")
|
|
71
|
+
args = dict(arguments) if isinstance(arguments, dict) else {}
|
|
72
|
+
summary += f" name={name} args={_preview_json(args, limit=limit)}"
|
|
73
|
+
return summary
|
|
74
|
+
|
|
75
|
+
if method == "tools/list":
|
|
76
|
+
return summary
|
|
77
|
+
|
|
78
|
+
if method == "initialize":
|
|
79
|
+
proto = str(params_obj.get("protocolVersion") or "").strip()
|
|
80
|
+
if proto:
|
|
81
|
+
summary += f" protocolVersion={_truncate(proto, limit=64)}"
|
|
82
|
+
return summary
|
|
83
|
+
|
|
84
|
+
return summary
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _summarize_jsonrpc_response(resp: Any, *, limit: int) -> str:
|
|
88
|
+
if resp is None:
|
|
89
|
+
return "rpc notification(no response)"
|
|
90
|
+
if not isinstance(resp, dict):
|
|
91
|
+
return f"rpc invalid_response type={type(resp).__name__} preview={_preview_json(resp, limit=limit)}"
|
|
92
|
+
|
|
93
|
+
if isinstance(resp.get("error"), dict):
|
|
94
|
+
err = resp["error"]
|
|
95
|
+
code = err.get("code")
|
|
96
|
+
msg = str(err.get("message") or "").strip()
|
|
97
|
+
return f"rpc_error code={code} message={_truncate(msg, limit=limit)}"
|
|
98
|
+
|
|
99
|
+
result = resp.get("result")
|
|
100
|
+
if isinstance(result, dict) and isinstance(result.get("tools"), list):
|
|
101
|
+
tools = result.get("tools") or []
|
|
102
|
+
names = [
|
|
103
|
+
str(t.get("name") or "").strip()
|
|
104
|
+
for t in tools
|
|
105
|
+
if isinstance(t, dict) and isinstance(t.get("name"), str) and str(t.get("name") or "").strip()
|
|
106
|
+
]
|
|
107
|
+
preview = ", ".join(names[:10])
|
|
108
|
+
suffix = f" names={_truncate(preview, limit=limit)}" if preview else ""
|
|
109
|
+
return f"rpc_result tools_count={len(tools)}{suffix}"
|
|
110
|
+
|
|
111
|
+
if isinstance(result, dict) and isinstance(result.get("protocolVersion"), str):
|
|
112
|
+
proto = str(result.get("protocolVersion") or "").strip()
|
|
113
|
+
if proto:
|
|
114
|
+
return f"rpc_result protocolVersion={_truncate(proto, limit=limit)}"
|
|
115
|
+
|
|
116
|
+
if isinstance(result, dict) and isinstance(result.get("content"), list):
|
|
117
|
+
is_error = result.get("isError")
|
|
118
|
+
content = result.get("content") or []
|
|
119
|
+
first = content[0] if isinstance(content, list) and content else None
|
|
120
|
+
if isinstance(first, dict) and first.get("type") == "text":
|
|
121
|
+
text = str(first.get("text") or "")
|
|
122
|
+
return f"rpc_result isError={bool(is_error)} preview={_truncate(text, limit=limit)}"
|
|
123
|
+
return f"rpc_result isError={bool(is_error)}"
|
|
124
|
+
|
|
125
|
+
return f"rpc_result preview={_preview_json(result, limit=limit)}"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _summarize_http_request(environ: Dict[str, Any], *, limit: int) -> str:
|
|
129
|
+
method = str(environ.get("REQUEST_METHOD") or "").strip() or "?"
|
|
130
|
+
path = str(environ.get("PATH_INFO") or "/") or "/"
|
|
131
|
+
query = str(environ.get("QUERY_STRING") or "").strip()
|
|
132
|
+
if query:
|
|
133
|
+
path = f"{path}?{query}"
|
|
134
|
+
|
|
135
|
+
remote = str(environ.get("REMOTE_ADDR") or "").strip()
|
|
136
|
+
port = str(environ.get("REMOTE_PORT") or "").strip()
|
|
137
|
+
if remote and port:
|
|
138
|
+
remote = f"{remote}:{port}"
|
|
139
|
+
|
|
140
|
+
origin = str(environ.get("HTTP_ORIGIN") or "").strip()
|
|
141
|
+
user_agent = str(environ.get("HTTP_USER_AGENT") or "").strip()
|
|
142
|
+
content_length = str(environ.get("CONTENT_LENGTH") or "").strip()
|
|
143
|
+
|
|
144
|
+
auth_present = bool(
|
|
145
|
+
str(environ.get("HTTP_AUTHORIZATION") or "").strip() or str(environ.get("HTTP_X_ABSTRACT_WORKER_TOKEN") or "").strip()
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
parts = [f"http {method} {path}"]
|
|
149
|
+
if remote:
|
|
150
|
+
parts.append(f"from={_truncate(remote, limit=128)}")
|
|
151
|
+
if origin:
|
|
152
|
+
parts.append(f"origin={_truncate(origin, limit=128)}")
|
|
153
|
+
parts.append(f"auth={'present' if auth_present else 'missing'}")
|
|
154
|
+
if content_length:
|
|
155
|
+
parts.append(f"content_length={_truncate(content_length, limit=32)}")
|
|
156
|
+
if user_agent:
|
|
157
|
+
parts.append(f"ua={_truncate(user_agent, limit=128)}")
|
|
158
|
+
|
|
159
|
+
return _truncate(" ".join(parts), limit=limit)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _runtime_version() -> Optional[str]:
|
|
163
|
+
for name in ("AbstractRuntime", "abstractruntime"):
|
|
164
|
+
try:
|
|
165
|
+
return metadata.version(name)
|
|
166
|
+
except Exception:
|
|
167
|
+
continue
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _jsonrpc_error(req_id: Any, *, code: int, message: str, data: Any = None) -> Dict[str, Any]:
|
|
172
|
+
err: Dict[str, Any] = {"code": int(code), "message": str(message)}
|
|
173
|
+
if data is not None:
|
|
174
|
+
err["data"] = data
|
|
175
|
+
return {"jsonrpc": "2.0", "id": req_id, "error": err}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _jsonrpc_result(req_id: Any, *, result: Dict[str, Any]) -> Dict[str, Any]:
|
|
179
|
+
return {"jsonrpc": "2.0", "id": req_id, "result": result}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _tool_callable_name(func: Callable[..., Any]) -> str:
|
|
183
|
+
tool_def = getattr(func, "_tool_definition", None)
|
|
184
|
+
if tool_def is not None:
|
|
185
|
+
name = getattr(tool_def, "name", None)
|
|
186
|
+
if isinstance(name, str) and name.strip():
|
|
187
|
+
return name.strip()
|
|
188
|
+
name = getattr(func, "__name__", "")
|
|
189
|
+
return str(name or "").strip()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _tool_callable_definition(func: Callable[..., Any]) -> Any:
|
|
193
|
+
tool_def = getattr(func, "_tool_definition", None)
|
|
194
|
+
if tool_def is not None:
|
|
195
|
+
return tool_def
|
|
196
|
+
|
|
197
|
+
from abstractcore.tools.core import ToolDefinition
|
|
198
|
+
|
|
199
|
+
return ToolDefinition.from_function(func)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _tool_def_to_mcp_entry(tool_def: Any) -> Dict[str, Any]:
|
|
203
|
+
name = str(getattr(tool_def, "name", "") or "").strip()
|
|
204
|
+
if not name:
|
|
205
|
+
raise ValueError("ToolDefinition missing name")
|
|
206
|
+
|
|
207
|
+
description = str(getattr(tool_def, "description", "") or "").strip()
|
|
208
|
+
if not description:
|
|
209
|
+
description = f"Tool '{name}'"
|
|
210
|
+
|
|
211
|
+
params = getattr(tool_def, "parameters", None)
|
|
212
|
+
if not isinstance(params, dict):
|
|
213
|
+
params = {}
|
|
214
|
+
|
|
215
|
+
props: Dict[str, Any] = {}
|
|
216
|
+
required: List[str] = []
|
|
217
|
+
for k, schema in params.items():
|
|
218
|
+
if not isinstance(k, str) or not k.strip():
|
|
219
|
+
continue
|
|
220
|
+
meta = dict(schema) if isinstance(schema, dict) else {}
|
|
221
|
+
props[k] = meta
|
|
222
|
+
if isinstance(schema, dict) and "default" not in schema:
|
|
223
|
+
required.append(k)
|
|
224
|
+
|
|
225
|
+
input_schema: Dict[str, Any] = {"type": "object", "properties": props}
|
|
226
|
+
if required:
|
|
227
|
+
input_schema["required"] = required
|
|
228
|
+
|
|
229
|
+
return {"name": name, "description": description, "inputSchema": input_schema}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _format_tool_result_text(value: Any) -> str:
|
|
233
|
+
if value is None:
|
|
234
|
+
return "null"
|
|
235
|
+
if isinstance(value, str):
|
|
236
|
+
return value
|
|
237
|
+
try:
|
|
238
|
+
return json.dumps(value, ensure_ascii=False)
|
|
239
|
+
except Exception:
|
|
240
|
+
return str(value)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@dataclass(frozen=True)
|
|
244
|
+
class McpWorkerState:
|
|
245
|
+
tools: List[Dict[str, Any]]
|
|
246
|
+
executor: MappingToolExecutor
|
|
247
|
+
http_require_auth: bool = False
|
|
248
|
+
http_auth_token: Optional[str] = None
|
|
249
|
+
http_allowed_origins: Tuple[str, ...] = ()
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def build_worker_state(*, toolsets: Sequence[str]) -> McpWorkerState:
|
|
253
|
+
desired = [str(t).strip() for t in (toolsets or []) if str(t).strip()]
|
|
254
|
+
available = get_default_toolsets()
|
|
255
|
+
|
|
256
|
+
selected: List[Callable[..., Any]] = []
|
|
257
|
+
for tid in desired:
|
|
258
|
+
spec = available.get(tid)
|
|
259
|
+
if not isinstance(spec, dict):
|
|
260
|
+
continue
|
|
261
|
+
for tool in spec.get("tools", []):
|
|
262
|
+
if callable(tool):
|
|
263
|
+
selected.append(tool)
|
|
264
|
+
|
|
265
|
+
tool_map: Dict[str, Callable[..., Any]] = {}
|
|
266
|
+
for tool in selected:
|
|
267
|
+
name = _tool_callable_name(tool)
|
|
268
|
+
if not name:
|
|
269
|
+
continue
|
|
270
|
+
tool_map[name] = tool
|
|
271
|
+
|
|
272
|
+
if not tool_map:
|
|
273
|
+
raise ValueError(
|
|
274
|
+
"No tools selected.\n\n"
|
|
275
|
+
f"Requested toolsets: {desired or ['(none)']}\n"
|
|
276
|
+
f"Available toolsets: {sorted(available.keys())}"
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
tools: List[Dict[str, Any]] = []
|
|
280
|
+
for tool in tool_map.values():
|
|
281
|
+
try:
|
|
282
|
+
tools.append(_tool_def_to_mcp_entry(_tool_callable_definition(tool)))
|
|
283
|
+
except Exception:
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
tools.sort(key=lambda t: str(t.get("name") or ""))
|
|
287
|
+
return McpWorkerState(tools=tools, executor=MappingToolExecutor(tool_map))
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def handle_mcp_request(*, req: Dict[str, Any], state: McpWorkerState) -> Optional[Dict[str, Any]]:
|
|
291
|
+
if not isinstance(req, dict):
|
|
292
|
+
return _jsonrpc_error(None, code=-32600, message="Invalid Request: must be an object")
|
|
293
|
+
|
|
294
|
+
if req.get("jsonrpc") != "2.0":
|
|
295
|
+
return _jsonrpc_error(req.get("id"), code=-32600, message="Invalid Request: jsonrpc must be '2.0'")
|
|
296
|
+
|
|
297
|
+
method = req.get("method")
|
|
298
|
+
if not isinstance(method, str) or not method.strip():
|
|
299
|
+
return _jsonrpc_error(req.get("id"), code=-32600, message="Invalid Request: missing method")
|
|
300
|
+
|
|
301
|
+
req_id = req.get("id")
|
|
302
|
+
if req_id is None:
|
|
303
|
+
# Notification: no response.
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
params = req.get("params")
|
|
307
|
+
params_obj = params if isinstance(params, dict) else {}
|
|
308
|
+
|
|
309
|
+
if method == "initialize":
|
|
310
|
+
client_req_version = params_obj.get("protocolVersion")
|
|
311
|
+
proto = str(client_req_version or "2025-11-25")
|
|
312
|
+
server_info: Dict[str, Any] = {"name": "abstractruntime-mcp-worker"}
|
|
313
|
+
ver = _runtime_version()
|
|
314
|
+
if ver:
|
|
315
|
+
server_info["version"] = ver
|
|
316
|
+
return _jsonrpc_result(
|
|
317
|
+
req_id,
|
|
318
|
+
result={
|
|
319
|
+
"protocolVersion": proto,
|
|
320
|
+
"capabilities": {"tools": {}},
|
|
321
|
+
"serverInfo": server_info,
|
|
322
|
+
},
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
if method == "tools/list":
|
|
326
|
+
return _jsonrpc_result(req_id, result={"tools": list(state.tools)})
|
|
327
|
+
|
|
328
|
+
if method == "tools/call":
|
|
329
|
+
name = str(params_obj.get("name") or "").strip()
|
|
330
|
+
if not name:
|
|
331
|
+
return _jsonrpc_error(req_id, code=-32602, message="Invalid params: missing tool name")
|
|
332
|
+
arguments = params_obj.get("arguments")
|
|
333
|
+
args = dict(arguments) if isinstance(arguments, dict) else {}
|
|
334
|
+
executed = state.executor.execute(tool_calls=[{"name": name, "arguments": args, "call_id": "mcp"}])
|
|
335
|
+
results = executed.get("results") if isinstance(executed, dict) else None
|
|
336
|
+
first = results[0] if isinstance(results, list) and results else None
|
|
337
|
+
if not isinstance(first, dict):
|
|
338
|
+
return _jsonrpc_error(req_id, code=-32000, message="Tool execution failed (malformed result)")
|
|
339
|
+
|
|
340
|
+
if first.get("success") is True:
|
|
341
|
+
text = _format_tool_result_text(first.get("output"))
|
|
342
|
+
return _jsonrpc_result(
|
|
343
|
+
req_id,
|
|
344
|
+
result={"content": [{"type": "text", "text": text}], "isError": False},
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
err = str(first.get("error") or "Tool execution failed").strip()
|
|
348
|
+
return _jsonrpc_result(
|
|
349
|
+
req_id,
|
|
350
|
+
result={"content": [{"type": "text", "text": err}], "isError": True},
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
return _jsonrpc_error(req_id, code=-32601, message=f"Method not found: {method}")
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def serve_stdio(*, state: McpWorkerState) -> None:
|
|
357
|
+
for line in sys.stdin:
|
|
358
|
+
text = (line or "").strip()
|
|
359
|
+
if not text:
|
|
360
|
+
continue
|
|
361
|
+
started = time.perf_counter()
|
|
362
|
+
try:
|
|
363
|
+
req = json.loads(text)
|
|
364
|
+
except Exception:
|
|
365
|
+
_log_worker_event("RECEIVING_COMMANDS", f"stdio parse_error body={_truncate(text, limit=500)}")
|
|
366
|
+
_log_worker_event("RETURNING_RESULTS", "stdio ok=false error=parse_error")
|
|
367
|
+
continue
|
|
368
|
+
_log_worker_event("RECEIVING_COMMANDS", f"stdio {_summarize_jsonrpc_request(req, limit=500)}")
|
|
369
|
+
if not isinstance(req, dict):
|
|
370
|
+
_log_worker_event("RETURNING_RESULTS", "stdio ok=false error=invalid_request_type")
|
|
371
|
+
continue
|
|
372
|
+
resp = handle_mcp_request(req=req, state=state)
|
|
373
|
+
elapsed_s = time.perf_counter() - started
|
|
374
|
+
ok = not (isinstance(resp, dict) and isinstance(resp.get("error"), dict))
|
|
375
|
+
_log_worker_event(
|
|
376
|
+
"RETURNING_RESULTS",
|
|
377
|
+
f"stdio ok={str(ok).lower()} elapsed_s={elapsed_s:.2f} {_summarize_jsonrpc_response(resp, limit=500)}",
|
|
378
|
+
)
|
|
379
|
+
if resp is None:
|
|
380
|
+
continue
|
|
381
|
+
sys.stdout.write(json.dumps(resp, ensure_ascii=False) + "\n")
|
|
382
|
+
sys.stdout.flush()
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def build_wsgi_app(*, state: McpWorkerState):
|
|
386
|
+
def _parse_auth(environ: Dict[str, Any]) -> Optional[str]:
|
|
387
|
+
raw = str(environ.get("HTTP_AUTHORIZATION") or "").strip()
|
|
388
|
+
if raw.lower().startswith("bearer "):
|
|
389
|
+
token = raw[len("bearer ") :].strip()
|
|
390
|
+
return token or None
|
|
391
|
+
# Allow a simple custom header for non-Bearer clients.
|
|
392
|
+
alt = str(environ.get("HTTP_X_ABSTRACT_WORKER_TOKEN") or "").strip()
|
|
393
|
+
return alt or None
|
|
394
|
+
|
|
395
|
+
def _check_security(environ: Dict[str, Any]) -> Optional[Tuple[str, List[Tuple[str, str]], Dict[str, Any]]]:
|
|
396
|
+
origin = str(environ.get("HTTP_ORIGIN") or "").strip()
|
|
397
|
+
if origin:
|
|
398
|
+
allowed = set(state.http_allowed_origins or ())
|
|
399
|
+
if not allowed or origin not in allowed:
|
|
400
|
+
return (
|
|
401
|
+
"403 Forbidden",
|
|
402
|
+
[("Content-Type", "application/json")],
|
|
403
|
+
_jsonrpc_error(None, code=-32000, message="Forbidden: invalid Origin"),
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
if state.http_require_auth:
|
|
407
|
+
expected = str(state.http_auth_token or "").strip()
|
|
408
|
+
if not expected:
|
|
409
|
+
return (
|
|
410
|
+
"500 Internal Server Error",
|
|
411
|
+
[("Content-Type", "application/json")],
|
|
412
|
+
_jsonrpc_error(None, code=-32000, message="Server misconfigured: auth enabled but no token set"),
|
|
413
|
+
)
|
|
414
|
+
provided = _parse_auth(environ)
|
|
415
|
+
if provided != expected:
|
|
416
|
+
headers = [("Content-Type", "application/json"), ("WWW-Authenticate", "Bearer")]
|
|
417
|
+
return ("401 Unauthorized", headers, _jsonrpc_error(None, code=-32001, message="Unauthorized"))
|
|
418
|
+
|
|
419
|
+
return None
|
|
420
|
+
|
|
421
|
+
def _app(environ: Dict[str, Any], start_response):
|
|
422
|
+
started = time.perf_counter()
|
|
423
|
+
sec = _check_security(environ)
|
|
424
|
+
if sec is not None:
|
|
425
|
+
status, headers, payload = sec
|
|
426
|
+
status_code = int(str(status).split(" ", 1)[0]) if status else 500
|
|
427
|
+
base = _summarize_http_request(environ, limit=500)
|
|
428
|
+
_log_worker_event("RECEIVING_COMMANDS", base)
|
|
429
|
+
elapsed_s = time.perf_counter() - started
|
|
430
|
+
_log_worker_event(
|
|
431
|
+
"RETURNING_RESULTS",
|
|
432
|
+
f"http status={status_code} elapsed_s={elapsed_s:.2f} {_summarize_jsonrpc_response(payload, limit=500)}",
|
|
433
|
+
)
|
|
434
|
+
start_response(status, headers)
|
|
435
|
+
return [json.dumps(payload).encode("utf-8")]
|
|
436
|
+
|
|
437
|
+
method = environ.get("REQUEST_METHOD")
|
|
438
|
+
if method != "POST":
|
|
439
|
+
base = _summarize_http_request(environ, limit=500)
|
|
440
|
+
_log_worker_event("RECEIVING_COMMANDS", base)
|
|
441
|
+
elapsed_s = time.perf_counter() - started
|
|
442
|
+
_log_worker_event("RETURNING_RESULTS", f"http status=405 elapsed_s={elapsed_s:.2f} body=method not allowed")
|
|
443
|
+
start_response("405 Method Not Allowed", [("Content-Type", "text/plain")])
|
|
444
|
+
return [b"method not allowed"]
|
|
445
|
+
|
|
446
|
+
try:
|
|
447
|
+
length = int(environ.get("CONTENT_LENGTH") or 0)
|
|
448
|
+
except Exception:
|
|
449
|
+
length = 0
|
|
450
|
+
body = environ.get("wsgi.input").read(length) if environ.get("wsgi.input") else b""
|
|
451
|
+
try:
|
|
452
|
+
req = json.loads(body.decode("utf-8"))
|
|
453
|
+
except Exception:
|
|
454
|
+
base = _summarize_http_request(environ, limit=500)
|
|
455
|
+
_log_worker_event("RECEIVING_COMMANDS", f"{base} body={_truncate(body.decode('utf-8', errors='replace'), limit=500)}")
|
|
456
|
+
elapsed_s = time.perf_counter() - started
|
|
457
|
+
_log_worker_event("RETURNING_RESULTS", f"http status=400 elapsed_s={elapsed_s:.2f} rpc_error parse_error")
|
|
458
|
+
start_response("400 Bad Request", [("Content-Type", "application/json")])
|
|
459
|
+
return [json.dumps(_jsonrpc_error(None, code=-32700, message="Parse error")).encode("utf-8")]
|
|
460
|
+
|
|
461
|
+
base = _summarize_http_request(environ, limit=500)
|
|
462
|
+
summary = _summarize_jsonrpc_request(req, limit=500)
|
|
463
|
+
_log_worker_event("RECEIVING_COMMANDS", f"{base} {summary}")
|
|
464
|
+
|
|
465
|
+
resp = handle_mcp_request(req=req if isinstance(req, dict) else {}, state=state)
|
|
466
|
+
if resp is None:
|
|
467
|
+
resp = _jsonrpc_error(None, code=-32600, message="Notification not supported over HTTP")
|
|
468
|
+
|
|
469
|
+
elapsed_s = time.perf_counter() - started
|
|
470
|
+
_log_worker_event(
|
|
471
|
+
"RETURNING_RESULTS",
|
|
472
|
+
f"http status=200 elapsed_s={elapsed_s:.2f} {_summarize_jsonrpc_response(resp, limit=500)}",
|
|
473
|
+
)
|
|
474
|
+
start_response("200 OK", [("Content-Type", "application/json")])
|
|
475
|
+
return [json.dumps(resp).encode("utf-8")]
|
|
476
|
+
|
|
477
|
+
return _app
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _parse_toolsets(value: str) -> List[str]:
|
|
481
|
+
return [p.strip() for p in (value or "").split(",") if p.strip()]
|
|
482
|
+
|
|
483
|
+
def _parse_allowed_origins(values: Sequence[str]) -> Tuple[str, ...]:
|
|
484
|
+
out: List[str] = []
|
|
485
|
+
for v in values or []:
|
|
486
|
+
for part in str(v or "").split(","):
|
|
487
|
+
s = part.strip()
|
|
488
|
+
if s:
|
|
489
|
+
out.append(s)
|
|
490
|
+
# stable
|
|
491
|
+
return tuple(dict.fromkeys(out))
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
495
|
+
parser = argparse.ArgumentParser(prog="abstractruntime-mcp-worker", add_help=True)
|
|
496
|
+
parser.add_argument(
|
|
497
|
+
"--transport",
|
|
498
|
+
choices=["stdio", "http"],
|
|
499
|
+
default="stdio",
|
|
500
|
+
help="MCP transport to serve (default: stdio).",
|
|
501
|
+
)
|
|
502
|
+
parser.add_argument(
|
|
503
|
+
"--toolsets",
|
|
504
|
+
default="",
|
|
505
|
+
help="Comma-separated toolsets to expose (required). Options: files, web, system.",
|
|
506
|
+
)
|
|
507
|
+
parser.add_argument("--host", default="127.0.0.1", help="HTTP bind host (http transport only).")
|
|
508
|
+
parser.add_argument("--port", type=int, default=8765, help="HTTP bind port (http transport only).")
|
|
509
|
+
parser.add_argument(
|
|
510
|
+
"--http-require-auth",
|
|
511
|
+
action="store_true",
|
|
512
|
+
help="Require HTTP Authorization (Bearer token). Recommended when binding beyond localhost.",
|
|
513
|
+
)
|
|
514
|
+
parser.add_argument(
|
|
515
|
+
"--http-auth-token",
|
|
516
|
+
default="",
|
|
517
|
+
help="Shared secret token for HTTP auth (prefer env var for stdio/ssh; see --http-auth-token-env).",
|
|
518
|
+
)
|
|
519
|
+
parser.add_argument(
|
|
520
|
+
"--http-auth-token-env",
|
|
521
|
+
default="ABSTRACT_WORKER_TOKEN",
|
|
522
|
+
help="Environment variable to read the HTTP auth token from when --http-require-auth is set (default: ABSTRACT_WORKER_TOKEN).",
|
|
523
|
+
)
|
|
524
|
+
parser.add_argument(
|
|
525
|
+
"--http-allow-origin",
|
|
526
|
+
action="append",
|
|
527
|
+
default=[],
|
|
528
|
+
help="Allowed Origin header value(s). If an Origin header is present and not allowed, the server returns 403.",
|
|
529
|
+
)
|
|
530
|
+
args = parser.parse_args(list(argv) if argv is not None else None)
|
|
531
|
+
|
|
532
|
+
try:
|
|
533
|
+
base_state = build_worker_state(toolsets=_parse_toolsets(str(args.toolsets or "")))
|
|
534
|
+
except Exception as e:
|
|
535
|
+
sys.stderr.write(
|
|
536
|
+
"Failed to start MCP worker.\n\n"
|
|
537
|
+
f"Error: {e}\n\n"
|
|
538
|
+
"Tips:\n"
|
|
539
|
+
"- Ensure AbstractCore is installed (and, for web tools, `abstractcore[tools]`).\n"
|
|
540
|
+
"- Choose toolsets explicitly (e.g. `--toolsets files` or `--toolsets files,system`).\n"
|
|
541
|
+
)
|
|
542
|
+
return 1
|
|
543
|
+
|
|
544
|
+
http_require_auth = bool(args.http_require_auth)
|
|
545
|
+
token = str(args.http_auth_token or "").strip()
|
|
546
|
+
token_env = str(args.http_auth_token_env or "").strip() or "ABSTRACT_WORKER_TOKEN"
|
|
547
|
+
if http_require_auth and not token:
|
|
548
|
+
token = str(os.environ.get(token_env) or "").strip()
|
|
549
|
+
if http_require_auth and not token:
|
|
550
|
+
sys.stderr.write(
|
|
551
|
+
"Failed to start MCP worker.\n\n"
|
|
552
|
+
"Error: --http-require-auth is set but no auth token was provided.\n\n"
|
|
553
|
+
"Provide one of:\n"
|
|
554
|
+
"- --http-auth-token <token>\n"
|
|
555
|
+
f"- environment variable {token_env}=<token>\n"
|
|
556
|
+
)
|
|
557
|
+
return 1
|
|
558
|
+
|
|
559
|
+
allowed_origins = _parse_allowed_origins(list(args.http_allow_origin or []))
|
|
560
|
+
|
|
561
|
+
state = McpWorkerState(
|
|
562
|
+
tools=base_state.tools,
|
|
563
|
+
executor=base_state.executor,
|
|
564
|
+
http_require_auth=http_require_auth,
|
|
565
|
+
http_auth_token=token or None,
|
|
566
|
+
http_allowed_origins=allowed_origins,
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
if args.transport == "http":
|
|
570
|
+
from socketserver import ThreadingMixIn
|
|
571
|
+
from wsgiref.simple_server import WSGIServer, make_server
|
|
572
|
+
|
|
573
|
+
app = build_wsgi_app(state=state)
|
|
574
|
+
|
|
575
|
+
class _ThreadingWSGIServer(ThreadingMixIn, WSGIServer):
|
|
576
|
+
daemon_threads = True
|
|
577
|
+
|
|
578
|
+
with make_server(str(args.host), int(args.port), app, server_class=_ThreadingWSGIServer) as httpd:
|
|
579
|
+
httpd.serve_forever()
|
|
580
|
+
return 0
|
|
581
|
+
|
|
582
|
+
serve_stdio(state=state)
|
|
583
|
+
return 0
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
if __name__ == "__main__": # pragma: no cover
|
|
587
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""abstractruntime.integrations.abstractcore.observability
|
|
2
|
+
|
|
3
|
+
Bridge AbstractRuntime execution events onto AbstractCore's GlobalEventBus.
|
|
4
|
+
|
|
5
|
+
Why this module exists:
|
|
6
|
+
- ADR-0001: Runtime kernel must not import AbstractCore.
|
|
7
|
+
- ADR-0004: Prefer a unified event bus for real-time observability.
|
|
8
|
+
|
|
9
|
+
This adapter consumes runtime ledger append events (StepRecord dicts) and emits
|
|
10
|
+
workflow-step events to `abstractcore.events.GlobalEventBus`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, Callable, Dict, Optional
|
|
16
|
+
|
|
17
|
+
from abstractcore.events import EventType, GlobalEventBus
|
|
18
|
+
|
|
19
|
+
from ...core.models import StepStatus
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _normalize_step_status(value: Any) -> Optional[str]:
|
|
23
|
+
if value is None:
|
|
24
|
+
return None
|
|
25
|
+
if isinstance(value, str):
|
|
26
|
+
return value
|
|
27
|
+
# StepStatus inherits from str, but be defensive.
|
|
28
|
+
raw = getattr(value, "value", None)
|
|
29
|
+
if isinstance(raw, str):
|
|
30
|
+
return raw
|
|
31
|
+
return str(value)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _event_type_for_status(status: Optional[str]) -> Optional[EventType]:
|
|
35
|
+
if status == StepStatus.STARTED.value:
|
|
36
|
+
return EventType.WORKFLOW_STEP_STARTED
|
|
37
|
+
if status == StepStatus.COMPLETED.value:
|
|
38
|
+
return EventType.WORKFLOW_STEP_COMPLETED
|
|
39
|
+
if status == StepStatus.WAITING.value:
|
|
40
|
+
return EventType.WORKFLOW_STEP_WAITING
|
|
41
|
+
if status == StepStatus.FAILED.value:
|
|
42
|
+
return EventType.WORKFLOW_STEP_FAILED
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def emit_step_record(record: Dict[str, Any], *, source: str = "abstractruntime") -> None:
|
|
47
|
+
"""Emit a GlobalEventBus event for a StepRecord dict."""
|
|
48
|
+
status = _normalize_step_status(record.get("status"))
|
|
49
|
+
event_type = _event_type_for_status(status)
|
|
50
|
+
if event_type is None:
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
# Keep the top-level fields filterable without deep inspection.
|
|
54
|
+
data = {
|
|
55
|
+
"run_id": record.get("run_id"),
|
|
56
|
+
"node_id": record.get("node_id"),
|
|
57
|
+
"step_id": record.get("step_id"),
|
|
58
|
+
"status": status,
|
|
59
|
+
"record": record,
|
|
60
|
+
}
|
|
61
|
+
GlobalEventBus.emit(event_type, data, source=source)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def attach_global_event_bus_bridge(
|
|
65
|
+
*,
|
|
66
|
+
runtime: Any,
|
|
67
|
+
run_id: Optional[str] = None,
|
|
68
|
+
source: str = "abstractruntime",
|
|
69
|
+
) -> Callable[[], None]:
|
|
70
|
+
"""Subscribe to runtime ledger appends and emit to GlobalEventBus.
|
|
71
|
+
|
|
72
|
+
Requires the configured runtime ledger store to support subscriptions
|
|
73
|
+
(wrap it with `ObservableLedgerStore`).
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def _on_record(rec: Dict[str, Any]) -> None:
|
|
77
|
+
emit_step_record(rec, source=source)
|
|
78
|
+
|
|
79
|
+
return runtime.subscribe_ledger(_on_record, run_id=run_id)
|
|
80
|
+
|