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
@@ -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
+