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