cisternal 0.1.0a1__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.
- cisternal/__init__.py +150 -0
- cisternal/adapters/__init__.py +1 -0
- cisternal/adapters/base.py +221 -0
- cisternal/adapters/cli.py +85 -0
- cisternal/adapters/v2_decorator.py +101 -0
- cisternal/adapters/v3_middleware.py +64 -0
- cisternal/assets/__init__.py +51 -0
- cisternal/assets/bridge.py +140 -0
- cisternal/assets/bundle.py +176 -0
- cisternal/assets/capability.py +146 -0
- cisternal/assets/composite.py +79 -0
- cisternal/assets/data/__init__.py +1 -0
- cisternal/assets/data/vendor_tools.toml +34 -0
- cisternal/assets/inspect_json.py +49 -0
- cisternal/assets/load.py +21 -0
- cisternal/assets/manifest.py +236 -0
- cisternal/assets/manifest_commands.py +48 -0
- cisternal/assets/manifest_extensions.py +50 -0
- cisternal/assets/protocol.py +15 -0
- cisternal/assets/source.py +135 -0
- cisternal/assets/spec.py +32 -0
- cisternal/assets/validate_golden.py +182 -0
- cisternal/cli.py +559 -0
- cisternal/export/__init__.py +40 -0
- cisternal/export/_hash.py +58 -0
- cisternal/export/_markdown.py +33 -0
- cisternal/export/_rust_emit.py +116 -0
- cisternal/export/antigravity.py +155 -0
- cisternal/export/antigravity_rust.py +56 -0
- cisternal/export/base.py +65 -0
- cisternal/export/claude.py +181 -0
- cisternal/export/claude_rust.py +52 -0
- cisternal/export/copilot.py +105 -0
- cisternal/export/copilot_rust.py +71 -0
- cisternal/export/cursor.py +119 -0
- cisternal/export/cursor_rust.py +70 -0
- cisternal/export/hooks.py +116 -0
- cisternal/export/registry.py +94 -0
- cisternal/export/sink.py +117 -0
- cisternal/export/write.py +59 -0
- cisternal/probe/__init__.py +1 -0
- cisternal/probe/capability_probe.py +50 -0
- cisternal/probe/telemetry_doctor.py +333 -0
- cisternal/probe/telemetry_env.py +23 -0
- cisternal/py.typed +0 -0
- cisternal/registration/__init__.py +46 -0
- cisternal/registration/compose.py +89 -0
- cisternal/registration/decorator.py +87 -0
- cisternal/registration/errors.py +36 -0
- cisternal/registration/registry.py +140 -0
- cisternal/registration/shim.py +126 -0
- cisternal/registration/wired.py +205 -0
- cisternal/telemetry/__init__.py +39 -0
- cisternal/telemetry/context.py +69 -0
- cisternal/telemetry/exporter.py +155 -0
- cisternal/telemetry/otlp_exporter.py +255 -0
- cisternal/telemetry/pipeline.py +314 -0
- cisternal/telemetry/record.py +41 -0
- cisternal/telemetry/self_obs.py +245 -0
- cisternal/telemetry/span.py +204 -0
- cisternal-0.1.0a1.dist-info/METADATA +109 -0
- cisternal-0.1.0a1.dist-info/RECORD +66 -0
- cisternal-0.1.0a1.dist-info/WHEEL +5 -0
- cisternal-0.1.0a1.dist-info/entry_points.txt +8 -0
- cisternal-0.1.0a1.dist-info/licenses/LICENSE +21 -0
- cisternal-0.1.0a1.dist-info/top_level.txt +1 -0
cisternal/__init__.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Cisternal: Shared telemetry substrate for praxia tool family.
|
|
2
|
+
|
|
3
|
+
Public API (spec §3.2 — M1 telemetry + M2 registration surface):
|
|
4
|
+
|
|
5
|
+
M1 — Telemetry:
|
|
6
|
+
init(log_dir, max_bytes, backup_count, exporters): Initialize pipeline
|
|
7
|
+
emit_event(name, **fields): Emit a telemetry event
|
|
8
|
+
span(name, **fields): Sync timing context manager
|
|
9
|
+
aspan(name, **fields): Async timing context manager
|
|
10
|
+
status(): Get pipeline health status
|
|
11
|
+
|
|
12
|
+
M2 — Registration surface (B+G2 hybrid design, challenger-hardened):
|
|
13
|
+
tool: Pure-metadata decorator for registering MCP tools (A1, A2).
|
|
14
|
+
@cisternal.tool returns the original fn unchanged (decorated_fn is fn).
|
|
15
|
+
wire(server, app, *, adapter, registry, expected, validate):
|
|
16
|
+
Snapshot a registry at call time and register each tool on a FastMCP
|
|
17
|
+
server (and optionally a Cyclopts App). Returns a WiredRegistry.
|
|
18
|
+
WiredRegistry: Introspection object returned by wire().
|
|
19
|
+
CisternalWireError: Raised by wire() when expected tools are missing.
|
|
20
|
+
clear_registry(name): Test teardown helper; clears a named registry (A7).
|
|
21
|
+
|
|
22
|
+
Design assumptions (spec §assumptions):
|
|
23
|
+
A1 — FastMCP v3 uses asyncio.iscoroutinefunction() to decide whether to await
|
|
24
|
+
the tool callable. The generated MCP callable is always async def.
|
|
25
|
+
A2 — FastMCP v3 reads inspect.signature(fn) for JSON schema generation.
|
|
26
|
+
Explicit __signature__ injection (H1) controls what FastMCP sees.
|
|
27
|
+
A3 — M1 CisternalMiddleware is installed on the FastMCP server before wire()
|
|
28
|
+
is called. Telemetry and shape adaptation are M1's exclusive responsibility.
|
|
29
|
+
A4 — Cyclopts 4.18.0+ calls asyncio.run() for async def command functions when
|
|
30
|
+
no event loop is running; inside a running loop, use app.run_async().
|
|
31
|
+
A5 — Global registry state is process-scoped; no cross-process sharing.
|
|
32
|
+
A6 — Python >= 3.11 (asyncio.get_running_loop() is the stable API).
|
|
33
|
+
A7 — Test environments call cisternal.clear_registry() in teardown to prevent
|
|
34
|
+
cross-test registry contamination.
|
|
35
|
+
|
|
36
|
+
HARD INVARIANT (C5/AC-M2-6):
|
|
37
|
+
The M2 wire-time MCP callable is a PURE PASSTHROUGH — it MUST NOT call any
|
|
38
|
+
adapter.emit_* or adapter.shape_* methods, or emit ANY telemetry. All
|
|
39
|
+
telemetry and shaping is exclusively owned by M1 CisternalMiddleware.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
from typing import Any
|
|
44
|
+
|
|
45
|
+
from cisternal.telemetry import (
|
|
46
|
+
init_pipeline,
|
|
47
|
+
get_pipeline,
|
|
48
|
+
span,
|
|
49
|
+
aspan,
|
|
50
|
+
job_span,
|
|
51
|
+
status,
|
|
52
|
+
ExporterBase,
|
|
53
|
+
_build_record,
|
|
54
|
+
)
|
|
55
|
+
from cisternal.registration.decorator import tool
|
|
56
|
+
from cisternal.registration.errors import CisternalWireError
|
|
57
|
+
from cisternal.registration.registry import clear_registry
|
|
58
|
+
|
|
59
|
+
# M3 (assets export)
|
|
60
|
+
from cisternal.assets.spec import AssetSpec
|
|
61
|
+
from cisternal.assets.bundle import AssetBundle
|
|
62
|
+
from cisternal.assets.source import registry_assets
|
|
63
|
+
from cisternal.export.base import Emitter
|
|
64
|
+
from cisternal.export.claude import ClaudeEmitter
|
|
65
|
+
from cisternal.export.write import write_bundle
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def init(
|
|
69
|
+
log_dir: str | Path | None = None,
|
|
70
|
+
max_bytes: int = 10_485_760,
|
|
71
|
+
backup_count: int = 5,
|
|
72
|
+
exporters: list[ExporterBase] | None = None,
|
|
73
|
+
heartbeat_interval: float = 30.0,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Initialize the telemetry pipeline (idempotent).
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
log_dir: Directory for JSONL logs. If None, resolves via env vars or defaults to ~/.cisternal/logs.
|
|
79
|
+
max_bytes: Max file size before rotation (default 10 MB).
|
|
80
|
+
backup_count: Number of backup files to keep.
|
|
81
|
+
exporters: Custom exporters. If None, uses JsonlExporter with log_dir.
|
|
82
|
+
heartbeat_interval: Seconds between liveness heartbeat probes (default 30s).
|
|
83
|
+
"""
|
|
84
|
+
init_pipeline(
|
|
85
|
+
log_dir=log_dir,
|
|
86
|
+
max_bytes=max_bytes,
|
|
87
|
+
backup_count=backup_count,
|
|
88
|
+
exporters=exporters,
|
|
89
|
+
heartbeat_interval=heartbeat_interval,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def emit_event(name: str, **fields: Any) -> None:
|
|
94
|
+
"""Emit a telemetry event.
|
|
95
|
+
|
|
96
|
+
Snapshots contextvars on this thread, builds a Record, and enqueues
|
|
97
|
+
it for non-blocking export. Never raises.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
name: Event name (e.g. 'mcp.call_start').
|
|
101
|
+
**fields: Event fields (e.g. tool='foo', request_id='xyz').
|
|
102
|
+
"""
|
|
103
|
+
import time
|
|
104
|
+
|
|
105
|
+
pipeline = get_pipeline()
|
|
106
|
+
if pipeline is None:
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
record = _build_record(name, ts=time.time(), **fields)
|
|
110
|
+
if record is not None:
|
|
111
|
+
pipeline.emit(record)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _lazy_import(name: str) -> object:
|
|
115
|
+
"""Lazy re-export for wire and WiredRegistry to defer fastmcp import."""
|
|
116
|
+
if name == "wire":
|
|
117
|
+
from cisternal.registration.wired import wire as _wire
|
|
118
|
+
return _wire
|
|
119
|
+
if name == "WiredRegistry":
|
|
120
|
+
from cisternal.registration.wired import WiredRegistry as _WiredRegistry
|
|
121
|
+
return _WiredRegistry
|
|
122
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def __getattr__(name: str) -> object:
|
|
126
|
+
return _lazy_import(name)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
__all__ = [
|
|
130
|
+
# M1 — Telemetry
|
|
131
|
+
"init",
|
|
132
|
+
"emit_event",
|
|
133
|
+
"span",
|
|
134
|
+
"aspan",
|
|
135
|
+
"job_span",
|
|
136
|
+
"status",
|
|
137
|
+
# M2 — Registration surface
|
|
138
|
+
"tool",
|
|
139
|
+
"wire",
|
|
140
|
+
"WiredRegistry",
|
|
141
|
+
"CisternalWireError",
|
|
142
|
+
"clear_registry",
|
|
143
|
+
# M3 (assets export)
|
|
144
|
+
"AssetSpec",
|
|
145
|
+
"AssetBundle",
|
|
146
|
+
"registry_assets",
|
|
147
|
+
"Emitter",
|
|
148
|
+
"ClaudeEmitter",
|
|
149
|
+
"write_bundle",
|
|
150
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Consumer adapters: FastMCP v3 middleware, v2 decorator, CLI."""
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""AdapterBase: Consumer-agnostic adapter protocol (spec §3.6, §5.4).
|
|
2
|
+
|
|
3
|
+
Each adapter owns the event-name set (ALLOWED_NAMES) and the response shape
|
|
4
|
+
for its consumer (shape_ok/shape_error). The MCP wrappers (CisternalMiddleware,
|
|
5
|
+
traced_tool) instantiate an adapter, delegate telemetry emission + shaping to it,
|
|
6
|
+
and never re-raise exceptions (CH-5).
|
|
7
|
+
|
|
8
|
+
(CH-9) Runtime name guard: emit_start/end/error check that the emitted name is
|
|
9
|
+
in self.ALLOWED_NAMES; on mismatch, calls _swallow_name_error (stderr warn + continue).
|
|
10
|
+
Tests monkeypatch _swallow_name_error to raise AssertionError instead.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from abc import ABC, abstractmethod
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from cisternal import emit_event
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AdapterBase(ABC):
|
|
22
|
+
"""Abstract base for MCP tool adapters (v3 middleware, v2 decorator, etc.).
|
|
23
|
+
|
|
24
|
+
Each subclass defines:
|
|
25
|
+
- ALLOWED_NAMES: frozenset[str] of event names this adapter may emit.
|
|
26
|
+
- shape_ok(tool_name, result): Transform success response.
|
|
27
|
+
- shape_error(tool_name, exc): Transform error response.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
ALLOWED_NAMES: frozenset[str]
|
|
31
|
+
|
|
32
|
+
def emit_start(self, tool_name: str, arg_keys: list[str], request_id: str) -> None:
|
|
33
|
+
"""Emit mcp.call_start event.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
tool_name: Name of the tool being called.
|
|
37
|
+
arg_keys: Sorted list of argument keys (spec §3.7, §3.8).
|
|
38
|
+
request_id: Unique request ID for this invocation.
|
|
39
|
+
"""
|
|
40
|
+
name = "mcp.call_start"
|
|
41
|
+
if name not in self.ALLOWED_NAMES:
|
|
42
|
+
self._swallow_name_error(name)
|
|
43
|
+
emit_event(name, tool=tool_name, arg_keys=arg_keys, request_id=request_id)
|
|
44
|
+
|
|
45
|
+
def emit_end(self, tool_name: str, request_id: str, duration_ms: float) -> None:
|
|
46
|
+
"""Emit mcp.call_end event.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
tool_name: Name of the tool that executed.
|
|
50
|
+
request_id: The request ID from emit_start.
|
|
51
|
+
duration_ms: Duration of execution in milliseconds.
|
|
52
|
+
"""
|
|
53
|
+
name = "mcp.call_end"
|
|
54
|
+
if name not in self.ALLOWED_NAMES:
|
|
55
|
+
self._swallow_name_error(name)
|
|
56
|
+
emit_event(name, tool=tool_name, request_id=request_id, duration_ms=duration_ms)
|
|
57
|
+
|
|
58
|
+
def emit_error(self, tool_name: str, request_id: str, exc: BaseException) -> None:
|
|
59
|
+
"""Emit mcp.tool_error event.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
tool_name: Name of the tool that raised.
|
|
63
|
+
request_id: The request ID from emit_start.
|
|
64
|
+
exc: The exception that was raised.
|
|
65
|
+
"""
|
|
66
|
+
name = "mcp.tool_error"
|
|
67
|
+
if name not in self.ALLOWED_NAMES:
|
|
68
|
+
self._swallow_name_error(name)
|
|
69
|
+
emit_event(
|
|
70
|
+
name,
|
|
71
|
+
tool=tool_name,
|
|
72
|
+
request_id=request_id,
|
|
73
|
+
exc_type=type(exc).__name__,
|
|
74
|
+
exc_msg=str(exc),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
@abstractmethod
|
|
78
|
+
def shape_ok(self, tool_name: str, result: Any) -> Any:
|
|
79
|
+
"""Shape a successful tool result for the consumer.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
tool_name: Name of the tool (for context).
|
|
83
|
+
result: The result returned by the tool.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Consumer-specific shaped response (dict, str, or other).
|
|
87
|
+
"""
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
@abstractmethod
|
|
91
|
+
def shape_error(self, tool_name: str, exc: BaseException, **fields: Any) -> Any:
|
|
92
|
+
"""Shape an error response for the consumer (never re-raises).
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
tool_name: Name of the tool (for context).
|
|
96
|
+
exc: The exception that was caught.
|
|
97
|
+
**fields: Optional extra fields for the error envelope.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Consumer-specific error shape (dict, str, or other).
|
|
101
|
+
"""
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
def _swallow_name_error(self, name: str) -> None:
|
|
105
|
+
"""Handle illegal event name: log to stderr and return (warn-and-continue).
|
|
106
|
+
|
|
107
|
+
Tests may monkeypatch this to raise AssertionError (AC-NAMEFREEZE-4).
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
name: The illegal event name.
|
|
111
|
+
"""
|
|
112
|
+
print(f"[cisternal] ILLEGAL event name: {name!r}", file=sys.stderr)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class BathosAdapter(AdapterBase):
|
|
116
|
+
"""Adapter for bathos v3 middleware.
|
|
117
|
+
|
|
118
|
+
Event names (spec §4.2): mcp.call_start, mcp.call_end, mcp.tool_error.
|
|
119
|
+
Response shape: dict envelope with ok/error_code/error/resolution_hint.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
ALLOWED_NAMES = frozenset({"mcp.call_start", "mcp.call_end", "mcp.tool_error"})
|
|
123
|
+
|
|
124
|
+
def shape_ok(self, tool_name: str, result: Any) -> Any:
|
|
125
|
+
"""Shape success: merge dict results, add envelope fields.
|
|
126
|
+
|
|
127
|
+
If result is a dict, spread it and add envelope.
|
|
128
|
+
Otherwise, return minimal envelope.
|
|
129
|
+
"""
|
|
130
|
+
if isinstance(result, dict):
|
|
131
|
+
return {
|
|
132
|
+
**result,
|
|
133
|
+
"ok": True,
|
|
134
|
+
"error_code": None,
|
|
135
|
+
"error": None,
|
|
136
|
+
"resolution_hint": None,
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
"ok": True,
|
|
140
|
+
"error_code": None,
|
|
141
|
+
"error": None,
|
|
142
|
+
"resolution_hint": None,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
def shape_error(self, tool_name: str, exc: BaseException, **fields: Any) -> Any:
|
|
146
|
+
"""Shape error: error envelope with error_code and message."""
|
|
147
|
+
return {
|
|
148
|
+
"ok": False,
|
|
149
|
+
"error_code": "INTERNAL",
|
|
150
|
+
"error": str(exc),
|
|
151
|
+
"resolution_hint": "",
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class ContemplexAdapter(AdapterBase):
|
|
156
|
+
"""Adapter for contemplex v2 decorator (sync).
|
|
157
|
+
|
|
158
|
+
Event names (spec §4.2): mcp.call_start, mcp.call_end, mcp.tool_error.
|
|
159
|
+
Response shape: passthrough for success; err_envelope for error.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
ALLOWED_NAMES = frozenset({"mcp.call_start", "mcp.call_end", "mcp.tool_error"})
|
|
163
|
+
|
|
164
|
+
def shape_ok(self, tool_name: str, result: Any) -> Any:
|
|
165
|
+
"""Shape success: passthrough result unchanged."""
|
|
166
|
+
return result
|
|
167
|
+
|
|
168
|
+
def shape_error(self, tool_name: str, exc: BaseException, **fields: Any) -> Any:
|
|
169
|
+
"""Shape error: use contemplex err_envelope if available, else fallback.
|
|
170
|
+
|
|
171
|
+
Tries to import contemplex.errors; if unavailable, returns basic dict.
|
|
172
|
+
"""
|
|
173
|
+
try:
|
|
174
|
+
from contemplex.errors import ErrorCode, err_envelope
|
|
175
|
+
|
|
176
|
+
return err_envelope(ErrorCode.INTERNAL, f"{type(exc).__name__}: {exc}")
|
|
177
|
+
except ImportError:
|
|
178
|
+
# Fallback if contemplex is not available
|
|
179
|
+
return {"ok": False, "error_code": "INTERNAL", "error": str(exc)}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class XpeririAdapter(AdapterBase):
|
|
183
|
+
"""Adapter for xperiri v2 decorator (sync, JSON-string MCP returns).
|
|
184
|
+
|
|
185
|
+
Event names (spec §4.2): mcp.call_start, mcp.call_end, mcp.tool_error.
|
|
186
|
+
Response shape: JSON string for success and error (xperiri MCP tools return str).
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
ALLOWED_NAMES = frozenset({"mcp.call_start", "mcp.call_end", "mcp.tool_error"})
|
|
190
|
+
|
|
191
|
+
def shape_ok(self, tool_name: str, result: Any) -> Any:
|
|
192
|
+
"""Shape success: passthrough str; serialize dict/other to JSON."""
|
|
193
|
+
if isinstance(result, str):
|
|
194
|
+
return result
|
|
195
|
+
return json.dumps(result, sort_keys=True)
|
|
196
|
+
|
|
197
|
+
def shape_error(self, tool_name: str, exc: BaseException, **fields: Any) -> Any:
|
|
198
|
+
"""Shape error: JSON string envelope matching xperiri error returns."""
|
|
199
|
+
payload: dict[str, Any] = {"ok": False, "error": str(exc)}
|
|
200
|
+
payload.update(fields)
|
|
201
|
+
return json.dumps(payload, sort_keys=True)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class MyxcelAdapter(AdapterBase):
|
|
205
|
+
"""Adapter for myxcel v2 decorator (async MCP tools, dict returns).
|
|
206
|
+
|
|
207
|
+
Event names (spec §4.2): mcp.call_start, mcp.call_end, mcp.tool_error.
|
|
208
|
+
Response shape: dict passthrough; errors use {error, message} per myxcel.mcp_server._tool_error.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
ALLOWED_NAMES = frozenset({"mcp.call_start", "mcp.call_end", "mcp.tool_error"})
|
|
212
|
+
|
|
213
|
+
def shape_ok(self, tool_name: str, result: Any) -> Any:
|
|
214
|
+
"""Shape success: passthrough dict/list results (myxcel in-band errors included)."""
|
|
215
|
+
if isinstance(result, (dict, list)):
|
|
216
|
+
return result
|
|
217
|
+
return {"result": result}
|
|
218
|
+
|
|
219
|
+
def shape_error(self, tool_name: str, exc: BaseException, **fields: Any) -> Any:
|
|
220
|
+
"""Shape error: myxcel _tool_error envelope."""
|
|
221
|
+
return {"error": type(exc).__name__, "message": str(exc)}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Typer CLI timing adapter (spec §4.2, AC-CLI-1).
|
|
2
|
+
|
|
3
|
+
Provides CliAdapter for Typer CLI commands and timed_command decorator
|
|
4
|
+
to instrument command execution with cli.cmd_start and cli.cmd_end events.
|
|
5
|
+
|
|
6
|
+
The decorator is CLI-specific: unlike MCP adapters, it re-raises exceptions
|
|
7
|
+
to the CLI, which owns exit codes.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import functools
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from cisternal import emit_event
|
|
15
|
+
from cisternal.adapters.base import AdapterBase
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CliAdapter(AdapterBase):
|
|
19
|
+
"""Adapter for Typer CLI commands.
|
|
20
|
+
|
|
21
|
+
Event names (spec §4.2): cli.cmd_start, cli.cmd_end.
|
|
22
|
+
Response shape: passthrough (CLI owns stdout/exit codes).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
ALLOWED_NAMES = frozenset({"cli.cmd_start", "cli.cmd_end"})
|
|
26
|
+
|
|
27
|
+
def shape_ok(self, tool_name: str, result: Any) -> Any:
|
|
28
|
+
"""Shape success: passthrough result (CLI owns output)."""
|
|
29
|
+
return result
|
|
30
|
+
|
|
31
|
+
def shape_error(self, tool_name: str, exc: BaseException, **fields: Any) -> Any:
|
|
32
|
+
"""Shape error: not used by CLI (exceptions re-raise directly)."""
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def timed_command(cmd_name: str | None = None):
|
|
37
|
+
"""Decorator to emit CLI command timing events.
|
|
38
|
+
|
|
39
|
+
Emits cli.cmd_start before execution and cli.cmd_end after.
|
|
40
|
+
On exception: cli.cmd_end with ok=False, exc_type, then re-raises
|
|
41
|
+
(CLI owns the exit code).
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
cmd_name: Optional custom command name. If None, uses function.__name__.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Decorator function.
|
|
48
|
+
|
|
49
|
+
Example:
|
|
50
|
+
@timed_command()
|
|
51
|
+
def my_command(arg1: str):
|
|
52
|
+
print(f"Running {arg1}")
|
|
53
|
+
return 0
|
|
54
|
+
|
|
55
|
+
@timed_command("custom_name")
|
|
56
|
+
def another_command():
|
|
57
|
+
return 0
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def decorator(fn):
|
|
61
|
+
name = cmd_name or fn.__name__
|
|
62
|
+
|
|
63
|
+
@functools.wraps(fn)
|
|
64
|
+
def wrapper(*args, **kwargs):
|
|
65
|
+
emit_event("cli.cmd_start", cmd=name)
|
|
66
|
+
t0 = time.monotonic_ns()
|
|
67
|
+
try:
|
|
68
|
+
result = fn(*args, **kwargs)
|
|
69
|
+
duration_ms = (time.monotonic_ns() - t0) / 1e6
|
|
70
|
+
emit_event("cli.cmd_end", cmd=name, duration_ms=duration_ms, ok=True)
|
|
71
|
+
return result
|
|
72
|
+
except Exception as exc:
|
|
73
|
+
duration_ms = (time.monotonic_ns() - t0) / 1e6
|
|
74
|
+
emit_event(
|
|
75
|
+
"cli.cmd_end",
|
|
76
|
+
cmd=name,
|
|
77
|
+
duration_ms=duration_ms,
|
|
78
|
+
ok=False,
|
|
79
|
+
exc_type=type(exc).__name__,
|
|
80
|
+
)
|
|
81
|
+
raise
|
|
82
|
+
|
|
83
|
+
return wrapper
|
|
84
|
+
|
|
85
|
+
return decorator
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""traced_tool: FastMCP v2 integration via decorator (spec §3.8).
|
|
2
|
+
|
|
3
|
+
Double-decorator pattern: outer decorator takes an adapter, inner decorator
|
|
4
|
+
wraps the tool function. On each call, emits telemetry, measures duration,
|
|
5
|
+
and returns a shaped response. Never re-raises exceptions (CH-5).
|
|
6
|
+
|
|
7
|
+
Sync and async originals are supported: async tools get an ``async def`` wrapper
|
|
8
|
+
that awaits the original (mirrors :func:`cisternal.registration.shim.dispatch`).
|
|
9
|
+
|
|
10
|
+
(CH-10, AC-MCP-4) Token management: set/reset with ValueError guard.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import functools
|
|
15
|
+
import time
|
|
16
|
+
import uuid
|
|
17
|
+
from typing import Any, Callable, TypeVar
|
|
18
|
+
|
|
19
|
+
from cisternal.telemetry.context import mcp_request_id_var
|
|
20
|
+
from cisternal.adapters.base import AdapterBase
|
|
21
|
+
|
|
22
|
+
T = TypeVar("T")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _reset_request_token(token: Any) -> None:
|
|
26
|
+
try:
|
|
27
|
+
mcp_request_id_var.reset(token)
|
|
28
|
+
except ValueError:
|
|
29
|
+
# AC-MCP-4: Token from different context; swallow.
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def traced_tool(
|
|
34
|
+
adapter: AdapterBase,
|
|
35
|
+
) -> Callable[[Callable[..., T]], Callable[..., Any]]:
|
|
36
|
+
"""Decorator to wrap a FastMCP v2 tool with telemetry.
|
|
37
|
+
|
|
38
|
+
Usage:
|
|
39
|
+
@traced_tool(ContemplexAdapter())
|
|
40
|
+
def my_tool(arg: str) -> str:
|
|
41
|
+
return f"result: {arg}"
|
|
42
|
+
|
|
43
|
+
@traced_tool(MyxcelAdapter())
|
|
44
|
+
async def mount_project(remote: str, project: str) -> dict:
|
|
45
|
+
...
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
adapter: AdapterBase instance (e.g., BathosAdapter(), ContemplexAdapter()).
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Decorator function that wraps the tool (sync or async).
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def decorator(fn: Callable[..., T]) -> Callable[..., Any]:
|
|
55
|
+
if asyncio.iscoroutinefunction(fn):
|
|
56
|
+
|
|
57
|
+
@functools.wraps(fn)
|
|
58
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
59
|
+
request_id = uuid.uuid4().hex
|
|
60
|
+
token = mcp_request_id_var.set(request_id)
|
|
61
|
+
arg_keys = sorted(kwargs.keys()) # CH-6: kwargs only (parity caveat)
|
|
62
|
+
adapter.emit_start(fn.__name__, arg_keys, request_id)
|
|
63
|
+
t0 = time.monotonic_ns()
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
result = await fn(*args, **kwargs)
|
|
67
|
+
adapter.emit_end(
|
|
68
|
+
fn.__name__, request_id, (time.monotonic_ns() - t0) / 1e6
|
|
69
|
+
)
|
|
70
|
+
return adapter.shape_ok(fn.__name__, result)
|
|
71
|
+
except Exception as exc:
|
|
72
|
+
adapter.emit_error(fn.__name__, request_id, exc)
|
|
73
|
+
return adapter.shape_error(fn.__name__, exc)
|
|
74
|
+
finally:
|
|
75
|
+
_reset_request_token(token)
|
|
76
|
+
|
|
77
|
+
return async_wrapper
|
|
78
|
+
|
|
79
|
+
@functools.wraps(fn)
|
|
80
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
81
|
+
request_id = uuid.uuid4().hex
|
|
82
|
+
token = mcp_request_id_var.set(request_id)
|
|
83
|
+
arg_keys = sorted(kwargs.keys()) # CH-6: kwargs only (parity caveat)
|
|
84
|
+
adapter.emit_start(fn.__name__, arg_keys, request_id)
|
|
85
|
+
t0 = time.monotonic_ns()
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
result = fn(*args, **kwargs)
|
|
89
|
+
adapter.emit_end(
|
|
90
|
+
fn.__name__, request_id, (time.monotonic_ns() - t0) / 1e6
|
|
91
|
+
)
|
|
92
|
+
return adapter.shape_ok(fn.__name__, result)
|
|
93
|
+
except Exception as exc:
|
|
94
|
+
adapter.emit_error(fn.__name__, request_id, exc)
|
|
95
|
+
return adapter.shape_error(fn.__name__, exc)
|
|
96
|
+
finally:
|
|
97
|
+
_reset_request_token(token)
|
|
98
|
+
|
|
99
|
+
return sync_wrapper
|
|
100
|
+
|
|
101
|
+
return decorator
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""CisternalMiddleware: FastMCP v3 integration via middleware hook (spec §3.7).
|
|
2
|
+
|
|
3
|
+
Middleware fires on_call_tool for each tool call, wrapping the tool execution
|
|
4
|
+
with telemetry: emit_start, measure duration, emit_end or emit_error, return
|
|
5
|
+
shaped envelope. Never re-raises exceptions to the transport (CH-5).
|
|
6
|
+
|
|
7
|
+
(CH-10, AC-MCP-4) Token management: set/reset with ValueError guard for
|
|
8
|
+
cross-context token resets (possible in asyncio context copy scenarios).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from fastmcp.server.middleware.middleware import Middleware, MiddlewareContext
|
|
16
|
+
from cisternal.telemetry.context import mcp_request_id_var
|
|
17
|
+
from cisternal.adapters.base import BathosAdapter
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CisternalMiddleware(Middleware):
|
|
21
|
+
"""Middleware for FastMCP v3 servers.
|
|
22
|
+
|
|
23
|
+
Wires via: server.add_middleware(CisternalMiddleware()).
|
|
24
|
+
Fires on every tools/call request (spec §3.7).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
async def on_call_tool(self, context: MiddlewareContext, call_next: Any) -> Any:
|
|
28
|
+
"""Intercept tool call, emit telemetry, and return shaped response.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
context: MiddlewareContext with .message = CallToolRequestParams.
|
|
32
|
+
.message.name = tool name (str)
|
|
33
|
+
.message.arguments = tool arguments (dict|None)
|
|
34
|
+
call_next: Async callable to invoke the actual tool.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Shaped response (dict envelope with ok/error) per BathosAdapter.
|
|
38
|
+
Never raises; exceptions caught and returned as error envelopes.
|
|
39
|
+
"""
|
|
40
|
+
tool_name = context.message.name
|
|
41
|
+
arguments = context.message.arguments or {}
|
|
42
|
+
arg_keys = sorted(arguments.keys()) # CH-6: client-supplied keys only
|
|
43
|
+
request_id = uuid.uuid4().hex
|
|
44
|
+
|
|
45
|
+
# Set mcp_request_id in context for this call
|
|
46
|
+
token = mcp_request_id_var.set(request_id)
|
|
47
|
+
adapter = BathosAdapter()
|
|
48
|
+
adapter.emit_start(tool_name, arg_keys, request_id)
|
|
49
|
+
t0 = time.monotonic_ns()
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
result = await call_next(context)
|
|
53
|
+
adapter.emit_end(tool_name, request_id, (time.monotonic_ns() - t0) / 1e6)
|
|
54
|
+
return adapter.shape_ok(tool_name, result)
|
|
55
|
+
except Exception as exc:
|
|
56
|
+
adapter.emit_error(tool_name, request_id, exc)
|
|
57
|
+
return adapter.shape_error(tool_name, exc)
|
|
58
|
+
finally:
|
|
59
|
+
try:
|
|
60
|
+
mcp_request_id_var.reset(token)
|
|
61
|
+
except ValueError:
|
|
62
|
+
# AC-MCP-4: Token was created in a different Context (e.g., via
|
|
63
|
+
# copy_context().run() in asyncio). Swallow and continue.
|
|
64
|
+
pass
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""cisternal.assets — Agent-asset IR (data model) and registry source.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
AssetSpec — frozen dataclass: one tool's asset metadata.
|
|
5
|
+
BundleMetadata — frozen dataclass: name/version/description for a bundle.
|
|
6
|
+
CommandAsset — frozen dataclass: one command asset.
|
|
7
|
+
McpAsset — frozen dataclass: one MCP server entry.
|
|
8
|
+
SkillAsset — frozen dataclass: one skill asset (M3.1a).
|
|
9
|
+
AgentAsset — frozen dataclass: one agent asset (M3.1a).
|
|
10
|
+
HookSpecAsset — frozen dataclass: one hook spec asset (M3.1a).
|
|
11
|
+
LoadReport — load result with bundle, warnings, conflicts (M3.1a).
|
|
12
|
+
AssetBundle — frozen dataclass: full bundle; sorted collections.
|
|
13
|
+
registry_assets — Extract AssetSpec tuples from a named registry partition.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from cisternal.assets.spec import AssetSpec
|
|
17
|
+
from cisternal.assets.bundle import (
|
|
18
|
+
AgentAsset,
|
|
19
|
+
AssetBundle,
|
|
20
|
+
BundleMetadata,
|
|
21
|
+
CommandAsset,
|
|
22
|
+
HookSpecAsset,
|
|
23
|
+
LoadReport,
|
|
24
|
+
McpAsset,
|
|
25
|
+
SkillAsset,
|
|
26
|
+
)
|
|
27
|
+
from cisternal.assets.capability import Capability, resolve_model_hint, resolve_tools
|
|
28
|
+
from cisternal.assets.composite import CompositeAssetSource
|
|
29
|
+
from cisternal.assets.manifest import ManifestAssetSource
|
|
30
|
+
from cisternal.assets.protocol import AssetSource
|
|
31
|
+
from cisternal.assets.source import registry_assets, registry_bundle
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"AssetSpec",
|
|
35
|
+
"AgentAsset",
|
|
36
|
+
"AssetBundle",
|
|
37
|
+
"AssetSource",
|
|
38
|
+
"BundleMetadata",
|
|
39
|
+
"Capability",
|
|
40
|
+
"CommandAsset",
|
|
41
|
+
"CompositeAssetSource",
|
|
42
|
+
"HookSpecAsset",
|
|
43
|
+
"LoadReport",
|
|
44
|
+
"ManifestAssetSource",
|
|
45
|
+
"McpAsset",
|
|
46
|
+
"SkillAsset",
|
|
47
|
+
"registry_assets",
|
|
48
|
+
"registry_bundle",
|
|
49
|
+
"resolve_model_hint",
|
|
50
|
+
"resolve_tools",
|
|
51
|
+
]
|