agentdebugx 0.2.10__tar.gz → 0.2.12__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.
Files changed (70) hide show
  1. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/PKG-INFO +2 -1
  2. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/docs/23_status_v0_2.md +5 -3
  3. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/pyproject.toml +3 -1
  4. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/src/agentdebug/__init__.py +9 -1
  5. agentdebugx-0.2.12/src/agentdebug/adapters/openai_agents.py +239 -0
  6. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/src/agentdebug/cli.py +6 -0
  7. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/src/agentdebug/detectors.py +95 -1
  8. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/src/agentdebug/llm.py +57 -0
  9. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/src/agentdebug/recovery.py +187 -1
  10. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/LICENSE +0 -0
  11. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/README.md +0 -0
  12. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/docs/00_overview.md +0 -0
  13. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/docs/01_literature_survey.md +0 -0
  14. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/docs/02_architecture.md +0 -0
  15. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/docs/03_taxonomy.md +0 -0
  16. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/docs/04_trace_schema.md +0 -0
  17. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/docs/05_adapters.md +0 -0
  18. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/docs/06_detectors.md +0 -0
  19. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/docs/07_attribution.md +0 -0
  20. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/docs/08_recovery.md +0 -0
  21. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/docs/09_error_database.md +0 -0
  22. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/docs/10_taxonomy_induction.md +0 -0
  23. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/docs/11_multimodal.md +0 -0
  24. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/docs/12_ui_dashboard.md +0 -0
  25. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/docs/13_class_design.md +0 -0
  26. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/docs/14_api_reference.md +0 -0
  27. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/docs/15_roadmap.md +0 -0
  28. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/docs/16_governance.md +0 -0
  29. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/docs/17_claude_code_design_patterns.md +0 -0
  30. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/docs/18_comparison_codex_vs_design.md +0 -0
  31. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/docs/19_error_hub.md +0 -0
  32. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/docs/20_deep_debug.md +0 -0
  33. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/docs/21_integrations.md +0 -0
  34. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/docs/22_industry_track_paper_eval_plan.md +0 -0
  35. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/docs/ERROR_TAXONOMY.md +0 -0
  36. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/docs/OPEN_SOURCE_DEVELOPMENT_PLAN.md +0 -0
  37. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/docs/README.md +0 -0
  38. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/docs/RESEARCH_SURVEY.md +0 -0
  39. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/docs/benchmarks/e2e_v0_2_3.md +0 -0
  40. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/docs/benchmarks/e2e_v0_2_4.md +0 -0
  41. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/docs/benchmarks/v0_1_smoke.json +0 -0
  42. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/docs/benchmarks/v0_1_smoke.md +0 -0
  43. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/docs/benchmarks/who_when_v0_2_6_leaderboard.md +0 -0
  44. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/src/agentdebug/adapters/__init__.py +0 -0
  45. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/src/agentdebug/adapters/base.py +0 -0
  46. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/src/agentdebug/adapters/crewai.py +0 -0
  47. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/src/agentdebug/adapters/langgraph.py +0 -0
  48. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/src/agentdebug/adapters/otel.py +0 -0
  49. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/src/agentdebug/adapters/raw.py +0 -0
  50. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/src/agentdebug/analyzers.py +0 -0
  51. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/src/agentdebug/attribution.py +0 -0
  52. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/src/agentdebug/deep.py +0 -0
  53. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/src/agentdebug/events.py +0 -0
  54. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/src/agentdebug/hub/__init__.py +0 -0
  55. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/src/agentdebug/hub/backend_base.py +0 -0
  56. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/src/agentdebug/hub/backends.py +0 -0
  57. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/src/agentdebug/hub/bundle.py +0 -0
  58. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/src/agentdebug/hub/scrub.py +0 -0
  59. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/src/agentdebug/instrumentation.py +0 -0
  60. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/src/agentdebug/integrations/__init__.py +0 -0
  61. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/src/agentdebug/integrations/claude_skill.py +0 -0
  62. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/src/agentdebug/integrations/openhands.py +0 -0
  63. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/src/agentdebug/judges.py +0 -0
  64. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/src/agentdebug/models.py +0 -0
  65. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/src/agentdebug/recorder.py +0 -0
  66. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/src/agentdebug/storage.py +0 -0
  67. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/src/agentdebug/taxonomy.py +0 -0
  68. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/src/agentdebug/traceback.py +0 -0
  69. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/src/agentdebug/ui/__init__.py +0 -0
  70. {agentdebugx-0.2.10 → agentdebugx-0.2.12}/src/agentdebug/ui/server.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentdebugx
3
- Version: 0.2.10
3
+ Version: 0.2.12
4
4
  Summary: Portable error analysis, tracing, and recovery framework for agentic AI systems. Import as `agentdebug`.
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -29,6 +29,7 @@ Provides-Extra: all
29
29
  Provides-Extra: crewai
30
30
  Provides-Extra: hub-hf
31
31
  Provides-Extra: langgraph
32
+ Provides-Extra: openai-agents
32
33
  Provides-Extra: openhands
33
34
  Provides-Extra: otel
34
35
  Provides-Extra: ui
@@ -28,9 +28,13 @@ the forward-looking plan; this doc is the rear-view mirror.
28
28
  | Recovery | `agentdebug.recovery.CriticRecoverer` + `VerifierSpec` registry | ✅ **new 0.2.3** | 5 family-matched verifier templates; dedup + custom-override |
29
29
  | Recovery | `agentdebug.recovery.SelfRefineLoop` | ✅ **new 0.2.9** | scripted-LLM happy + silent-LLM safe + multi-iter; per-finding generator→critic→refiner |
30
30
  | Recovery | `agentdebug.recovery.AutoManualRules` (+ `apply()`) | ✅ **new 0.2.9** | no-LLM fallback + LLM rule-distill + idempotent apply + multi-rule append |
31
+ | Recovery | `agentdebug.recovery.SagaRollback` + `Compensator` registry | ✅ **new 0.2.12** | reverse-order suggestion + skip-errored-tools + apply() invokes registered compensation + raises on unknown tool / missing event |
32
+ | Adapter | `agentdebug.adapters.openai_agents.OpenAIAgentsBridge` (TracingProcessor) | ✅ **new 0.2.12** | doctor-status when sdk absent + import-error path + bridge translates function/generation/handoff/unknown spans + context manager attach/detach |
31
33
  | DeepDebug | `agentdebug.deep.DeepDebugAnalyzer` | ✅ stable | full loop + silent LLM |
32
34
  | Cascade view | `agentdebug.traceback.format_traceback` | ✅ stable | cascade + step-order + ANSI + empty |
33
35
  | Detectors | `agentdebug.detectors.RepeatedToolCall / RepeatedState / StepCountLimit` | ✅ **new 0.2.2** | threshold + window + budget |
36
+ | Detectors | `agentdebug.detectors.TopicDriftDetector` (embedding cosine vs goal) | ✅ **new 0.2.11** | stub-embedder ranking + no-goal short-circuit + embedder-raises safe + threshold boundary |
37
+ | LLM client | `agentdebug.llm.OpenAICompatClient.embed()` (+ `EmbeddingClient` Protocol) | ✅ **new 0.2.11** | mocked-httpx POST /v1/embeddings + empty-input short-circuit |
34
38
  | Hub bundle | `agentdebug.hub.Bundle / pack_bundle / unpack_bundle` | ✅ stable | round-trip |
35
39
  | Hub scrubber | `agentdebug.hub.Scrubber` | ✅ stable | 12 redactions + idempotent |
36
40
  | Hub backends | `LocalHubBackend`, `GitHubBackend`, `HuggingFaceBackend` | ✅ stable | local-bare-git + local |
@@ -49,14 +53,12 @@ across 32 source files.
49
53
 
50
54
  | Doc | Component | Why deferred | Realistic ship |
51
55
  |---|---|---|---|
52
- | [06_detectors.md](./06_detectors.md) | `trajectory_perplexity` (TrajAD) | needs token-level LM perplexity API or embedding model + baseline calibration | v0.3 |
53
- | [06_detectors.md](./06_detectors.md) | `topic_drift` (embedding cosine) | needs embedding client; consider reusing `OpenAICompatClient` `/embeddings` | v0.3 |
56
+ | [06_detectors.md](./06_detectors.md) | `trajectory_perplexity` (TrajAD) | needs token-level LM perplexity API; v0.3 |
54
57
  | [06_detectors.md](./06_detectors.md) | LTL spec monitors | requires user-supplied spec or LLM-synthesized monitors; gated on RV research | v1.2 |
55
58
  | [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 |
56
59
  | [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 |
57
60
  | [07_attribution.md](./07_attribution.md) | `DeltaDebugAttributor` (Zeller) | same replay constraint | v0.3 |
58
61
  | [08_recovery.md](./08_recovery.md) | `LangGraphRewind` | depends on LangGraph checkpointer; ships when we have a real LangGraph user | v0.3 |
59
- | [08_recovery.md](./08_recovery.md) | `SagaRollback` | needs compensation registry on tool definitions; new schema | v0.3 |
60
62
  | [08_recovery.md](./08_recovery.md) | `MCTSBranchExploration` (LATS) | heavy; v2 feature | v2.0 |
61
63
  | [09_error_database.md](./09_error_database.md) | DuckDB analytical + Parquet archive | optional; Hub bundles already give per-project corpus | v0.3 |
62
64
  | [09_error_database.md](./09_error_database.md) | Vector similarity search | needs embedding model + index choice | v0.3 |
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "agentdebugx"
3
- version = "0.2.10"
3
+ version = "0.2.12"
4
4
  description = "Portable error analysis, tracing, and recovery framework for agentic AI systems. Import as `agentdebug`."
5
5
  authors = ["ULab @ UIUC <ulab@illinois.edu>"]
6
6
  license = "MIT"
@@ -53,6 +53,7 @@ httpx = ">=0.24,<1.0"
53
53
  [tool.poetry.extras]
54
54
  langgraph = ["langchain-core"]
55
55
  crewai = ["crewai"]
56
+ openai-agents = ["openai-agents"]
56
57
  otel = ["opentelemetry-api", "opentelemetry-sdk"]
57
58
  ui = ["fastapi", "uvicorn"]
58
59
  hub-hf = ["huggingface_hub"]
@@ -60,6 +61,7 @@ openhands = ["openhands-ai"]
60
61
  all = [
61
62
  "langchain-core",
62
63
  "crewai",
64
+ "openai-agents",
63
65
  "opentelemetry-api",
64
66
  "opentelemetry-sdk",
65
67
  "fastapi",
@@ -28,6 +28,7 @@ from agentdebug.detectors import (
28
28
  RepeatedStateDetector,
29
29
  RepeatedToolCallDetector,
30
30
  StepCountLimitDetector,
31
+ TopicDriftDetector,
31
32
  default_detectors,
32
33
  run_detectors,
33
34
  )
@@ -46,10 +47,13 @@ from agentdebug.recorder import AgentDebug, TraceSession
46
47
  from agentdebug.recovery import (
47
48
  DEFAULT_VERIFIERS,
48
49
  AutoManualRules,
50
+ CompensationSpec,
51
+ Compensator,
49
52
  CriticRecoverer,
50
53
  FixProposal,
51
54
  Recoverer,
52
55
  ReflexionSuggestion,
56
+ SagaRollback,
53
57
  SelfRefineLoop,
54
58
  VerifierSpec,
55
59
  )
@@ -71,6 +75,8 @@ __all__ = [
71
75
  'AutoManualRules',
72
76
  'BinarySearchAttributor',
73
77
  'CascadeFrame',
78
+ 'CompensationSpec',
79
+ 'Compensator',
74
80
  'CounterfactualAttributor',
75
81
  'CriticRecoverer',
76
82
  'DEFAULT_VERIFIERS',
@@ -79,9 +85,11 @@ __all__ = [
79
85
  'EnsembleAttributor',
80
86
  'RepeatedStateDetector',
81
87
  'RepeatedToolCallDetector',
88
+ 'SagaRollback',
82
89
  'SBFLAttributor',
83
90
  'SelfRefineLoop',
84
91
  'StepByStepAttributor',
92
+ 'TopicDriftDetector',
85
93
  'StepCountLimitDetector',
86
94
  'VerifierSpec',
87
95
  'build_cascade',
@@ -108,4 +116,4 @@ __all__ = [
108
116
  'get_failure_mode',
109
117
  ]
110
118
 
111
- __version__ = '0.2.10'
119
+ __version__ = '0.2.12'
@@ -0,0 +1,239 @@
1
+ """OpenAI Agents SDK adapter — bridge `agents.tracing` into AgentDebug.
2
+
3
+ The OpenAI Agents SDK (``openai-agents``) emits typed spans through its own
4
+ tracing system (not OpenTelemetry — though there are community OTel bridges).
5
+ The integration point is :class:`agents.tracing.TracingProcessor`: subclass
6
+ it, register via ``add_trace_processor``, and you receive ``on_span_start`` /
7
+ ``on_span_end`` for every span the SDK creates (``generation``, ``function``,
8
+ ``agent``, ``guardrail``, ``handoff``, audio, …).
9
+
10
+ This module ships ``OpenAIAgentsBridge``, which converts each span end into
11
+ an :class:`agentdebug.models.AgentEvent` and records it on a long-lived
12
+ :class:`AgentTrajectory`. The ``agents`` package is imported lazily, so this
13
+ module is safe to import without the SDK installed.
14
+
15
+ Usage::
16
+
17
+ from agentdebug import AgentDebug, SQLiteTraceStore
18
+ from agentdebug.adapters.openai_agents import OpenAIAgentsBridge
19
+
20
+ debugger = AgentDebug(store=SQLiteTraceStore('.agentdebug/errors.sqlite'))
21
+ trajectory = debugger.start_trace(goal='answer the user', framework='openai-agents')
22
+
23
+ with OpenAIAgentsBridge(debugger, trajectory):
24
+ from agents import Agent, Runner
25
+ agent = Agent(name='Assistant', instructions='Be concise.')
26
+ Runner.run_sync(agent, 'What is 2+2?')
27
+
28
+ debugger.finish_trace(trajectory, success=True)
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import logging
34
+ from types import TracebackType
35
+ from typing import Any, List, Literal, Optional, Type
36
+
37
+ from agentdebug.adapters.base import AdapterStatus, FrameworkAdapter
38
+ from agentdebug.models import AgentTrajectory, EventType
39
+ from agentdebug.recorder import AgentDebug
40
+
41
+ LOG = logging.getLogger('agentdebug.adapters.openai_agents')
42
+
43
+
44
+ def _import_agents_tracing() -> Any:
45
+ try:
46
+ from agents import tracing as _tracing
47
+ except ImportError as exc:
48
+ raise ImportError(
49
+ 'OpenAIAgentsBridge requires the `openai-agents` package. '
50
+ "Install with `pip install 'agentdebugx[openai-agents]'` or "
51
+ "`pip install openai-agents`."
52
+ ) from exc
53
+ return _tracing
54
+
55
+
56
+ # Span-type names the SDK emits; map each to the most representative
57
+ # AgentDebugX EventType. Names not in this map are recorded as OBSERVATION.
58
+ _SPAN_TYPE_TO_EVENT: dict[str, EventType] = {
59
+ 'agent': EventType.AGENT_STEP,
60
+ 'generation': EventType.LLM_RESPONSE,
61
+ 'function': EventType.TOOL_RESULT,
62
+ 'tool': EventType.TOOL_RESULT,
63
+ 'handoff': EventType.HANDOFF,
64
+ 'guardrail': EventType.GUARDRAIL,
65
+ 'response': EventType.LLM_RESPONSE,
66
+ }
67
+
68
+
69
+ def _span_attr(span: Any, *names: str) -> Any:
70
+ """Read the first non-None attribute from `span` (or `span.span_data` if
71
+ the SDK wraps its payload there).
72
+ """
73
+ holders = [span, getattr(span, 'span_data', None)]
74
+ for holder in holders:
75
+ if holder is None:
76
+ continue
77
+ for name in names:
78
+ val = getattr(holder, name, None)
79
+ if val is not None:
80
+ return val
81
+ return None
82
+
83
+
84
+ class OpenAIAgentsBridge:
85
+ """Bridge ``agents.tracing`` spans into an AgentDebug trajectory.
86
+
87
+ Both a context manager and a plain object with attach/detach. Holds the
88
+ registered ``TracingProcessor`` so the SDK doesn't GC it mid-session.
89
+ """
90
+
91
+ framework = 'openai-agents'
92
+
93
+ def __init__(
94
+ self,
95
+ debugger: AgentDebug,
96
+ trajectory: AgentTrajectory,
97
+ ) -> None:
98
+ self.debugger = debugger
99
+ self.trajectory = trajectory
100
+ self._processor: Optional[Any] = None
101
+ self._attached = False
102
+
103
+ def attach(self) -> 'OpenAIAgentsBridge':
104
+ if self._attached:
105
+ return self
106
+ tracing = _import_agents_tracing()
107
+ base_cls = getattr(tracing, 'TracingProcessor', None)
108
+ if base_cls is None:
109
+ raise RuntimeError(
110
+ 'agents.tracing.TracingProcessor not found; the installed '
111
+ '`openai-agents` version may be incompatible.'
112
+ )
113
+ bridge = self
114
+
115
+ class _Processor(base_cls): # type: ignore[misc, valid-type]
116
+ def on_trace_start(self_, trace_obj: Any) -> None:
117
+ pass
118
+
119
+ def on_trace_end(self_, trace_obj: Any) -> None:
120
+ pass
121
+
122
+ def on_span_start(self_, span: Any) -> None:
123
+ pass
124
+
125
+ def on_span_end(self_, span: Any) -> None:
126
+ bridge._record_span(span)
127
+
128
+ def shutdown(self_) -> None:
129
+ pass
130
+
131
+ def force_flush(self_) -> None:
132
+ pass
133
+
134
+ self._processor = _Processor()
135
+ add = getattr(tracing, 'add_trace_processor', None)
136
+ if not callable(add):
137
+ raise RuntimeError(
138
+ 'agents.tracing.add_trace_processor not found; the installed '
139
+ '`openai-agents` version may be incompatible.'
140
+ )
141
+ add(self._processor)
142
+ self._attached = True
143
+ return self
144
+
145
+ def detach(self) -> None:
146
+ # The SDK does not expose a clean unsubscribe in current releases.
147
+ # Dropping the strong reference + having the processor become a no-op
148
+ # is the safest available approach.
149
+ self._processor = None
150
+ self._attached = False
151
+
152
+ # Context-manager sugar
153
+ def __enter__(self) -> 'OpenAIAgentsBridge':
154
+ return self.attach()
155
+
156
+ def __exit__(
157
+ self,
158
+ exc_type: Optional[Type[BaseException]],
159
+ exc_value: Optional[BaseException],
160
+ tb: Optional[TracebackType],
161
+ ) -> Literal[False]:
162
+ self.detach()
163
+ return False
164
+
165
+ def _record_span(self, span: Any) -> None:
166
+ try:
167
+ span_type = str(_span_attr(span, 'span_type', 'type') or 'unknown')
168
+ event_type = _SPAN_TYPE_TO_EVENT.get(span_type, EventType.OBSERVATION)
169
+ error = _span_attr(span, 'error')
170
+ agent_name = (
171
+ _span_attr(span, 'agent_name', 'name', 'function_name')
172
+ or span_type
173
+ )
174
+ self.debugger.record_event(
175
+ self.trajectory,
176
+ event_type=event_type,
177
+ agent_name=str(agent_name),
178
+ module=self._module_for(event_type),
179
+ input=_summarize(_span_attr(span, 'input', 'arguments', 'prompt')),
180
+ output=_summarize(
181
+ _span_attr(span, 'output', 'response', 'result',
182
+ 'completion')
183
+ ),
184
+ error=str(error)[:500] if error else None,
185
+ openai_agents_span_type=span_type,
186
+ openai_agents_span_id=str(_span_attr(span, 'span_id') or ''),
187
+ )
188
+ except Exception as exc: # pragma: no cover - defensive
189
+ LOG.warning('OpenAIAgentsBridge failed to record span: %s', exc)
190
+
191
+ @staticmethod
192
+ def _module_for(event_type: EventType) -> str:
193
+ # Pull from agentdebug.taxonomy module families: planning/action/etc.
194
+ if event_type in {EventType.TOOL_CALL, EventType.TOOL_RESULT}:
195
+ return 'action'
196
+ if event_type in {EventType.HANDOFF, EventType.GUARDRAIL}:
197
+ return 'multiagent'
198
+ if event_type in {EventType.LLM_CALL, EventType.LLM_RESPONSE}:
199
+ return 'planning'
200
+ return 'planning'
201
+
202
+
203
+ def _summarize(value: Any) -> Any:
204
+ if value is None:
205
+ return None
206
+ text = str(value)
207
+ return text[:1000] if len(text) > 1000 else text
208
+
209
+
210
+ class OpenAIAgentsAdapter:
211
+ """Structural :class:`FrameworkAdapter` for the doctor."""
212
+
213
+ framework = 'openai-agents'
214
+
215
+ def instrument(self, debugger: AgentDebug) -> AdapterStatus: # noqa: D401
216
+ try:
217
+ _import_agents_tracing()
218
+ except ImportError as exc:
219
+ return AdapterStatus(
220
+ framework=self.framework,
221
+ implemented=False,
222
+ notes=str(exc),
223
+ )
224
+ return AdapterStatus(
225
+ framework=self.framework,
226
+ implemented=True,
227
+ notes=(
228
+ 'Create a trajectory, then use '
229
+ '`with OpenAIAgentsBridge(debugger, trajectory): Runner.run_sync(...)` '
230
+ 'to record every Agent/Runner/Function/Handoff/Guardrail span '
231
+ 'into AgentDebug.'
232
+ ),
233
+ )
234
+
235
+
236
+ _: FrameworkAdapter = OpenAIAgentsAdapter() # static structural check
237
+
238
+
239
+ __all__ = ['OpenAIAgentsAdapter', 'OpenAIAgentsBridge']
@@ -453,6 +453,12 @@ def _cmd_doctor() -> int:
453
453
  statuses.append(CrewAIAdapter().instrument(_dummy_debugger()))
454
454
  except Exception as exc: # pragma: no cover - defensive
455
455
  statuses.append(_status('crewai', False, str(exc)))
456
+ try:
457
+ from agentdebug.adapters.openai_agents import OpenAIAgentsAdapter
458
+
459
+ statuses.append(OpenAIAgentsAdapter().instrument(_dummy_debugger()))
460
+ except Exception as exc: # pragma: no cover - defensive
461
+ statuses.append(_status('openai-agents', False, str(exc)))
456
462
  try:
457
463
  from agentdebug.adapters.otel import OTelExportAdapter
458
464
 
@@ -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:
@@ -13,9 +13,10 @@ artifacts to be surfaced (CLI/UI/PR comment) or fed back into the next run.
13
13
  from __future__ import annotations
14
14
 
15
15
  from dataclasses import dataclass, field
16
- from typing import List, Optional, Protocol, Tuple
16
+ from typing import Any, Callable, Dict, List, Optional, Protocol, Tuple
17
17
 
18
18
  from agentdebug.models import (
19
+ AgentEvent,
19
20
  AgentTrajectory,
20
21
  DiagnosticReport,
21
22
  FailureFinding,
@@ -583,13 +584,198 @@ class AutoManualRules:
583
584
  return rule
584
585
 
585
586
 
587
+ @dataclass
588
+ class CompensationSpec:
589
+ """Pairs a tool name with the function that undoes its side effect.
590
+
591
+ The compensation receives the recorded TOOL_CALL input + TOOL_RESULT
592
+ output and returns a short human-readable summary of what was
593
+ rolled back. The function is the user's responsibility — AgentDebug
594
+ only orchestrates the order and provenance.
595
+ """
596
+
597
+ tool_name: str
598
+ description: str
599
+ compensate: Callable[[Any, Any], str]
600
+
601
+
602
+ class Compensator:
603
+ """Registry mapping tool_name → :class:`CompensationSpec`.
604
+
605
+ Users register compensations once per tool the agent might call with a
606
+ side effect (email send, DB write, API POST, file create). The Saga
607
+ recoverer looks up compensations by the agent_name on a TOOL_CALL /
608
+ TOOL_RESULT pair.
609
+ """
610
+
611
+ def __init__(self) -> None:
612
+ self._specs: dict[str, CompensationSpec] = {}
613
+
614
+ def register(
615
+ self,
616
+ tool_name: str,
617
+ compensate: Callable[[Any, Any], str],
618
+ *,
619
+ description: str = '',
620
+ ) -> CompensationSpec:
621
+ spec = CompensationSpec(
622
+ tool_name=tool_name,
623
+ description=description or f'compensation for {tool_name}',
624
+ compensate=compensate,
625
+ )
626
+ self._specs[tool_name] = spec
627
+ return spec
628
+
629
+ def get(self, tool_name: str) -> Optional[CompensationSpec]:
630
+ return self._specs.get(tool_name)
631
+
632
+ def registered_tools(self) -> list[str]:
633
+ return sorted(self._specs.keys())
634
+
635
+
636
+ @dataclass
637
+ class _ToolPair:
638
+ """A matched TOOL_CALL / TOOL_RESULT pair from a trajectory."""
639
+
640
+ call_event_id: str
641
+ result_event_id: str
642
+ tool_name: str
643
+ step_index: Optional[int]
644
+ input: Any
645
+ output: Any
646
+ error: Optional[str]
647
+
648
+
649
+ def _pair_tool_calls(trajectory: AgentTrajectory) -> List[_ToolPair]:
650
+ """Walk the trajectory and emit (call, result) pairs by agent_name +
651
+ proximity. Calls without a following result are dropped (the tool never
652
+ completed, so there's nothing to compensate)."""
653
+ pairs: List[_ToolPair] = []
654
+ pending: Dict[str, AgentEvent] = {}
655
+ for evt in trajectory.events:
656
+ et = getattr(evt.event_type, 'value', evt.event_type)
657
+ if et == 'tool.call':
658
+ pending[evt.agent_name] = evt
659
+ continue
660
+ if et == 'tool.result' and evt.agent_name in pending:
661
+ call = pending.pop(evt.agent_name)
662
+ pairs.append(_ToolPair(
663
+ call_event_id=call.event_id,
664
+ result_event_id=evt.event_id,
665
+ tool_name=evt.agent_name,
666
+ step_index=evt.step_index or call.step_index,
667
+ input=call.input,
668
+ output=evt.output,
669
+ error=evt.error,
670
+ ))
671
+ return pairs
672
+
673
+
674
+ class SagaRollback:
675
+ """SagaLLM-style compensating-action layer (arXiv:2503.11951).
676
+
677
+ For every successful TOOL_CALL/TOOL_RESULT pair in the trajectory whose
678
+ tool has a registered compensation, emit a :class:`FixProposal`. Tool
679
+ results with an error are skipped — there's nothing to undo.
680
+
681
+ Proposals are returned in REVERSE EXECUTION ORDER so applying them
682
+ walks the side effects back to a clean state. Each ``apply()`` invokes
683
+ the user-provided ``compensate(input, output)`` and returns the
684
+ function's summary string.
685
+
686
+ Unlike the suggest-only Reflexion / CRITIC / Self-Refine / AutoManual
687
+ recoverers, ``apply()`` here HAS REAL SIDE EFFECTS — it runs the
688
+ user's compensation code. Marked ``requires_human_approval=True`` by
689
+ default so the UI surfaces every rollback for confirmation.
690
+ """
691
+
692
+ id = 'saga_rollback'
693
+
694
+ def __init__(
695
+ self,
696
+ compensator: Compensator,
697
+ *,
698
+ require_approval: bool = True,
699
+ ) -> None:
700
+ self.compensator = compensator
701
+ self.require_approval = require_approval
702
+
703
+ def suggest(
704
+ self,
705
+ trajectory: AgentTrajectory,
706
+ report: DiagnosticReport,
707
+ ) -> List[FixProposal]:
708
+ # Recovery suggestions are independent of the report — saga always
709
+ # looks at the trajectory's actual side-effect surface.
710
+ del report
711
+ pairs = _pair_tool_calls(trajectory)
712
+ # Reverse order so consumers can apply in sequence.
713
+ proposals: List[FixProposal] = []
714
+ for pair in reversed(pairs):
715
+ if pair.error:
716
+ continue # tool failed; no side effect to compensate
717
+ spec = self.compensator.get(pair.tool_name)
718
+ if spec is None:
719
+ continue # no registered compensation; can't auto-rollback
720
+ proposals.append(FixProposal(
721
+ proposal_id=new_id('fix'),
722
+ recoverer_id=self.id,
723
+ target_event_id=pair.result_event_id,
724
+ summary=(
725
+ f'Compensate {pair.tool_name} call '
726
+ f'at step {pair.step_index}'
727
+ ),
728
+ rationale=(
729
+ f'SagaRollback (arXiv:2503.11951): undo the recorded '
730
+ f'side effect of {pair.tool_name}. '
731
+ f'Compensation: {spec.description}'
732
+ ),
733
+ confidence=0.85,
734
+ suggestion_text=(
735
+ f'apply() will call the registered compensation for '
736
+ f'`{pair.tool_name}` with the recorded input/output, '
737
+ f'reversing the side effect.'
738
+ ),
739
+ side_effects=[f'tool.compensate:{pair.tool_name}'],
740
+ requires_human_approval=self.require_approval,
741
+ ))
742
+ return proposals
743
+
744
+ def apply(self, proposal: FixProposal, *, trajectory: AgentTrajectory) -> str:
745
+ """Run the registered compensation. Returns the user's summary string.
746
+
747
+ ``trajectory`` is required so apply can recover the recorded
748
+ input/output from the target event without the caller having to
749
+ carry them through.
750
+ """
751
+ pairs_by_result = {
752
+ p.result_event_id: p for p in _pair_tool_calls(trajectory)
753
+ }
754
+ pair = pairs_by_result.get(proposal.target_event_id or '')
755
+ if pair is None:
756
+ raise ValueError(
757
+ f'proposal target_event_id={proposal.target_event_id!r} not '
758
+ f'found among tool-result events in the trajectory'
759
+ )
760
+ spec = self.compensator.get(pair.tool_name)
761
+ if spec is None:
762
+ raise ValueError(
763
+ f'no registered compensation for tool {pair.tool_name!r}; '
764
+ f'register it on the Compensator before applying'
765
+ )
766
+ return spec.compensate(pair.input, pair.output)
767
+
768
+
586
769
  __all__ = [
587
770
  'AutoManualRules',
771
+ 'CompensationSpec',
772
+ 'Compensator',
588
773
  'CriticRecoverer',
589
774
  'DEFAULT_VERIFIERS',
590
775
  'FixProposal',
591
776
  'Recoverer',
592
777
  'ReflexionSuggestion',
778
+ 'SagaRollback',
593
779
  'SelfRefineLoop',
594
780
  'VerifierSpec',
595
781
  ]
File without changes
File without changes