agentdebugx 0.2.9__tar.gz → 0.2.11__tar.gz
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.
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/PKG-INFO +1 -1
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/docs/23_status_v0_2.md +4 -3
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/pyproject.toml +1 -1
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/src/agentdebug/__init__.py +7 -1
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/src/agentdebug/attribution.py +213 -4
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/src/agentdebug/detectors.py +95 -1
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/src/agentdebug/llm.py +57 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/LICENSE +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/README.md +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/docs/00_overview.md +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/docs/01_literature_survey.md +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/docs/02_architecture.md +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/docs/03_taxonomy.md +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/docs/04_trace_schema.md +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/docs/05_adapters.md +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/docs/06_detectors.md +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/docs/07_attribution.md +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/docs/08_recovery.md +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/docs/09_error_database.md +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/docs/10_taxonomy_induction.md +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/docs/11_multimodal.md +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/docs/12_ui_dashboard.md +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/docs/13_class_design.md +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/docs/14_api_reference.md +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/docs/15_roadmap.md +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/docs/16_governance.md +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/docs/17_claude_code_design_patterns.md +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/docs/18_comparison_codex_vs_design.md +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/docs/19_error_hub.md +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/docs/20_deep_debug.md +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/docs/21_integrations.md +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/docs/22_industry_track_paper_eval_plan.md +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/docs/ERROR_TAXONOMY.md +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/docs/OPEN_SOURCE_DEVELOPMENT_PLAN.md +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/docs/README.md +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/docs/RESEARCH_SURVEY.md +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/docs/benchmarks/e2e_v0_2_3.md +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/docs/benchmarks/e2e_v0_2_4.md +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/docs/benchmarks/v0_1_smoke.json +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/docs/benchmarks/v0_1_smoke.md +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/docs/benchmarks/who_when_v0_2_6_leaderboard.md +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/src/agentdebug/adapters/__init__.py +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/src/agentdebug/adapters/base.py +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/src/agentdebug/adapters/crewai.py +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/src/agentdebug/adapters/langgraph.py +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/src/agentdebug/adapters/otel.py +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/src/agentdebug/adapters/raw.py +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/src/agentdebug/analyzers.py +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/src/agentdebug/cli.py +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/src/agentdebug/deep.py +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/src/agentdebug/events.py +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/src/agentdebug/hub/__init__.py +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/src/agentdebug/hub/backend_base.py +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/src/agentdebug/hub/backends.py +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/src/agentdebug/hub/bundle.py +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/src/agentdebug/hub/scrub.py +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/src/agentdebug/instrumentation.py +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/src/agentdebug/integrations/__init__.py +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/src/agentdebug/integrations/claude_skill.py +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/src/agentdebug/integrations/openhands.py +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/src/agentdebug/judges.py +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/src/agentdebug/models.py +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/src/agentdebug/recorder.py +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/src/agentdebug/recovery.py +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/src/agentdebug/storage.py +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/src/agentdebug/taxonomy.py +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/src/agentdebug/traceback.py +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/src/agentdebug/ui/__init__.py +0 -0
- {agentdebugx-0.2.9 → agentdebugx-0.2.11}/src/agentdebug/ui/server.py +0 -0
|
@@ -23,6 +23,7 @@ the forward-looking plan; this doc is the rear-view mirror.
|
|
|
23
23
|
| Attribution | `agentdebug.attribution.BinarySearchAttributor` | ✅ **new 0.2.3** | oracle-LLM logarithmic convergence + fallback + render elision |
|
|
24
24
|
| Attribution | `agentdebug.attribution.CounterfactualAttributor` | ✅ **new 0.2.7** | scripted-rescue-prob ranking + candidate selection priority (findings → errors → tail) + dual fallback (no candidates / silent LLM) |
|
|
25
25
|
| Attribution | `agentdebug.attribution.SBFLAttributor` (Tarantula / Ochiai / DStar) | ✅ **new 0.2.8** | signature design + ranking on synthetic corpus + 3 formulas + dual fallback + edge cases. Model-free: zero LLM cost at inference. |
|
|
26
|
+
| Attribution | `agentdebug.attribution.EnsembleAttributor` (+ `AttributionBudget`) | ✅ **new 0.2.10** | Borda + Bayesian merge, weighted backends, walltime/backend-count budget, swallows backend exceptions, falls back when all empty, honest per-source provenance |
|
|
26
27
|
| Recovery | `agentdebug.recovery.ReflexionSuggestion` | ✅ stable | per-finding + empty |
|
|
27
28
|
| Recovery | `agentdebug.recovery.CriticRecoverer` + `VerifierSpec` registry | ✅ **new 0.2.3** | 5 family-matched verifier templates; dedup + custom-override |
|
|
28
29
|
| Recovery | `agentdebug.recovery.SelfRefineLoop` | ✅ **new 0.2.9** | scripted-LLM happy + silent-LLM safe + multi-iter; per-finding generator→critic→refiner |
|
|
@@ -30,6 +31,8 @@ the forward-looking plan; this doc is the rear-view mirror.
|
|
|
30
31
|
| DeepDebug | `agentdebug.deep.DeepDebugAnalyzer` | ✅ stable | full loop + silent LLM |
|
|
31
32
|
| Cascade view | `agentdebug.traceback.format_traceback` | ✅ stable | cascade + step-order + ANSI + empty |
|
|
32
33
|
| Detectors | `agentdebug.detectors.RepeatedToolCall / RepeatedState / StepCountLimit` | ✅ **new 0.2.2** | threshold + window + budget |
|
|
34
|
+
| Detectors | `agentdebug.detectors.TopicDriftDetector` (embedding cosine vs goal) | ✅ **new 0.2.11** | stub-embedder ranking + no-goal short-circuit + embedder-raises safe + threshold boundary |
|
|
35
|
+
| LLM client | `agentdebug.llm.OpenAICompatClient.embed()` (+ `EmbeddingClient` Protocol) | ✅ **new 0.2.11** | mocked-httpx POST /v1/embeddings + empty-input short-circuit |
|
|
33
36
|
| Hub bundle | `agentdebug.hub.Bundle / pack_bundle / unpack_bundle` | ✅ stable | round-trip |
|
|
34
37
|
| Hub scrubber | `agentdebug.hub.Scrubber` | ✅ stable | 12 redactions + idempotent |
|
|
35
38
|
| Hub backends | `LocalHubBackend`, `GitHubBackend`, `HuggingFaceBackend` | ✅ stable | local-bare-git + local |
|
|
@@ -48,13 +51,11 @@ across 32 source files.
|
|
|
48
51
|
|
|
49
52
|
| Doc | Component | Why deferred | Realistic ship |
|
|
50
53
|
|---|---|---|---|
|
|
51
|
-
| [06_detectors.md](./06_detectors.md) | `trajectory_perplexity` (TrajAD) | needs token-level LM perplexity API
|
|
52
|
-
| [06_detectors.md](./06_detectors.md) | `topic_drift` (embedding cosine) | needs embedding client; consider reusing `OpenAICompatClient` `/embeddings` | v0.3 |
|
|
54
|
+
| [06_detectors.md](./06_detectors.md) | `trajectory_perplexity` (TrajAD) | needs token-level LM perplexity API; v0.3 |
|
|
53
55
|
| [06_detectors.md](./06_detectors.md) | LTL spec monitors | requires user-supplied spec or LLM-synthesized monitors; gated on RV research | v1.2 |
|
|
54
56
|
| [07_attribution.md](./07_attribution.md) | `CounterfactualAttributor` — *real* replay variant | true re-rollout requires framework-specific replay surface; the v0.2.7 LLM-simulated variant ships now, the real-replay variant is gated on adapter support (LangGraph checkpointer / OpenHands rewind) | v0.4 |
|
|
55
57
|
| [07_attribution.md](./07_attribution.md) | `SBFLAttributor` — *corpus* | shipped in 0.2.8 (`tarantula`/`ochiai`/`dstar`); awaiting paired-trace adoption to gather a useful corpus in production | corpus tooling deferred to v0.4 |
|
|
56
58
|
| [07_attribution.md](./07_attribution.md) | `DeltaDebugAttributor` (Zeller) | same replay constraint | v0.3 |
|
|
57
|
-
| [07_attribution.md](./07_attribution.md) | `EnsembleAttributor` | trivial once Counterfactual lands; awaits Counterfactual | v0.3 |
|
|
58
59
|
| [08_recovery.md](./08_recovery.md) | `LangGraphRewind` | depends on LangGraph checkpointer; ships when we have a real LangGraph user | v0.3 |
|
|
59
60
|
| [08_recovery.md](./08_recovery.md) | `SagaRollback` | needs compensation registry on tool definitions; new schema | v0.3 |
|
|
60
61
|
| [08_recovery.md](./08_recovery.md) | `MCTSBranchExploration` (LATS) | heavy; v2 feature | v2.0 |
|
|
@@ -11,11 +11,13 @@ See ``docs/14_api_reference.md`` for the full public surface and
|
|
|
11
11
|
from agentdebug.analyzers import HeuristicAnalyzer
|
|
12
12
|
from agentdebug.attribution import (
|
|
13
13
|
AllAtOnceAttributor,
|
|
14
|
+
AttributionBudget,
|
|
14
15
|
AttributionResult,
|
|
15
16
|
Attributor,
|
|
16
17
|
BinarySearchAttributor,
|
|
17
18
|
Blame,
|
|
18
19
|
CounterfactualAttributor,
|
|
20
|
+
EnsembleAttributor,
|
|
19
21
|
HeuristicAttributor,
|
|
20
22
|
SBFLAttributor,
|
|
21
23
|
StepByStepAttributor,
|
|
@@ -26,6 +28,7 @@ from agentdebug.detectors import (
|
|
|
26
28
|
RepeatedStateDetector,
|
|
27
29
|
RepeatedToolCallDetector,
|
|
28
30
|
StepCountLimitDetector,
|
|
31
|
+
TopicDriftDetector,
|
|
29
32
|
default_detectors,
|
|
30
33
|
run_detectors,
|
|
31
34
|
)
|
|
@@ -65,6 +68,7 @@ __all__ = [
|
|
|
65
68
|
'Attributor',
|
|
66
69
|
'Blame',
|
|
67
70
|
'BusEvent',
|
|
71
|
+
'AttributionBudget',
|
|
68
72
|
'AutoManualRules',
|
|
69
73
|
'BinarySearchAttributor',
|
|
70
74
|
'CascadeFrame',
|
|
@@ -73,11 +77,13 @@ __all__ = [
|
|
|
73
77
|
'DEFAULT_VERIFIERS',
|
|
74
78
|
'Detector',
|
|
75
79
|
'DetectorConfig',
|
|
80
|
+
'EnsembleAttributor',
|
|
76
81
|
'RepeatedStateDetector',
|
|
77
82
|
'RepeatedToolCallDetector',
|
|
78
83
|
'SBFLAttributor',
|
|
79
84
|
'SelfRefineLoop',
|
|
80
85
|
'StepByStepAttributor',
|
|
86
|
+
'TopicDriftDetector',
|
|
81
87
|
'StepCountLimitDetector',
|
|
82
88
|
'VerifierSpec',
|
|
83
89
|
'build_cascade',
|
|
@@ -104,4 +110,4 @@ __all__ = [
|
|
|
104
110
|
'get_failure_mode',
|
|
105
111
|
]
|
|
106
112
|
|
|
107
|
-
__version__ = '0.2.
|
|
113
|
+
__version__ = '0.2.11'
|
|
@@ -19,7 +19,7 @@ from __future__ import annotations
|
|
|
19
19
|
|
|
20
20
|
import logging
|
|
21
21
|
from dataclasses import dataclass, field
|
|
22
|
-
from typing import Any, Dict, List, Optional, Protocol, cast
|
|
22
|
+
from typing import Any, Dict, List, Optional, Protocol, Tuple, cast
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
# Forward decl so BinarySearchAttributor.attribute can reference _EllipsisEvent
|
|
@@ -936,8 +936,217 @@ def _suspiciousness(
|
|
|
936
936
|
return float((ef ** dstar_exponent) / denom)
|
|
937
937
|
|
|
938
938
|
|
|
939
|
+
@dataclass
|
|
940
|
+
class AttributionBudget:
|
|
941
|
+
"""Optional cap on ensemble cost."""
|
|
942
|
+
|
|
943
|
+
max_backends: Optional[int] = None # short-circuit after N successful backends
|
|
944
|
+
max_seconds: Optional[float] = None # walltime cap; remaining backends skipped
|
|
945
|
+
|
|
946
|
+
def exceeded(
|
|
947
|
+
self, *, completed: int, elapsed_s: float,
|
|
948
|
+
) -> bool:
|
|
949
|
+
if self.max_backends is not None and completed >= self.max_backends:
|
|
950
|
+
return True
|
|
951
|
+
if self.max_seconds is not None and elapsed_s >= self.max_seconds:
|
|
952
|
+
return True
|
|
953
|
+
return False
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
class EnsembleAttributor:
|
|
957
|
+
"""Compose any subset of attributors into a single ranked result.
|
|
958
|
+
|
|
959
|
+
Two merge strategies:
|
|
960
|
+
|
|
961
|
+
* ``"borda"`` (default) — each backend's ranked list contributes
|
|
962
|
+
Borda points (top hypothesis = N, next = N-1, …). Steps are
|
|
963
|
+
ranked by total points × backend weight; ties broken by primary
|
|
964
|
+
confidence × weight.
|
|
965
|
+
* ``"bayesian"`` — combine confidences as 1 - ∏(1 - w_i × c_i)
|
|
966
|
+
across backends that nominated the same (event_id, step_index).
|
|
967
|
+
Treats each backend as an independent noisy classifier; good when
|
|
968
|
+
backends are heterogeneous and confidences are well-calibrated.
|
|
969
|
+
|
|
970
|
+
Provenance is HONEST: every output ``Blame.sources`` lists every
|
|
971
|
+
backend that contributed; the rationale aggregates source-by-source
|
|
972
|
+
so the UI can show which backends agreed.
|
|
973
|
+
|
|
974
|
+
Pair with ``AttributionBudget`` to cap wall-clock or backend count
|
|
975
|
+
when running expensive backends (e.g., BinarySearch + Counterfactual)
|
|
976
|
+
alongside cheap ones (Heuristic + SBFL).
|
|
977
|
+
"""
|
|
978
|
+
|
|
979
|
+
id = 'ensemble'
|
|
980
|
+
|
|
981
|
+
def __init__(
|
|
982
|
+
self,
|
|
983
|
+
backends: List[Attributor],
|
|
984
|
+
*,
|
|
985
|
+
weights: Optional[Dict[str, float]] = None,
|
|
986
|
+
merge: str = 'borda',
|
|
987
|
+
top_k: int = 5,
|
|
988
|
+
budget: Optional[AttributionBudget] = None,
|
|
989
|
+
fallback: Optional[Attributor] = None,
|
|
990
|
+
) -> None:
|
|
991
|
+
if not backends:
|
|
992
|
+
raise ValueError('EnsembleAttributor requires at least one backend')
|
|
993
|
+
if merge not in {'borda', 'bayesian'}:
|
|
994
|
+
raise ValueError(
|
|
995
|
+
f"Unknown merge strategy {merge!r}; use 'borda' or 'bayesian'"
|
|
996
|
+
)
|
|
997
|
+
self.backends = list(backends)
|
|
998
|
+
self.weights = weights or {b.id: 1.0 for b in backends}
|
|
999
|
+
self.merge = merge
|
|
1000
|
+
self.top_k = top_k
|
|
1001
|
+
self.budget = budget
|
|
1002
|
+
self.fallback: Attributor = fallback or HeuristicAttributor()
|
|
1003
|
+
|
|
1004
|
+
def attribute(
|
|
1005
|
+
self,
|
|
1006
|
+
trajectory: AgentTrajectory,
|
|
1007
|
+
findings: List[FailureFinding],
|
|
1008
|
+
) -> AttributionResult:
|
|
1009
|
+
import time as _time
|
|
1010
|
+
|
|
1011
|
+
per_backend: List[Tuple[str, AttributionResult]] = []
|
|
1012
|
+
started = _time.perf_counter()
|
|
1013
|
+
for backend in self.backends:
|
|
1014
|
+
if self.budget and self.budget.exceeded(
|
|
1015
|
+
completed=len(per_backend),
|
|
1016
|
+
elapsed_s=_time.perf_counter() - started,
|
|
1017
|
+
):
|
|
1018
|
+
LOG.debug('ensemble budget hit; stopping early')
|
|
1019
|
+
break
|
|
1020
|
+
try:
|
|
1021
|
+
result = backend.attribute(trajectory, findings)
|
|
1022
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
1023
|
+
LOG.warning('ensemble backend %s raised: %s', backend.id, exc)
|
|
1024
|
+
continue
|
|
1025
|
+
per_backend.append((backend.id, result))
|
|
1026
|
+
|
|
1027
|
+
# Any backend produced hypotheses? Otherwise fall back.
|
|
1028
|
+
if not any(r.hypotheses for _id, r in per_backend):
|
|
1029
|
+
return self.fallback.attribute(trajectory, findings)
|
|
1030
|
+
|
|
1031
|
+
if self.merge == 'borda':
|
|
1032
|
+
merged = self._merge_borda(per_backend)
|
|
1033
|
+
else:
|
|
1034
|
+
merged = self._merge_bayesian(per_backend)
|
|
1035
|
+
|
|
1036
|
+
merged = merged[: self.top_k]
|
|
1037
|
+
elapsed_ms = int((_time.perf_counter() - started) * 1000)
|
|
1038
|
+
return AttributionResult(
|
|
1039
|
+
method=self.id,
|
|
1040
|
+
hypotheses=merged,
|
|
1041
|
+
elapsed_ms=elapsed_ms,
|
|
1042
|
+
raw={
|
|
1043
|
+
'merge': self.merge,
|
|
1044
|
+
'backends_run': [bid for bid, _ in per_backend],
|
|
1045
|
+
'weights': dict(self.weights),
|
|
1046
|
+
'budget_exceeded': bool(
|
|
1047
|
+
self.budget
|
|
1048
|
+
and self.budget.exceeded(
|
|
1049
|
+
completed=len(per_backend),
|
|
1050
|
+
elapsed_s=_time.perf_counter() - started,
|
|
1051
|
+
)
|
|
1052
|
+
),
|
|
1053
|
+
},
|
|
1054
|
+
)
|
|
1055
|
+
|
|
1056
|
+
# ---- merge strategies ----
|
|
1057
|
+
|
|
1058
|
+
def _merge_borda(
|
|
1059
|
+
self, per_backend: List[Tuple[str, AttributionResult]],
|
|
1060
|
+
) -> List[Blame]:
|
|
1061
|
+
# Aggregator keyed by canonical step identity.
|
|
1062
|
+
agg: Dict[Tuple[Optional[str], Optional[int]], _MergeRow] = {}
|
|
1063
|
+
for backend_id, result in per_backend:
|
|
1064
|
+
w = self.weights.get(backend_id, 1.0)
|
|
1065
|
+
n = len(result.hypotheses)
|
|
1066
|
+
for rank, h in enumerate(result.hypotheses):
|
|
1067
|
+
key = (h.span_id, h.step_index)
|
|
1068
|
+
row = agg.setdefault(key, _MergeRow(span_id=h.span_id,
|
|
1069
|
+
step_index=h.step_index))
|
|
1070
|
+
# Borda points: top = N, next = N-1, … last = 1
|
|
1071
|
+
row.borda_points += (n - rank) * w
|
|
1072
|
+
row.weighted_conf_sum += w * h.confidence
|
|
1073
|
+
row.weight_sum += w
|
|
1074
|
+
row.sources.add(backend_id)
|
|
1075
|
+
if h.agent_name and not row.agent_name:
|
|
1076
|
+
row.agent_name = h.agent_name
|
|
1077
|
+
if h.rationale and backend_id not in row.rationales:
|
|
1078
|
+
row.rationales[backend_id] = h.rationale
|
|
1079
|
+
row.evidence.extend(h.evidence)
|
|
1080
|
+
ranked = sorted(
|
|
1081
|
+
agg.values(),
|
|
1082
|
+
key=lambda r: (-r.borda_points, -r.weighted_conf_sum),
|
|
1083
|
+
)
|
|
1084
|
+
return [self._row_to_blame(r) for r in ranked]
|
|
1085
|
+
|
|
1086
|
+
def _merge_bayesian(
|
|
1087
|
+
self, per_backend: List[Tuple[str, AttributionResult]],
|
|
1088
|
+
) -> List[Blame]:
|
|
1089
|
+
agg: Dict[Tuple[Optional[str], Optional[int]], _MergeRow] = {}
|
|
1090
|
+
for backend_id, result in per_backend:
|
|
1091
|
+
w = self.weights.get(backend_id, 1.0)
|
|
1092
|
+
for h in result.hypotheses:
|
|
1093
|
+
key = (h.span_id, h.step_index)
|
|
1094
|
+
row = agg.setdefault(key, _MergeRow(span_id=h.span_id,
|
|
1095
|
+
step_index=h.step_index))
|
|
1096
|
+
# P(no source supports) *= (1 - w*c)
|
|
1097
|
+
effective = max(0.0, min(1.0, w * h.confidence))
|
|
1098
|
+
row.bayesian_not_pos *= (1.0 - effective)
|
|
1099
|
+
row.weighted_conf_sum += w * h.confidence
|
|
1100
|
+
row.weight_sum += w
|
|
1101
|
+
row.sources.add(backend_id)
|
|
1102
|
+
if h.agent_name and not row.agent_name:
|
|
1103
|
+
row.agent_name = h.agent_name
|
|
1104
|
+
if h.rationale and backend_id not in row.rationales:
|
|
1105
|
+
row.rationales[backend_id] = h.rationale
|
|
1106
|
+
row.evidence.extend(h.evidence)
|
|
1107
|
+
ranked = sorted(
|
|
1108
|
+
agg.values(),
|
|
1109
|
+
key=lambda r: (-(1.0 - r.bayesian_not_pos), -r.weighted_conf_sum),
|
|
1110
|
+
)
|
|
1111
|
+
return [self._row_to_blame(r) for r in ranked]
|
|
1112
|
+
|
|
1113
|
+
def _row_to_blame(self, row: '_MergeRow') -> Blame:
|
|
1114
|
+
if self.merge == 'borda':
|
|
1115
|
+
# Confidence = weighted average of backend confidences (cap at 1).
|
|
1116
|
+
conf = min(1.0, row.weighted_conf_sum / max(row.weight_sum, 1e-9))
|
|
1117
|
+
else:
|
|
1118
|
+
conf = max(0.0, min(1.0, 1.0 - row.bayesian_not_pos))
|
|
1119
|
+
rationale = '; '.join(
|
|
1120
|
+
f'{bid}: {txt[:140]}' for bid, txt in row.rationales.items()
|
|
1121
|
+
) or 'ensemble agreement'
|
|
1122
|
+
return Blame(
|
|
1123
|
+
span_id=row.span_id,
|
|
1124
|
+
step_index=row.step_index,
|
|
1125
|
+
agent_name=row.agent_name,
|
|
1126
|
+
confidence=conf,
|
|
1127
|
+
rationale=rationale,
|
|
1128
|
+
evidence=list(dict.fromkeys(row.evidence)), # dedupe preserve order
|
|
1129
|
+
sources=sorted(row.sources),
|
|
1130
|
+
)
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
@dataclass
|
|
1134
|
+
class _MergeRow:
|
|
1135
|
+
span_id: Optional[str]
|
|
1136
|
+
step_index: Optional[int]
|
|
1137
|
+
agent_name: Optional[str] = None
|
|
1138
|
+
borda_points: float = 0.0
|
|
1139
|
+
bayesian_not_pos: float = 1.0
|
|
1140
|
+
weighted_conf_sum: float = 0.0
|
|
1141
|
+
weight_sum: float = 0.0
|
|
1142
|
+
sources: set[str] = field(default_factory=set)
|
|
1143
|
+
rationales: Dict[str, str] = field(default_factory=dict)
|
|
1144
|
+
evidence: List[str] = field(default_factory=list)
|
|
1145
|
+
|
|
1146
|
+
|
|
939
1147
|
__all__ = [
|
|
940
|
-
'
|
|
941
|
-
'
|
|
942
|
-
'
|
|
1148
|
+
'AllAtOnceAttributor', 'AttributionBudget', 'AttributionResult',
|
|
1149
|
+
'Attributor', 'BinarySearchAttributor', 'Blame',
|
|
1150
|
+
'CounterfactualAttributor', 'EnsembleAttributor', 'HeuristicAttributor',
|
|
1151
|
+
'SBFLAttributor', 'StepByStepAttributor',
|
|
943
1152
|
]
|
|
@@ -15,7 +15,7 @@ from __future__ import annotations
|
|
|
15
15
|
|
|
16
16
|
import logging
|
|
17
17
|
from dataclasses import dataclass
|
|
18
|
-
from typing import List, Optional, Protocol
|
|
18
|
+
from typing import Any, List, Optional, Protocol
|
|
19
19
|
|
|
20
20
|
from agentdebug.models import (
|
|
21
21
|
AgentEvent,
|
|
@@ -273,12 +273,106 @@ def _suggestion(mode: FailureMode) -> Optional[str]:
|
|
|
273
273
|
return None
|
|
274
274
|
|
|
275
275
|
|
|
276
|
+
class TopicDriftDetector:
|
|
277
|
+
"""Embedding-based anomaly detector for goal drift.
|
|
278
|
+
|
|
279
|
+
Embed the trajectory's goal once; embed each user-facing event payload
|
|
280
|
+
(LLM_RESPONSE / PLAN / OBSERVATION outputs); flag any step whose cosine
|
|
281
|
+
similarity with the goal drops below ``threshold``.
|
|
282
|
+
|
|
283
|
+
Maps to ``FM-2.3 task_derailment`` (MAST) / ``planning.inefficient_plan``.
|
|
284
|
+
Closes the anomaly family from doc 06 alongside the existing
|
|
285
|
+
RepeatedToolCallDetector / RepeatedStateDetector.
|
|
286
|
+
|
|
287
|
+
Skipped silently if the embedding client raises or returns no vectors —
|
|
288
|
+
the rest of the detector pipeline is unaffected.
|
|
289
|
+
"""
|
|
290
|
+
|
|
291
|
+
id = 'topic_drift'
|
|
292
|
+
|
|
293
|
+
def __init__(
|
|
294
|
+
self,
|
|
295
|
+
embedding_client: Any,
|
|
296
|
+
*,
|
|
297
|
+
threshold: float = 0.35,
|
|
298
|
+
max_events: int = 60,
|
|
299
|
+
) -> None:
|
|
300
|
+
# embedding_client is duck-typed to EmbeddingClient to avoid an
|
|
301
|
+
# import cycle (detectors.py is imported from agentdebug/__init__.py).
|
|
302
|
+
self.embedding_client = embedding_client
|
|
303
|
+
self.threshold = threshold
|
|
304
|
+
self.max_events = max_events
|
|
305
|
+
|
|
306
|
+
def detect(self, trajectory: AgentTrajectory) -> List[FailureFinding]:
|
|
307
|
+
if not trajectory.goal:
|
|
308
|
+
return []
|
|
309
|
+
contextful = [
|
|
310
|
+
e for e in trajectory.events
|
|
311
|
+
if e.event_type in {
|
|
312
|
+
EventType.LLM_RESPONSE, EventType.PLAN, EventType.OBSERVATION,
|
|
313
|
+
EventType.LLM_RESPONSE.value, EventType.PLAN.value,
|
|
314
|
+
EventType.OBSERVATION.value,
|
|
315
|
+
}
|
|
316
|
+
and e.output is not None and str(e.output).strip()
|
|
317
|
+
]
|
|
318
|
+
if not contextful:
|
|
319
|
+
return []
|
|
320
|
+
contextful = contextful[-self.max_events:]
|
|
321
|
+
texts = [trajectory.goal] + [str(e.output)[:1000] for e in contextful]
|
|
322
|
+
try:
|
|
323
|
+
vectors = self.embedding_client.embed(texts)
|
|
324
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
325
|
+
LOG.warning('topic_drift detector embed() failed: %s', exc)
|
|
326
|
+
return []
|
|
327
|
+
if not vectors or len(vectors) != len(texts):
|
|
328
|
+
return []
|
|
329
|
+
goal_vec = vectors[0]
|
|
330
|
+
findings: List[FailureFinding] = []
|
|
331
|
+
mode = SEED_FAILURE_MODES['planning.inefficient_plan']
|
|
332
|
+
for evt, evt_vec in zip(contextful, vectors[1:]):
|
|
333
|
+
sim = _cosine(goal_vec, evt_vec)
|
|
334
|
+
if sim >= self.threshold:
|
|
335
|
+
continue
|
|
336
|
+
findings.append(FailureFinding(
|
|
337
|
+
finding_id=new_id('finding'),
|
|
338
|
+
failure_mode=mode,
|
|
339
|
+
event_id=evt.event_id,
|
|
340
|
+
agent_name=evt.agent_name,
|
|
341
|
+
step_index=evt.step_index,
|
|
342
|
+
# Confidence proportional to how far below threshold we drifted.
|
|
343
|
+
confidence=min(0.95, 0.4 + (self.threshold - sim)),
|
|
344
|
+
evidence=[
|
|
345
|
+
f'goal/output cosine={sim:.3f} < threshold={self.threshold:.2f}',
|
|
346
|
+
],
|
|
347
|
+
suggestion=_suggestion(mode),
|
|
348
|
+
metadata={
|
|
349
|
+
'source': self.id,
|
|
350
|
+
'cosine_to_goal': round(sim, 4),
|
|
351
|
+
'threshold': self.threshold,
|
|
352
|
+
},
|
|
353
|
+
))
|
|
354
|
+
return findings
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _cosine(a: List[float], b: List[float]) -> float:
|
|
358
|
+
import math
|
|
359
|
+
if not a or not b or len(a) != len(b):
|
|
360
|
+
return 0.0
|
|
361
|
+
dot = sum(x * y for x, y in zip(a, b))
|
|
362
|
+
na = math.sqrt(sum(x * x for x in a))
|
|
363
|
+
nb = math.sqrt(sum(y * y for y in b))
|
|
364
|
+
if na == 0 or nb == 0:
|
|
365
|
+
return 0.0
|
|
366
|
+
return dot / (na * nb)
|
|
367
|
+
|
|
368
|
+
|
|
276
369
|
__all__ = [
|
|
277
370
|
'Detector',
|
|
278
371
|
'DetectorConfig',
|
|
279
372
|
'RepeatedStateDetector',
|
|
280
373
|
'RepeatedToolCallDetector',
|
|
281
374
|
'StepCountLimitDetector',
|
|
375
|
+
'TopicDriftDetector',
|
|
282
376
|
'default_detectors',
|
|
283
377
|
'run_detectors',
|
|
284
378
|
]
|
|
@@ -44,6 +44,25 @@ class LLMClient(Protocol):
|
|
|
44
44
|
...
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
class EmbeddingClient(Protocol):
|
|
48
|
+
"""Subprotocol for clients that also expose ``/v1/embeddings``.
|
|
49
|
+
|
|
50
|
+
Kept separate from :class:`LLMClient` so detectors can declare a
|
|
51
|
+
narrower dependency and tests can stub embeddings without faking
|
|
52
|
+
a chat client.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
embedding_model: str
|
|
56
|
+
|
|
57
|
+
def embed(
|
|
58
|
+
self,
|
|
59
|
+
texts: List[str],
|
|
60
|
+
*,
|
|
61
|
+
timeout: float = 60.0,
|
|
62
|
+
) -> List[List[float]]:
|
|
63
|
+
...
|
|
64
|
+
|
|
65
|
+
|
|
47
66
|
class OpenAICompatClient:
|
|
48
67
|
"""OpenAI-compatible chat completions client.
|
|
49
68
|
|
|
@@ -63,12 +82,16 @@ class OpenAICompatClient:
|
|
|
63
82
|
base_url: str,
|
|
64
83
|
api_key: str,
|
|
65
84
|
model: str,
|
|
85
|
+
embedding_model: str = 'text-embedding-3-small',
|
|
66
86
|
default_max_tokens: int = 2048,
|
|
67
87
|
timeout: float = 60.0,
|
|
68
88
|
) -> None:
|
|
69
89
|
self.base_url = base_url.rstrip('/')
|
|
70
90
|
self.api_key = api_key
|
|
71
91
|
self.model = model
|
|
92
|
+
# Embeddings hit a separate endpoint with a separate model id; default
|
|
93
|
+
# to OpenAI's small embedding model since the gateway is OpenAI-compat.
|
|
94
|
+
self.embedding_model = embedding_model
|
|
72
95
|
self.default_max_tokens = default_max_tokens
|
|
73
96
|
self.timeout = timeout
|
|
74
97
|
|
|
@@ -132,6 +155,40 @@ class OpenAICompatClient:
|
|
|
132
155
|
return CompletionResult(text=text, raw=data)
|
|
133
156
|
|
|
134
157
|
|
|
158
|
+
def embed(
|
|
159
|
+
self,
|
|
160
|
+
texts: List[str],
|
|
161
|
+
*,
|
|
162
|
+
timeout: Optional[float] = None,
|
|
163
|
+
) -> List[List[float]]:
|
|
164
|
+
"""OpenAI-compatible ``/v1/embeddings`` POST.
|
|
165
|
+
|
|
166
|
+
Returns a list of vectors (one per input text) in the same order.
|
|
167
|
+
Empty ``texts`` short-circuits to ``[]`` to save a network round-trip.
|
|
168
|
+
"""
|
|
169
|
+
if not texts:
|
|
170
|
+
return []
|
|
171
|
+
url = f'{self.base_url}/embeddings'
|
|
172
|
+
headers = {
|
|
173
|
+
'Authorization': f'Bearer {self.api_key}',
|
|
174
|
+
'Content-Type': 'application/json',
|
|
175
|
+
}
|
|
176
|
+
body = {'model': self.embedding_model, 'input': list(texts)}
|
|
177
|
+
resp = httpx.post(
|
|
178
|
+
url, headers=headers, json=body, timeout=timeout or self.timeout
|
|
179
|
+
)
|
|
180
|
+
resp.raise_for_status()
|
|
181
|
+
data = resp.json()
|
|
182
|
+
rows = data.get('data') or []
|
|
183
|
+
out: List[List[float]] = []
|
|
184
|
+
for row in rows:
|
|
185
|
+
vec = row.get('embedding')
|
|
186
|
+
if not isinstance(vec, list):
|
|
187
|
+
continue
|
|
188
|
+
out.append([float(v) for v in vec])
|
|
189
|
+
return out
|
|
190
|
+
|
|
191
|
+
|
|
135
192
|
def extract_json_block(text: str) -> Optional[Dict[str, Any]]:
|
|
136
193
|
"""Extract the first top-level JSON object from a possibly-fenced response."""
|
|
137
194
|
if not text:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|