skillpool 4.3.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.
- skillpool/__init__.py +74 -0
- skillpool/__main__.py +6 -0
- skillpool/adapters/__init__.py +8 -0
- skillpool/adapters/base.py +41 -0
- skillpool/adapters/claude_adapter.py +36 -0
- skillpool/adapters/codex_adapter.py +92 -0
- skillpool/adapters/hermes_adapter.py +38 -0
- skillpool/audit/__init__.py +651 -0
- skillpool/bridge/__init__.py +16 -0
- skillpool/bridge/freeze_detector.py +134 -0
- skillpool/bridge/maintenance.py +119 -0
- skillpool/bridge/wal_manager.py +136 -0
- skillpool/clawmem_client.py +176 -0
- skillpool/cli.py +700 -0
- skillpool/combiner/__init__.py +31 -0
- skillpool/combiner/lifecycle.py +453 -0
- skillpool/combiner/models.py +99 -0
- skillpool/config.py +34 -0
- skillpool/cost/__init__.py +111 -0
- skillpool/cost/audit_hash.py +51 -0
- skillpool/cost/budget_tracker.py +66 -0
- skillpool/cost/dashboard.py +189 -0
- skillpool/cost/models.py +129 -0
- skillpool/cost/token_governor.py +264 -0
- skillpool/cost/trace_ceiling.py +38 -0
- skillpool/csdf.py +126 -0
- skillpool/evolver/__init__.py +978 -0
- skillpool/gain/__init__.py +285 -0
- skillpool/gate.py +282 -0
- skillpool/gate_policy/__init__.py +31 -0
- skillpool/gate_policy/incremental.py +157 -0
- skillpool/gate_policy/parser.py +258 -0
- skillpool/gate_policy/state_machine.py +432 -0
- skillpool/graph/__init__.py +14 -0
- skillpool/graph/ppr.py +279 -0
- skillpool/health/__init__.py +73 -0
- skillpool/health/check.py +85 -0
- skillpool/health/degradation.py +90 -0
- skillpool/health/models.py +43 -0
- skillpool/hooks/__init__.py +4 -0
- skillpool/hooks/security_scanner.py +288 -0
- skillpool/lifecycle.py +150 -0
- skillpool/materializer/__init__.py +124 -0
- skillpool/materializer/budget_cropper.py +178 -0
- skillpool/materializer/csdf_loader.py +114 -0
- skillpool/materializer/lazy_loader.py +265 -0
- skillpool/materializer/lifecycle_filter.py +93 -0
- skillpool/materializer/mapper.py +178 -0
- skillpool/materializer/models.py +66 -0
- skillpool/mcp_server.py +2005 -0
- skillpool/monitor/__init__.py +576 -0
- skillpool/monitor/bug_collector.py +392 -0
- skillpool/monitor/defect_classifier.py +218 -0
- skillpool/monitor/self_healing.py +530 -0
- skillpool/monitor/telemetry_bridge.py +197 -0
- skillpool/paradigm/__init__.py +312 -0
- skillpool/paradigm/override.py +285 -0
- skillpool/profile.py +94 -0
- skillpool/quality.py +254 -0
- skillpool/registry/__init__.py +509 -0
- skillpool/registry/models.py +98 -0
- skillpool/resolver/__init__.py +320 -0
- skillpool/resolver/cache.py +103 -0
- skillpool/resolver/circuit_breaker.py +103 -0
- skillpool/resolver/conflict_detector.py +111 -0
- skillpool/resolver/health_filter.py +38 -0
- skillpool/resolver/models.py +154 -0
- skillpool/resolver/rate_limiter.py +48 -0
- skillpool/resolver/skill_graph.py +183 -0
- skillpool/review/__init__.py +242 -0
- skillpool/review/async_queue.py +96 -0
- skillpool/review/checkpoint_runner.py +345 -0
- skillpool/review/models.py +164 -0
- skillpool/review/suspect_marker.py +39 -0
- skillpool/review/veto_evaluator.py +94 -0
- skillpool/router/__init__.py +481 -0
- skillpool/schemas.py +119 -0
- skillpool/synergy/__init__.py +240 -0
- skillpool/synergy/detector.py +5 -0
- skillpool/telemetry.py +126 -0
- skillpool/utils/__init__.py +21 -0
- skillpool/utils/changelog.py +218 -0
- skillpool/utils/logger.py +273 -0
- skillpool/utils/runtime_audit.py +163 -0
- skillpool/utils/time_utils.py +13 -0
- skillpool-4.3.0.dist-info/METADATA +21 -0
- skillpool-4.3.0.dist-info/RECORD +90 -0
- skillpool-4.3.0.dist-info/WHEEL +5 -0
- skillpool-4.3.0.dist-info/entry_points.txt +3 -0
- skillpool-4.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""BugCollector — Sentry-inspired 4-stage bug collection pipeline.
|
|
2
|
+
|
|
3
|
+
Stages: Capture -> Enrich -> Filter -> Persist
|
|
4
|
+
|
|
5
|
+
Capture: Intercept exceptions via sys.excepthook + manual record() calls
|
|
6
|
+
Enrich: Auto-attach skill_id, trace_id, checkpoint, gate_result, defect_type
|
|
7
|
+
Filter: Sample rate (test=1.0, prod=0.1), before_persist hook for noise filtering
|
|
8
|
+
Persist: Write to JSONL at ~/.skillpool/logs/bugs.jsonl (with fsync) + append to AuditLayer hash chain
|
|
9
|
+
|
|
10
|
+
Part of SkillPool — independent infrastructure, shared by all agents.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"BugCollector",
|
|
17
|
+
"BugRecord",
|
|
18
|
+
"BugSeverity",
|
|
19
|
+
"DefectType",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
import os
|
|
25
|
+
import random
|
|
26
|
+
import sys
|
|
27
|
+
import traceback
|
|
28
|
+
import uuid
|
|
29
|
+
from collections.abc import Callable
|
|
30
|
+
from dataclasses import asdict, dataclass, field
|
|
31
|
+
from enum import StrEnum
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from skillpool.config import get_data_dir
|
|
36
|
+
from skillpool.utils.time_utils import utc_now
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class BugSeverity(StrEnum):
|
|
42
|
+
"""Bug severity levels (Sentry-inspired)."""
|
|
43
|
+
|
|
44
|
+
P0 = "P0"
|
|
45
|
+
P1 = "P1"
|
|
46
|
+
P2 = "P2"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class DefectType(StrEnum):
|
|
50
|
+
"""Defect classification from ProcCtrlBench — 11 types."""
|
|
51
|
+
|
|
52
|
+
PARAM_ERROR = "PARAM_ERROR"
|
|
53
|
+
PERMISSION_BREACH = "PERMISSION_BREACH"
|
|
54
|
+
TIMEOUT = "TIMEOUT"
|
|
55
|
+
DEPENDENCY_MISSING = "DEPENDENCY_MISSING"
|
|
56
|
+
EXECUTION_FAILURE = "EXECUTION_FAILURE"
|
|
57
|
+
OUTPUT_INVALID = "OUTPUT_INVALID"
|
|
58
|
+
STATE_CORRUPTION = "STATE_CORRUPTION"
|
|
59
|
+
RESOURCE_EXHAUSTION = "RESOURCE_EXHAUSTION"
|
|
60
|
+
GATE_DENIED = "GATE_DENIED"
|
|
61
|
+
PROTOCOL_ERROR = "PROTOCOL_ERROR"
|
|
62
|
+
UNKNOWN = "UNKNOWN"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class BugRecord:
|
|
67
|
+
"""Immutable bug record produced by the 4-stage pipeline."""
|
|
68
|
+
|
|
69
|
+
bug_id: str
|
|
70
|
+
timestamp: str
|
|
71
|
+
severity: BugSeverity
|
|
72
|
+
defect_type: DefectType
|
|
73
|
+
message: str
|
|
74
|
+
skill_id: str = ""
|
|
75
|
+
trace_id: str = ""
|
|
76
|
+
traceback: str = ""
|
|
77
|
+
context: dict[str, Any] = field(default_factory=dict)
|
|
78
|
+
|
|
79
|
+
def to_dict(self) -> dict[str, Any]:
|
|
80
|
+
"""Serialize to dict (JSON-safe)."""
|
|
81
|
+
d = asdict(self)
|
|
82
|
+
d["severity"] = self.severity.value
|
|
83
|
+
d["defect_type"] = self.defect_type.value
|
|
84
|
+
return d
|
|
85
|
+
|
|
86
|
+
def to_jsonl(self) -> str:
|
|
87
|
+
"""Serialize to single JSON line for JSONL persistence."""
|
|
88
|
+
return json.dumps(self.to_dict(), sort_keys=True, ensure_ascii=False)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class BugCollector:
|
|
92
|
+
"""4-stage bug collection pipeline: Capture -> Enrich -> Filter -> Persist.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
audit_layer: Optional AuditLayer instance for hash chain persistence.
|
|
96
|
+
sample_rate: Fraction of bugs to persist (1.0 = all, 0.1 = 10%).
|
|
97
|
+
before_persist: Optional hook called before persist; return False to drop.
|
|
98
|
+
log_dir: Override for persistence directory (default: ~/.skillpool/logs).
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
audit_layer: Any | None = None,
|
|
104
|
+
sample_rate: float = 1.0,
|
|
105
|
+
before_persist: Callable[[BugRecord], bool] | None = None,
|
|
106
|
+
log_dir: Path | None = None,
|
|
107
|
+
) -> None:
|
|
108
|
+
self._audit = audit_layer
|
|
109
|
+
self._sample_rate = max(0.0, min(1.0, sample_rate))
|
|
110
|
+
self._before_persist = before_persist
|
|
111
|
+
self._log_dir = log_dir or get_data_dir() / "logs"
|
|
112
|
+
self._bugs: list[BugRecord] = []
|
|
113
|
+
self._original_excepthook: Any = None
|
|
114
|
+
self._rng = random.Random(42)
|
|
115
|
+
|
|
116
|
+
# ── Stage 1: Capture ──
|
|
117
|
+
|
|
118
|
+
def record(
|
|
119
|
+
self,
|
|
120
|
+
severity: BugSeverity,
|
|
121
|
+
defect_type: DefectType,
|
|
122
|
+
message: str,
|
|
123
|
+
skill_id: str | None = None,
|
|
124
|
+
context: dict[str, Any] | None = None,
|
|
125
|
+
) -> BugRecord:
|
|
126
|
+
"""Manually record a bug (Capture stage entry point).
|
|
127
|
+
|
|
128
|
+
Runs the full 4-stage pipeline and returns the BugRecord
|
|
129
|
+
(even if filtered out from persistence).
|
|
130
|
+
"""
|
|
131
|
+
rec = self._create_record(
|
|
132
|
+
severity=severity,
|
|
133
|
+
defect_type=defect_type,
|
|
134
|
+
message=message,
|
|
135
|
+
skill_id=skill_id or "",
|
|
136
|
+
tb="",
|
|
137
|
+
context=context or {},
|
|
138
|
+
)
|
|
139
|
+
return self._pipeline(rec)
|
|
140
|
+
|
|
141
|
+
def capture_exception(
|
|
142
|
+
self,
|
|
143
|
+
exc: BaseException,
|
|
144
|
+
skill_id: str | None = None,
|
|
145
|
+
) -> BugRecord:
|
|
146
|
+
"""Capture an exception with auto-extracted traceback.
|
|
147
|
+
|
|
148
|
+
Maps exception type to DefectType heuristically.
|
|
149
|
+
"""
|
|
150
|
+
tb_str = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
|
|
151
|
+
defect_type = self._classify_exception(exc)
|
|
152
|
+
severity = self._severity_from_exception(exc)
|
|
153
|
+
|
|
154
|
+
rec = self._create_record(
|
|
155
|
+
severity=severity,
|
|
156
|
+
defect_type=defect_type,
|
|
157
|
+
message=str(exc),
|
|
158
|
+
skill_id=skill_id or "",
|
|
159
|
+
tb=tb_str,
|
|
160
|
+
context={"exc_type": type(exc).__name__},
|
|
161
|
+
)
|
|
162
|
+
return self._pipeline(rec)
|
|
163
|
+
|
|
164
|
+
def install_excepthook(self) -> None:
|
|
165
|
+
"""Install sys.excepthook to auto-capture unhandled exceptions."""
|
|
166
|
+
if self._original_excepthook is not None:
|
|
167
|
+
return # Already installed
|
|
168
|
+
self._original_excepthook = sys.excepthook
|
|
169
|
+
collector = self
|
|
170
|
+
|
|
171
|
+
def _hook(exc_type, exc_value, exc_tb): # type: ignore[no-untyped-def]
|
|
172
|
+
# KeyboardInterrupt/SystemExit are control flow signals, not bugs
|
|
173
|
+
if issubclass(exc_type, (KeyboardInterrupt, SystemExit)):
|
|
174
|
+
if collector._original_excepthook is not None:
|
|
175
|
+
collector._original_excepthook(exc_type, exc_value, exc_tb)
|
|
176
|
+
return
|
|
177
|
+
try:
|
|
178
|
+
tb_str = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
|
|
179
|
+
defect_type = collector._classify_exception(exc_value)
|
|
180
|
+
severity = collector._severity_from_exception(exc_value)
|
|
181
|
+
rec = collector._create_record(
|
|
182
|
+
severity=severity,
|
|
183
|
+
defect_type=defect_type,
|
|
184
|
+
message=str(exc_value),
|
|
185
|
+
skill_id="",
|
|
186
|
+
tb=tb_str,
|
|
187
|
+
context={"exc_type": exc_type.__name__, "source": "excepthook"},
|
|
188
|
+
)
|
|
189
|
+
collector._pipeline(rec)
|
|
190
|
+
except Exception as e:
|
|
191
|
+
logger.warning("BugCollector excepthook failed: %s", e) # Never let the hook itself crash
|
|
192
|
+
finally:
|
|
193
|
+
if collector._original_excepthook is not None:
|
|
194
|
+
collector._original_excepthook(exc_type, exc_value, exc_tb)
|
|
195
|
+
|
|
196
|
+
sys.excepthook = _hook
|
|
197
|
+
|
|
198
|
+
def uninstall_excepthook(self) -> None:
|
|
199
|
+
"""Restore original sys.excepthook."""
|
|
200
|
+
if self._original_excepthook is not None:
|
|
201
|
+
sys.excepthook = self._original_excepthook
|
|
202
|
+
self._original_excepthook = None
|
|
203
|
+
|
|
204
|
+
# ── Query ──
|
|
205
|
+
|
|
206
|
+
def get_bugs(
|
|
207
|
+
self,
|
|
208
|
+
severity: BugSeverity | None = None,
|
|
209
|
+
defect_type: DefectType | None = None,
|
|
210
|
+
skill_id: str | None = None,
|
|
211
|
+
) -> list[BugRecord]:
|
|
212
|
+
"""Query collected bugs with optional filters."""
|
|
213
|
+
bugs = self._bugs
|
|
214
|
+
if severity is not None:
|
|
215
|
+
bugs = [b for b in bugs if b.severity == severity]
|
|
216
|
+
if defect_type is not None:
|
|
217
|
+
bugs = [b for b in bugs if b.defect_type == defect_type]
|
|
218
|
+
if skill_id is not None:
|
|
219
|
+
bugs = [b for b in bugs if b.skill_id == skill_id]
|
|
220
|
+
return bugs
|
|
221
|
+
|
|
222
|
+
def get_stats(self) -> dict[str, Any]:
|
|
223
|
+
"""Return aggregate counts by severity and defect_type."""
|
|
224
|
+
by_severity: dict[str, int] = {}
|
|
225
|
+
by_defect: dict[str, int] = {}
|
|
226
|
+
for bug in self._bugs:
|
|
227
|
+
by_severity[bug.severity.value] = by_severity.get(bug.severity.value, 0) + 1
|
|
228
|
+
by_defect[bug.defect_type.value] = by_defect.get(bug.defect_type.value, 0) + 1
|
|
229
|
+
return {
|
|
230
|
+
"total": len(self._bugs),
|
|
231
|
+
"by_severity": by_severity,
|
|
232
|
+
"by_defect_type": by_defect,
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
# ── Internal: Pipeline ──
|
|
236
|
+
|
|
237
|
+
def _create_record(
|
|
238
|
+
self,
|
|
239
|
+
severity: BugSeverity,
|
|
240
|
+
defect_type: DefectType,
|
|
241
|
+
message: str,
|
|
242
|
+
skill_id: str,
|
|
243
|
+
tb: str,
|
|
244
|
+
context: dict[str, Any],
|
|
245
|
+
) -> BugRecord:
|
|
246
|
+
"""Create a BugRecord with auto-generated id and timestamp."""
|
|
247
|
+
return BugRecord(
|
|
248
|
+
bug_id=f"bug-{uuid.uuid4().hex[:12]}",
|
|
249
|
+
timestamp=utc_now().isoformat(),
|
|
250
|
+
severity=severity,
|
|
251
|
+
defect_type=defect_type,
|
|
252
|
+
message=message,
|
|
253
|
+
skill_id=skill_id,
|
|
254
|
+
trace_id="",
|
|
255
|
+
traceback=tb,
|
|
256
|
+
context=context,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
def _pipeline(self, record: BugRecord) -> BugRecord:
|
|
260
|
+
"""Run the 4-stage pipeline: Capture -> Enrich -> Filter -> Persist."""
|
|
261
|
+
# Stage 2: Enrich
|
|
262
|
+
record = self._enrich(record)
|
|
263
|
+
# Always store in-memory (regardless of filter outcome)
|
|
264
|
+
self._bugs.append(record)
|
|
265
|
+
# Stage 3: Filter
|
|
266
|
+
if not self._filter(record):
|
|
267
|
+
return record
|
|
268
|
+
# Stage 4: Persist
|
|
269
|
+
self._persist(record)
|
|
270
|
+
return record
|
|
271
|
+
|
|
272
|
+
# ── Stage 2: Enrich ──
|
|
273
|
+
|
|
274
|
+
def _enrich(self, record: BugRecord) -> BugRecord:
|
|
275
|
+
"""Auto-attach trace_id and context from environment."""
|
|
276
|
+
if not record.trace_id:
|
|
277
|
+
record.trace_id = os.urandom(16).hex()
|
|
278
|
+
|
|
279
|
+
env_info: dict[str, str] = {}
|
|
280
|
+
if "SKILLPOOL_ENV" in os.environ:
|
|
281
|
+
env_info["env"] = os.environ["SKILLPOOL_ENV"]
|
|
282
|
+
if env_info:
|
|
283
|
+
record.context.update(env_info)
|
|
284
|
+
|
|
285
|
+
return record
|
|
286
|
+
|
|
287
|
+
# ── Stage 3: Filter ──
|
|
288
|
+
|
|
289
|
+
def _filter(self, record: BugRecord) -> bool:
|
|
290
|
+
"""Determine if a record should be persisted.
|
|
291
|
+
|
|
292
|
+
Returns True if the record passes both sampling and the before_persist hook.
|
|
293
|
+
"""
|
|
294
|
+
if self._sample_rate < 1.0 and self._rng.random() >= self._sample_rate:
|
|
295
|
+
return False
|
|
296
|
+
|
|
297
|
+
if self._before_persist is not None:
|
|
298
|
+
try:
|
|
299
|
+
return self._before_persist(record)
|
|
300
|
+
except Exception as e:
|
|
301
|
+
logger.warning("before_persist hook failed, persisting by default: %s", e)
|
|
302
|
+
return True # Hook error -> persist by default
|
|
303
|
+
|
|
304
|
+
return True
|
|
305
|
+
|
|
306
|
+
# ── Stage 4: Persist ──
|
|
307
|
+
|
|
308
|
+
def _persist(self, record: BugRecord) -> None:
|
|
309
|
+
"""Write to JSONL file and append to AuditLayer hash chain.
|
|
310
|
+
|
|
311
|
+
Uses fsync for crash safety — partial lines from crashes are
|
|
312
|
+
truncated on recovery by downstream consumers.
|
|
313
|
+
"""
|
|
314
|
+
# JSONL persistence
|
|
315
|
+
try:
|
|
316
|
+
self._log_dir.mkdir(parents=True, exist_ok=True)
|
|
317
|
+
log_path = self._log_dir / "bugs.jsonl"
|
|
318
|
+
line = record.to_jsonl() + "\n"
|
|
319
|
+
with open(log_path, "a", encoding="utf-8") as f:
|
|
320
|
+
f.write(line)
|
|
321
|
+
f.flush()
|
|
322
|
+
os.fsync(f.fileno())
|
|
323
|
+
except OSError:
|
|
324
|
+
pass # Filesystem errors should not crash the pipeline
|
|
325
|
+
|
|
326
|
+
# Audit chain persistence
|
|
327
|
+
if self._audit is not None:
|
|
328
|
+
try:
|
|
329
|
+
self._audit.append(
|
|
330
|
+
action="bug_collected",
|
|
331
|
+
object_id=record.bug_id,
|
|
332
|
+
result=record.severity.value,
|
|
333
|
+
reason=record.message[:200],
|
|
334
|
+
severity=self._map_severity(record.severity),
|
|
335
|
+
metadata=record.to_dict(),
|
|
336
|
+
trace_id=record.trace_id,
|
|
337
|
+
)
|
|
338
|
+
except Exception as e:
|
|
339
|
+
logger.warning(
|
|
340
|
+
"Audit record failed for bug %s: %s", record.bug_id, e
|
|
341
|
+
) # Audit errors should not crash the pipeline
|
|
342
|
+
|
|
343
|
+
@staticmethod
|
|
344
|
+
def _map_severity(severity: BugSeverity) -> str:
|
|
345
|
+
"""Map BugSeverity to AuditLayer severity string."""
|
|
346
|
+
mapping = {
|
|
347
|
+
BugSeverity.P0: "CRITICAL",
|
|
348
|
+
BugSeverity.P1: "ERROR",
|
|
349
|
+
BugSeverity.P2: "WARN",
|
|
350
|
+
}
|
|
351
|
+
return mapping.get(severity, "INFO")
|
|
352
|
+
|
|
353
|
+
# ── Exception classification ──
|
|
354
|
+
|
|
355
|
+
@staticmethod
|
|
356
|
+
def _classify_exception(exc: BaseException) -> DefectType:
|
|
357
|
+
"""Heuristically map exception type to DefectType."""
|
|
358
|
+
exc_name = type(exc).__name__
|
|
359
|
+
mapping: dict[str, DefectType] = {
|
|
360
|
+
"TimeoutError": DefectType.TIMEOUT,
|
|
361
|
+
"ConnectionTimeoutError": DefectType.TIMEOUT,
|
|
362
|
+
"PermissionError": DefectType.PERMISSION_BREACH,
|
|
363
|
+
"PermissionDenied": DefectType.PERMISSION_BREACH,
|
|
364
|
+
"ImportError": DefectType.DEPENDENCY_MISSING,
|
|
365
|
+
"ModuleNotFoundError": DefectType.DEPENDENCY_MISSING,
|
|
366
|
+
"FileNotFoundError": DefectType.DEPENDENCY_MISSING,
|
|
367
|
+
"ValueError": DefectType.PARAM_ERROR,
|
|
368
|
+
"TypeError": DefectType.PARAM_ERROR,
|
|
369
|
+
"KeyError": DefectType.PARAM_ERROR,
|
|
370
|
+
"AssertionError": DefectType.OUTPUT_INVALID,
|
|
371
|
+
"RuntimeError": DefectType.EXECUTION_FAILURE,
|
|
372
|
+
"OSError": DefectType.RESOURCE_EXHAUSTION,
|
|
373
|
+
"MemoryError": DefectType.RESOURCE_EXHAUSTION,
|
|
374
|
+
"ConnectionRefusedError": DefectType.RESOURCE_EXHAUSTION,
|
|
375
|
+
}
|
|
376
|
+
return mapping.get(exc_name, DefectType.UNKNOWN)
|
|
377
|
+
|
|
378
|
+
@staticmethod
|
|
379
|
+
def _severity_from_exception(exc: BaseException) -> BugSeverity:
|
|
380
|
+
"""Heuristically determine severity from exception type.
|
|
381
|
+
|
|
382
|
+
KeyboardInterrupt and SystemExit are excluded from bug collection
|
|
383
|
+
entirely (handled in the excepthook), so this method only maps
|
|
384
|
+
actual defect exceptions.
|
|
385
|
+
"""
|
|
386
|
+
critical_types = (MemoryError,)
|
|
387
|
+
if isinstance(exc, critical_types):
|
|
388
|
+
return BugSeverity.P0
|
|
389
|
+
error_types = (PermissionError, ImportError, ModuleNotFoundError)
|
|
390
|
+
if isinstance(exc, error_types):
|
|
391
|
+
return BugSeverity.P1
|
|
392
|
+
return BugSeverity.P2
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Defect Classifier — ProcCtrlBench 11-type defect ontology.
|
|
2
|
+
|
|
3
|
+
Auto-classifies exceptions into structured defect types for monitoring
|
|
4
|
+
and self-healing feedback loops.
|
|
5
|
+
|
|
6
|
+
Uses MRO-based exception matching (more precise than string-based matching
|
|
7
|
+
in BugCollector) and context-aware severity escalation.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"DefectClassifier",
|
|
14
|
+
"DefectType",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
from enum import StrEnum
|
|
19
|
+
|
|
20
|
+
from skillpool.monitor.bug_collector import BugSeverity
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DefectType(StrEnum):
|
|
24
|
+
"""11 defect types from ProcCtrlBench ontology.
|
|
25
|
+
|
|
26
|
+
Uses lowercase snake_case values (distinct from BugCollector's
|
|
27
|
+
UPPER_CASE DefectType for string-based matching). This enum is
|
|
28
|
+
for class-based MRO classification via DefectClassifier.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
PARAM_ERROR = "param_error"
|
|
32
|
+
PERMISSION_BREACH = "permission_breach"
|
|
33
|
+
TIMEOUT = "timeout"
|
|
34
|
+
DEPENDENCY_MISSING = "dependency_missing"
|
|
35
|
+
EXECUTION_FAILURE = "execution_failure"
|
|
36
|
+
OUTPUT_INVALID = "output_invalid"
|
|
37
|
+
STATE_CORRUPTION = "state_corruption"
|
|
38
|
+
RESOURCE_EXHAUSTION = "resource_exhaustion"
|
|
39
|
+
GATE_DENIED = "gate_denied"
|
|
40
|
+
PROTOCOL_ERROR = "protocol_error"
|
|
41
|
+
UNKNOWN = "unknown"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Lazy import for domain-specific exceptions that live in other modules.
|
|
45
|
+
# Avoids circular imports at module load time.
|
|
46
|
+
def _get_domain_exceptions() -> dict[type[Exception], DefectType]:
|
|
47
|
+
"""Resolve domain-specific exception mappings lazily."""
|
|
48
|
+
mapping: dict[type[Exception], DefectType] = {}
|
|
49
|
+
try:
|
|
50
|
+
from skillpool.registry import (
|
|
51
|
+
SkillNotFoundError,
|
|
52
|
+
SupplyChainEvidenceMissingError,
|
|
53
|
+
IllegalStateTransitionError,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
mapping[SkillNotFoundError] = DefectType.DEPENDENCY_MISSING
|
|
57
|
+
mapping[SupplyChainEvidenceMissingError] = DefectType.PERMISSION_BREACH
|
|
58
|
+
mapping[IllegalStateTransitionError] = DefectType.STATE_CORRUPTION
|
|
59
|
+
except ImportError:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
from skillpool.audit import AuditUnavailableError
|
|
64
|
+
|
|
65
|
+
mapping[AuditUnavailableError] = DefectType.GATE_DENIED
|
|
66
|
+
except ImportError:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
return mapping
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# Severity heuristics: defect type → default severity when no context
|
|
73
|
+
_DEFAULT_SEVERITY: dict[DefectType, BugSeverity] = {
|
|
74
|
+
DefectType.RESOURCE_EXHAUSTION: BugSeverity.P0,
|
|
75
|
+
DefectType.STATE_CORRUPTION: BugSeverity.P0,
|
|
76
|
+
DefectType.PERMISSION_BREACH: BugSeverity.P1,
|
|
77
|
+
DefectType.GATE_DENIED: BugSeverity.P1,
|
|
78
|
+
DefectType.TIMEOUT: BugSeverity.P2,
|
|
79
|
+
DefectType.EXECUTION_FAILURE: BugSeverity.P2,
|
|
80
|
+
DefectType.DEPENDENCY_MISSING: BugSeverity.P2,
|
|
81
|
+
DefectType.OUTPUT_INVALID: BugSeverity.P2,
|
|
82
|
+
DefectType.PARAM_ERROR: BugSeverity.P2,
|
|
83
|
+
DefectType.PROTOCOL_ERROR: BugSeverity.P2,
|
|
84
|
+
DefectType.UNKNOWN: BugSeverity.P2,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# One-line fix suggestions per defect type
|
|
88
|
+
_FIX_SUGGESTIONS: dict[DefectType, str] = {
|
|
89
|
+
DefectType.PARAM_ERROR: "Validate input parameters against the skill contract before execution.",
|
|
90
|
+
DefectType.PERMISSION_BREACH: "Check agent trust level and supply chain evidence before access.",
|
|
91
|
+
DefectType.TIMEOUT: "Increase timeout threshold or add async cancellation with fallback.",
|
|
92
|
+
DefectType.DEPENDENCY_MISSING: "Verify all skill dependencies are registered and in ACTIVE state.",
|
|
93
|
+
DefectType.EXECUTION_FAILURE: "Add defensive error handling and retry with exponential backoff.",
|
|
94
|
+
DefectType.OUTPUT_INVALID: "Add output contract validation (schema check) after execution.",
|
|
95
|
+
DefectType.STATE_CORRUPTION: "Reset skill lifecycle state and re-validate transition legality.",
|
|
96
|
+
DefectType.RESOURCE_EXHAUSTION: "Enforce resource budgets via Token Governor and rate limiting.",
|
|
97
|
+
DefectType.GATE_DENIED: "Review gate complexity score and escalate or adjust agent capabilities.",
|
|
98
|
+
DefectType.PROTOCOL_ERROR: "Verify MCP protocol version and message schema compliance.",
|
|
99
|
+
DefectType.UNKNOWN: "Add structured error context to enable classification on recurrence.",
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
# Context-based severity escalation rules
|
|
103
|
+
_SEVERITY_ESCALATION_KEYS: dict[str, BugSeverity] = {
|
|
104
|
+
"production": BugSeverity.P0,
|
|
105
|
+
"critical_path": BugSeverity.P0,
|
|
106
|
+
"data_loss": BugSeverity.P0,
|
|
107
|
+
"security_breach": BugSeverity.P0,
|
|
108
|
+
"user_facing": BugSeverity.P1,
|
|
109
|
+
"retry_exhausted": BugSeverity.P1,
|
|
110
|
+
"cascade": BugSeverity.P1,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class DefectClassifier:
|
|
115
|
+
"""Auto-classify exceptions into defect types.
|
|
116
|
+
|
|
117
|
+
Uses a two-tier mapping: built-in Python exceptions (static) and
|
|
118
|
+
domain-specific SkillPool exceptions (lazy-loaded to avoid circular imports).
|
|
119
|
+
|
|
120
|
+
Unlike BugCollector's string-based _classify_exception, this classifier
|
|
121
|
+
walks the MRO for precise matching and supports context-aware severity
|
|
122
|
+
escalation.
|
|
123
|
+
|
|
124
|
+
Usage:
|
|
125
|
+
classifier = DefectClassifier()
|
|
126
|
+
defect = classifier.classify(ValueError("bad param"))
|
|
127
|
+
defect_type, severity = classifier.classify_with_context(
|
|
128
|
+
TimeoutError("slow"), {"production": True}
|
|
129
|
+
)
|
|
130
|
+
hint = classifier.suggest_fix(defect)
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
# Built-in exception → DefectType mapping (always available)
|
|
134
|
+
EXCEPTION_MAP: dict[type[Exception], DefectType] = {
|
|
135
|
+
TypeError: DefectType.PARAM_ERROR,
|
|
136
|
+
ValueError: DefectType.PARAM_ERROR,
|
|
137
|
+
KeyError: DefectType.PARAM_ERROR,
|
|
138
|
+
PermissionError: DefectType.PERMISSION_BREACH,
|
|
139
|
+
TimeoutError: DefectType.TIMEOUT,
|
|
140
|
+
asyncio.TimeoutError: DefectType.TIMEOUT,
|
|
141
|
+
ImportError: DefectType.DEPENDENCY_MISSING,
|
|
142
|
+
ModuleNotFoundError: DefectType.DEPENDENCY_MISSING,
|
|
143
|
+
FileNotFoundError: DefectType.DEPENDENCY_MISSING,
|
|
144
|
+
RuntimeError: DefectType.EXECUTION_FAILURE,
|
|
145
|
+
AssertionError: DefectType.OUTPUT_INVALID,
|
|
146
|
+
MemoryError: DefectType.RESOURCE_EXHAUSTION,
|
|
147
|
+
ConnectionError: DefectType.PROTOCOL_ERROR,
|
|
148
|
+
OSError: DefectType.EXECUTION_FAILURE,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
def __init__(self) -> None:
|
|
152
|
+
self._domain_map: dict[type[Exception], DefectType] | None = None
|
|
153
|
+
|
|
154
|
+
def _full_map(self) -> dict[type[Exception], DefectType]:
|
|
155
|
+
"""Return merged mapping (built-in + domain-specific)."""
|
|
156
|
+
if self._domain_map is None:
|
|
157
|
+
self._domain_map = {**self.EXCEPTION_MAP, **_get_domain_exceptions()}
|
|
158
|
+
return self._domain_map
|
|
159
|
+
|
|
160
|
+
def classify(self, exception: Exception) -> DefectType:
|
|
161
|
+
"""Classify an exception into a DefectType.
|
|
162
|
+
|
|
163
|
+
Walks the MRO of the exception class to find the most specific
|
|
164
|
+
matching defect type.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
exception: The exception to classify.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
DefectType enum value.
|
|
171
|
+
"""
|
|
172
|
+
mapping = self._full_map()
|
|
173
|
+
for cls in type(exception).__mro__:
|
|
174
|
+
if cls in mapping:
|
|
175
|
+
return mapping[cls]
|
|
176
|
+
return DefectType.UNKNOWN
|
|
177
|
+
|
|
178
|
+
def classify_with_context(
|
|
179
|
+
self,
|
|
180
|
+
exception: Exception,
|
|
181
|
+
context: dict,
|
|
182
|
+
) -> tuple[DefectType, BugSeverity]:
|
|
183
|
+
"""Classify an exception and determine severity with context.
|
|
184
|
+
|
|
185
|
+
Context keys that escalate severity:
|
|
186
|
+
- production / critical_path / data_loss / security_breach → P0
|
|
187
|
+
- user_facing / retry_exhausted / cascade → P1
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
exception: The exception to classify.
|
|
191
|
+
context: Additional context for severity determination.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
Tuple of (DefectType, BugSeverity).
|
|
195
|
+
"""
|
|
196
|
+
defect_type = self.classify(exception)
|
|
197
|
+
severity = _DEFAULT_SEVERITY[defect_type]
|
|
198
|
+
|
|
199
|
+
# Escalate based on context
|
|
200
|
+
for key, escalated in _SEVERITY_ESCALATION_KEYS.items():
|
|
201
|
+
if context.get(key):
|
|
202
|
+
# Only escalate, never de-escalate
|
|
203
|
+
if escalated.value < severity.value:
|
|
204
|
+
severity = escalated
|
|
205
|
+
break
|
|
206
|
+
|
|
207
|
+
return defect_type, severity
|
|
208
|
+
|
|
209
|
+
def suggest_fix(self, defect_type: DefectType) -> str:
|
|
210
|
+
"""Return a one-line fix suggestion for a defect type.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
defect_type: The classified defect type.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
Human-readable fix suggestion string.
|
|
217
|
+
"""
|
|
218
|
+
return _FIX_SUGGESTIONS.get(defect_type, _FIX_SUGGESTIONS[DefectType.UNKNOWN])
|