agentwatchx 0.1.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.
@@ -0,0 +1,106 @@
1
+ """AgentWatchX — Observability SDK for AI Agent Execution."""
2
+
3
+ from agentwatchx.client import AgentWatchXClient
4
+ from agentwatchx.decorators import trace
5
+ from agentwatchx.schemas import TraceData, ToolCallData
6
+
7
+ # Module-level client instance
8
+ _client: AgentWatchXClient | None = None
9
+
10
+
11
+ def init(api_key: str, endpoint: str = "https://api.agentwatchx.com", auto_instrument: bool = True, **kwargs):
12
+ """Initialize the AgentWatchX SDK.
13
+
14
+ After calling init(), tracing begins automatically for supported libraries
15
+ (OpenAI, Anthropic, LangChain, LlamaIndex) if they are installed.
16
+
17
+ Set auto_instrument=False to disable auto-patching and use manual
18
+ capture/wrap/trace only.
19
+ """
20
+ global _client
21
+ _client = AgentWatchXClient(api_key=api_key, endpoint=endpoint, **kwargs)
22
+
23
+ if auto_instrument:
24
+ from agentwatchx.integrations import auto_instrument as _auto
25
+ _auto(_client)
26
+
27
+ return _client
28
+
29
+
30
+ def get_client() -> AgentWatchXClient:
31
+ if _client is None:
32
+ raise RuntimeError("AgentWatchX SDK not initialized. Call agentwatchx.init() first.")
33
+ return _client
34
+
35
+
36
+ def wrap(llm_client):
37
+ """Wrap an LLM client instance for explicit trace capture.
38
+
39
+ This is optional when auto-instrumentation is active. Use it when you
40
+ want to trace a specific client instance without global patching.
41
+
42
+ Supported: OpenAI, Anthropic client instances.
43
+ """
44
+ if _client is None:
45
+ raise RuntimeError("AgentWatchX SDK not initialized. Call agentwatchx.init() first.")
46
+
47
+ # Detect client type and apply instance-level wrapping
48
+ client_type = type(llm_client).__module__
49
+
50
+ if "openai" in client_type:
51
+ from agentwatchx.integrations.openai_patch import patch
52
+ patch(_client)
53
+ elif "anthropic" in client_type:
54
+ from agentwatchx.integrations.anthropic_patch import patch
55
+ patch(_client)
56
+
57
+ return llm_client
58
+
59
+
60
+ def log(data: dict):
61
+ """Manually log a trace from a dict. Convenience for custom events."""
62
+ if _client is None:
63
+ raise RuntimeError("AgentWatchX SDK not initialized. Call agentwatchx.init() first.")
64
+
65
+ tool_calls = []
66
+ for tc in data.get("tool_calls", []):
67
+ tool_calls.append(ToolCallData(**tc) if isinstance(tc, dict) else tc)
68
+
69
+ trace_data = TraceData(
70
+ service=data.get("service", data.get("type", "custom")),
71
+ input_text=str(data.get("input", data.get("input_text", ""))),
72
+ output_text=str(data.get("output", data.get("output_text", ""))) if data.get("output") or data.get("output_text") else None,
73
+ llm_model=data.get("llm_model", data.get("model")),
74
+ duration_ms=data.get("duration_ms"),
75
+ tool_calls=tool_calls,
76
+ metadata={k: v for k, v in data.items() if k not in {
77
+ "service", "type", "input", "input_text", "output", "output_text",
78
+ "llm_model", "model", "duration_ms", "tool_calls",
79
+ }},
80
+ )
81
+ _client.capture(trace_data)
82
+
83
+
84
+ def shutdown():
85
+ """Flush pending traces and close the client."""
86
+ if _client:
87
+ from agentwatchx.integrations import unpatch_all
88
+ unpatch_all()
89
+ _client.flush()
90
+ _client.close()
91
+
92
+
93
+ def status() -> dict:
94
+ """Return SDK connection status."""
95
+ if _client is None:
96
+ return {"initialized": False}
97
+ info = _client.status()
98
+ from agentwatchx.integrations import get_instrumented
99
+ info["instrumented"] = get_instrumented()
100
+ return info
101
+
102
+
103
+ __all__ = [
104
+ "init", "trace", "wrap", "log", "shutdown", "status",
105
+ "get_client", "TraceData", "ToolCallData",
106
+ ]
agentwatchx/client.py ADDED
@@ -0,0 +1,90 @@
1
+ import threading
2
+ import time
3
+ import atexit
4
+
5
+ import httpx
6
+
7
+ from agentwatchx.schemas import TraceData
8
+
9
+
10
+ class AgentWatchXClient:
11
+ """Core SDK client. Buffers traces and flushes in batches."""
12
+
13
+ def __init__(
14
+ self,
15
+ api_key: str,
16
+ endpoint: str = "https://api.agentwatchx.com",
17
+ flush_interval: float = 5.0,
18
+ batch_size: int = 100,
19
+ service: str = "default",
20
+ ):
21
+ self.api_key = api_key
22
+ self.endpoint = endpoint.rstrip("/")
23
+ self.flush_interval = flush_interval
24
+ self.batch_size = batch_size
25
+ self.default_service = service
26
+
27
+ self._buffer: list[dict] = []
28
+ self._lock = threading.Lock()
29
+ self._http = httpx.Client(
30
+ base_url=self.endpoint,
31
+ headers={"Authorization": f"Bearer {api_key}"},
32
+ timeout=10.0,
33
+ )
34
+
35
+ # Background flush thread
36
+ self._running = True
37
+ self._flush_thread = threading.Thread(target=self._flush_loop, daemon=True)
38
+ self._flush_thread.start()
39
+ atexit.register(self.shutdown)
40
+
41
+ def capture(self, trace: TraceData):
42
+ """Add a trace to the buffer."""
43
+ data = trace.model_dump()
44
+ if data["service"] == "default":
45
+ data["service"] = self.default_service
46
+ with self._lock:
47
+ self._buffer.append(data)
48
+ if len(self._buffer) >= self.batch_size:
49
+ self._send_batch()
50
+
51
+ def flush(self):
52
+ """Force flush all buffered traces."""
53
+ with self._lock:
54
+ self._send_batch()
55
+
56
+ def close(self):
57
+ """Stop the flush thread and close HTTP client."""
58
+ self._running = False
59
+ self.flush()
60
+ self._http.close()
61
+
62
+ def shutdown(self):
63
+ """Alias for close — used by atexit."""
64
+ self.close()
65
+
66
+ def status(self) -> dict:
67
+ with self._lock:
68
+ buffered = len(self._buffer)
69
+ return {
70
+ "initialized": True,
71
+ "endpoint": self.endpoint,
72
+ "buffered_traces": buffered,
73
+ }
74
+
75
+ def _flush_loop(self):
76
+ while self._running:
77
+ time.sleep(self.flush_interval)
78
+ self.flush()
79
+
80
+ def _send_batch(self):
81
+ """Send buffered traces. Must be called with self._lock held."""
82
+ if not self._buffer:
83
+ return
84
+ batch = self._buffer[:self.batch_size]
85
+ self._buffer = self._buffer[self.batch_size:]
86
+ try:
87
+ self._http.post("/v1/traces/batch", json={"traces": batch})
88
+ except Exception:
89
+ # Re-add to buffer on failure (best effort)
90
+ self._buffer = batch + self._buffer
@@ -0,0 +1,63 @@
1
+ import functools
2
+ import time
3
+ from typing import Callable
4
+
5
+ from agentwatchx.schemas import TraceData
6
+
7
+
8
+ def trace(
9
+ func: Callable | None = None,
10
+ *,
11
+ service: str = "default",
12
+ llm_model: str | None = None,
13
+ ):
14
+ """
15
+ Decorator to trace a function execution.
16
+
17
+ Usage:
18
+ @agentwatchx.trace
19
+ def my_agent(input_text):
20
+ ...
21
+
22
+ @agentwatchx.trace(service="order-agent", llm_model="gpt-4")
23
+ def my_agent(input_text):
24
+ ...
25
+ """
26
+ def decorator(fn: Callable) -> Callable:
27
+ @functools.wraps(fn)
28
+ def wrapper(*args, **kwargs):
29
+ import agentwatchx
30
+
31
+ input_text = str(args[0]) if args else str(kwargs) if kwargs else None
32
+ start = time.perf_counter()
33
+ error = None
34
+
35
+ try:
36
+ result = fn(*args, **kwargs)
37
+ return result
38
+ except Exception as e:
39
+ error = e
40
+ raise
41
+ finally:
42
+ duration_ms = (time.perf_counter() - start) * 1000
43
+ output_text = None if error else str(result) if 'result' in dir() else None
44
+
45
+ trace_data = TraceData(
46
+ service=service,
47
+ input_text=input_text,
48
+ output_text=output_text,
49
+ llm_model=llm_model,
50
+ duration_ms=round(duration_ms, 2),
51
+ )
52
+
53
+ try:
54
+ client = agentwatchx.get_client()
55
+ client.capture(trace_data)
56
+ except RuntimeError:
57
+ pass # SDK not initialized, silently skip
58
+
59
+ return wrapper
60
+
61
+ if func is not None:
62
+ return decorator(func)
63
+ return decorator
@@ -0,0 +1,59 @@
1
+ """
2
+ Auto-instrumentation registry.
3
+
4
+ Detects installed LLM libraries and patches them to capture traces
5
+ automatically after agentwatchx.init() is called.
6
+ """
7
+
8
+ import importlib
9
+ import logging
10
+
11
+ logger = logging.getLogger("agentwatchx.integrations")
12
+
13
+ _PATCHES = {
14
+ "openai": "agentwatchx.integrations.openai_patch",
15
+ "anthropic": "agentwatchx.integrations.anthropic_patch",
16
+ "langchain_core": "agentwatchx.integrations.langchain_cb",
17
+ "llama_index": "agentwatchx.integrations.llamaindex_cb",
18
+ }
19
+
20
+ _applied: list[str] = []
21
+
22
+
23
+ def auto_instrument(client):
24
+ """Detect installed libraries and apply patches. Called by init()."""
25
+ for lib_name, patch_module in _PATCHES.items():
26
+ if lib_name in _applied:
27
+ continue # already patched, skip
28
+
29
+ try:
30
+ importlib.import_module(lib_name)
31
+ except ImportError:
32
+ continue # library not installed, skip
33
+
34
+ try:
35
+ mod = importlib.import_module(patch_module)
36
+ mod.patch(client)
37
+ _applied.append(lib_name)
38
+ logger.info("Instrumented %s", lib_name)
39
+ except Exception as exc:
40
+ logger.warning("Failed to instrument %s: %s", lib_name, exc)
41
+
42
+
43
+ def get_instrumented() -> list[str]:
44
+ """Return list of libraries that were successfully instrumented."""
45
+ return list(_applied)
46
+
47
+
48
+ def unpatch_all():
49
+ """Remove all patches (for testing / shutdown)."""
50
+ for lib_name, patch_module in _PATCHES.items():
51
+ if lib_name not in _applied:
52
+ continue
53
+ try:
54
+ mod = importlib.import_module(patch_module)
55
+ if hasattr(mod, "unpatch"):
56
+ mod.unpatch()
57
+ except Exception:
58
+ pass
59
+ _applied.clear()
@@ -0,0 +1,247 @@
1
+ """
2
+ Auto-instrumentation for the Anthropic Python SDK.
3
+
4
+ Patches:
5
+ - messages.create (sync + async)
6
+ - Handles both regular and streaming responses
7
+
8
+ After agentwatchx.init(), every Anthropic call is traced automatically.
9
+ """
10
+
11
+ import time
12
+ import functools
13
+ import logging
14
+
15
+ logger = logging.getLogger("agentwatchx.integrations.anthropic")
16
+
17
+ _original_create = None
18
+ _original_async_create = None
19
+ _client_ref = None
20
+ _patched = False
21
+
22
+
23
+ def patch(awx_client):
24
+ """Monkey-patch anthropic messages.create."""
25
+ global _original_create, _original_async_create, _client_ref, _patched
26
+ if _patched:
27
+ return
28
+ _client_ref = awx_client
29
+
30
+ try:
31
+ from anthropic.resources.messages import Messages
32
+ except ImportError:
33
+ logger.debug("anthropic messages module not found, skipping")
34
+ return
35
+
36
+ _original_create = Messages.create
37
+
38
+ @functools.wraps(_original_create)
39
+ def patched_create(self, *args, **kwargs):
40
+ stream = kwargs.get("stream", False)
41
+ start = time.perf_counter()
42
+
43
+ if stream:
44
+ result = _original_create(self, *args, **kwargs)
45
+ return _wrap_stream(result, kwargs, start)
46
+
47
+ error = None
48
+ result = None
49
+ try:
50
+ result = _original_create(self, *args, **kwargs)
51
+ return result
52
+ except Exception as exc:
53
+ error = exc
54
+ raise
55
+ finally:
56
+ duration_ms = (time.perf_counter() - start) * 1000
57
+ _capture_trace(kwargs, result, error, duration_ms)
58
+
59
+ Messages.create = patched_create
60
+
61
+ # Patch async
62
+ try:
63
+ from anthropic.resources.messages import AsyncMessages
64
+ _original_async_create = AsyncMessages.create
65
+
66
+ @functools.wraps(_original_async_create)
67
+ async def patched_async_create(self, *args, **kwargs):
68
+ stream = kwargs.get("stream", False)
69
+ start = time.perf_counter()
70
+
71
+ if stream:
72
+ result = await _original_async_create(self, *args, **kwargs)
73
+ return _wrap_async_stream(result, kwargs, start)
74
+
75
+ error = None
76
+ result = None
77
+ try:
78
+ result = await _original_async_create(self, *args, **kwargs)
79
+ return result
80
+ except Exception as exc:
81
+ error = exc
82
+ raise
83
+ finally:
84
+ duration_ms = (time.perf_counter() - start) * 1000
85
+ _capture_trace(kwargs, result, error, duration_ms)
86
+
87
+ AsyncMessages.create = patched_async_create
88
+ except ImportError:
89
+ pass
90
+
91
+ _patched = True
92
+ logger.info("Anthropic auto-instrumentation applied")
93
+
94
+
95
+ def unpatch():
96
+ """Restore original methods."""
97
+ global _original_create, _original_async_create, _patched
98
+ if _original_create:
99
+ from anthropic.resources.messages import Messages
100
+ Messages.create = _original_create
101
+ _original_create = None
102
+ if _original_async_create:
103
+ from anthropic.resources.messages import AsyncMessages
104
+ AsyncMessages.create = _original_async_create
105
+ _original_async_create = None
106
+ _patched = False
107
+
108
+
109
+ def _wrap_stream(stream, kwargs, start):
110
+ """Wrap a sync Anthropic stream to capture events."""
111
+ content_parts = []
112
+ input_tokens = 0
113
+ output_tokens = 0
114
+ try:
115
+ for event in stream:
116
+ # Collect text deltas
117
+ if hasattr(event, "type"):
118
+ if event.type == "content_block_delta" and hasattr(event, "delta"):
119
+ if hasattr(event.delta, "text"):
120
+ content_parts.append(event.delta.text)
121
+ elif event.type == "message_delta" and hasattr(event, "usage"):
122
+ output_tokens = getattr(event.usage, "output_tokens", 0)
123
+ elif event.type == "message_start" and hasattr(event, "message"):
124
+ usage = getattr(event.message, "usage", None)
125
+ if usage:
126
+ input_tokens = getattr(usage, "input_tokens", 0)
127
+ yield event
128
+ finally:
129
+ duration_ms = (time.perf_counter() - start) * 1000
130
+ _capture_stream_trace(kwargs, content_parts, input_tokens, output_tokens, duration_ms)
131
+
132
+
133
+ async def _wrap_async_stream(stream, kwargs, start):
134
+ """Wrap an async Anthropic stream."""
135
+ content_parts = []
136
+ input_tokens = 0
137
+ output_tokens = 0
138
+ try:
139
+ async for event in stream:
140
+ if hasattr(event, "type"):
141
+ if event.type == "content_block_delta" and hasattr(event, "delta"):
142
+ if hasattr(event.delta, "text"):
143
+ content_parts.append(event.delta.text)
144
+ elif event.type == "message_delta" and hasattr(event, "usage"):
145
+ output_tokens = getattr(event.usage, "output_tokens", 0)
146
+ elif event.type == "message_start" and hasattr(event, "message"):
147
+ usage = getattr(event.message, "usage", None)
148
+ if usage:
149
+ input_tokens = getattr(usage, "input_tokens", 0)
150
+ yield event
151
+ finally:
152
+ duration_ms = (time.perf_counter() - start) * 1000
153
+ _capture_stream_trace(kwargs, content_parts, input_tokens, output_tokens, duration_ms)
154
+
155
+
156
+ def _capture_stream_trace(kwargs, content_parts, input_tokens, output_tokens, duration_ms):
157
+ """Build trace from collected stream data."""
158
+ if _client_ref is None:
159
+ return
160
+
161
+ from agentwatchx.schemas import TraceData
162
+
163
+ model = kwargs.get("model", "unknown")
164
+ messages = kwargs.get("messages", [])
165
+ input_text = _extract_input(messages)
166
+ output_text = "".join(content_parts)
167
+
168
+ metadata = {"provider": "anthropic", "streamed": True}
169
+ if input_tokens or output_tokens:
170
+ metadata["input_tokens"] = input_tokens
171
+ metadata["output_tokens"] = output_tokens
172
+
173
+ try:
174
+ _client_ref.capture(TraceData(
175
+ service="anthropic",
176
+ input_text=input_text,
177
+ output_text=output_text or None,
178
+ llm_model=model,
179
+ duration_ms=round(duration_ms, 2),
180
+ metadata=metadata,
181
+ ))
182
+ except Exception as exc:
183
+ logger.debug("Failed to capture Anthropic stream trace: %s", exc)
184
+
185
+
186
+ def _extract_input(messages):
187
+ """Pull input text from the last message."""
188
+ if not messages:
189
+ return ""
190
+ last = messages[-1]
191
+ if isinstance(last, dict):
192
+ content = last.get("content", "")
193
+ return content if isinstance(content, str) else str(content)
194
+ return str(last)
195
+
196
+
197
+ def _capture_trace(kwargs, result, error, duration_ms):
198
+ """Extract trace data from a non-streaming Anthropic call."""
199
+ if _client_ref is None:
200
+ return
201
+
202
+ from agentwatchx.schemas import TraceData
203
+
204
+ model = kwargs.get("model", "unknown")
205
+ messages = kwargs.get("messages", [])
206
+ input_text = _extract_input(messages)
207
+
208
+ output_text = None
209
+ tool_calls = []
210
+ metadata = {"provider": "anthropic"}
211
+
212
+ if error:
213
+ metadata["error"] = str(error)
214
+ elif result:
215
+ try:
216
+ blocks = result.content
217
+ text_parts = []
218
+ for b in blocks:
219
+ if hasattr(b, "text"):
220
+ text_parts.append(b.text)
221
+ elif hasattr(b, "type") and b.type == "tool_use":
222
+ from agentwatchx.schemas import ToolCallData
223
+ tool_calls.append(ToolCallData(
224
+ name=b.name,
225
+ input=b.input if hasattr(b, "input") else None,
226
+ status="success",
227
+ ))
228
+ output_text = " ".join(text_parts)
229
+
230
+ if hasattr(result, "usage") and result.usage:
231
+ metadata["input_tokens"] = result.usage.input_tokens
232
+ metadata["output_tokens"] = result.usage.output_tokens
233
+ except (AttributeError, TypeError):
234
+ output_text = str(result)
235
+
236
+ try:
237
+ _client_ref.capture(TraceData(
238
+ service="anthropic",
239
+ input_text=input_text,
240
+ output_text=output_text,
241
+ llm_model=model,
242
+ duration_ms=round(duration_ms, 2),
243
+ tool_calls=tool_calls,
244
+ metadata=metadata,
245
+ ))
246
+ except Exception as exc:
247
+ logger.debug("Failed to capture Anthropic trace: %s", exc)
@@ -0,0 +1,191 @@
1
+ """
2
+ Auto-instrumentation for LangChain via callback handler.
3
+
4
+ Registers an AgentWatchX callback handler globally so all LangChain
5
+ LLM/chain/chat model invocations are traced automatically.
6
+
7
+ Works with langchain-core >= 0.1.0 (the modern package structure).
8
+ """
9
+
10
+ import time
11
+ import logging
12
+ from typing import Any
13
+ from uuid import UUID
14
+
15
+ logger = logging.getLogger("agentwatchx.integrations.langchain")
16
+
17
+ _client_ref = None
18
+ _handler_instance = None
19
+ _patched = False
20
+
21
+
22
+ def patch(awx_client):
23
+ """Create and globally register a LangChain callback handler."""
24
+ global _client_ref, _handler_instance, _patched
25
+ if _patched:
26
+ return
27
+ _client_ref = awx_client
28
+
29
+ try:
30
+ from langchain_core.callbacks import BaseCallbackHandler
31
+ except ImportError:
32
+ logger.debug("langchain_core not available, skipping")
33
+ return
34
+
35
+ class AWXCallbackHandler(BaseCallbackHandler):
36
+ """Captures LLM start/end events and sends them as traces."""
37
+
38
+ def __init__(self):
39
+ self._runs: dict[UUID, dict] = {}
40
+
41
+ @property
42
+ def always_verbose(self) -> bool:
43
+ return True
44
+
45
+ def on_llm_start(
46
+ self,
47
+ serialized: dict[str, Any],
48
+ prompts: list[str],
49
+ *,
50
+ run_id: UUID,
51
+ **kwargs: Any,
52
+ ) -> None:
53
+ model = (
54
+ kwargs.get("invocation_params", {}).get("model_name")
55
+ or kwargs.get("invocation_params", {}).get("model")
56
+ or serialized.get("kwargs", {}).get("model_name")
57
+ or serialized.get("kwargs", {}).get("model")
58
+ or "unknown"
59
+ )
60
+ self._runs[run_id] = {
61
+ "start": time.perf_counter(),
62
+ "input_text": prompts[0] if prompts else "",
63
+ "model": model,
64
+ "metadata": {"provider": "langchain"},
65
+ }
66
+
67
+ def on_chat_model_start(
68
+ self,
69
+ serialized: dict[str, Any],
70
+ messages: list[list],
71
+ *,
72
+ run_id: UUID,
73
+ **kwargs: Any,
74
+ ) -> None:
75
+ input_text = ""
76
+ if messages and messages[0]:
77
+ last_msg = messages[0][-1]
78
+ if hasattr(last_msg, "content"):
79
+ input_text = str(last_msg.content)
80
+ else:
81
+ input_text = str(last_msg)
82
+
83
+ model = (
84
+ kwargs.get("invocation_params", {}).get("model_name")
85
+ or kwargs.get("invocation_params", {}).get("model")
86
+ or serialized.get("kwargs", {}).get("model_name")
87
+ or serialized.get("kwargs", {}).get("model")
88
+ or "unknown"
89
+ )
90
+ self._runs[run_id] = {
91
+ "start": time.perf_counter(),
92
+ "input_text": input_text,
93
+ "model": model,
94
+ "metadata": {"provider": "langchain"},
95
+ }
96
+
97
+ def on_llm_end(self, response, *, run_id: UUID, **kwargs: Any) -> None:
98
+ run = self._runs.pop(run_id, None)
99
+ if not run or _client_ref is None:
100
+ return
101
+ duration_ms = (time.perf_counter() - run["start"]) * 1000
102
+
103
+ output_text = ""
104
+ try:
105
+ gen = response.generations[0][0]
106
+ output_text = gen.text if hasattr(gen, "text") else str(gen)
107
+ # Capture token usage if available
108
+ if hasattr(response, "llm_output") and response.llm_output:
109
+ usage = response.llm_output.get("token_usage", {})
110
+ if usage:
111
+ run["metadata"]["tokens"] = usage
112
+ except (IndexError, AttributeError):
113
+ output_text = str(response)
114
+
115
+ from agentwatchx.schemas import TraceData
116
+ try:
117
+ _client_ref.capture(TraceData(
118
+ service="langchain",
119
+ input_text=run["input_text"],
120
+ output_text=output_text,
121
+ llm_model=run["model"],
122
+ duration_ms=round(duration_ms, 2),
123
+ metadata=run["metadata"],
124
+ ))
125
+ except Exception as exc:
126
+ logger.debug("Failed to capture LangChain trace: %s", exc)
127
+
128
+ def on_llm_error(self, error, *, run_id: UUID, **kwargs: Any) -> None:
129
+ run = self._runs.pop(run_id, None)
130
+ if not run or _client_ref is None:
131
+ return
132
+ duration_ms = (time.perf_counter() - run["start"]) * 1000
133
+ run["metadata"]["error"] = str(error)
134
+
135
+ from agentwatchx.schemas import TraceData
136
+ try:
137
+ _client_ref.capture(TraceData(
138
+ service="langchain",
139
+ input_text=run["input_text"],
140
+ output_text=None,
141
+ llm_model=run["model"],
142
+ duration_ms=round(duration_ms, 2),
143
+ metadata=run["metadata"],
144
+ ))
145
+ except Exception as exc:
146
+ logger.debug("Failed to capture LangChain error trace: %s", exc)
147
+
148
+ _handler_instance = AWXCallbackHandler()
149
+
150
+ # Register globally by patching the callback manager's configure function.
151
+ # This ensures our handler is included in every LLM/chain invocation
152
+ # without requiring users to pass callbacks= manually.
153
+ try:
154
+ from langchain_core.callbacks.manager import CallbackManager
155
+
156
+ _original_configure = CallbackManager.configure
157
+
158
+ @classmethod
159
+ def patched_configure(cls, *args, **kwargs):
160
+ manager = _original_configure.__func__(cls, *args, **kwargs)
161
+ if manager is not None and _handler_instance is not None:
162
+ # Avoid duplicates
163
+ handler_types = {type(h) for h in manager.handlers}
164
+ if type(_handler_instance) not in handler_types:
165
+ manager.add_handler(_handler_instance)
166
+ return manager
167
+
168
+ CallbackManager.configure = patched_configure
169
+ logger.info("LangChain global callback handler registered via CallbackManager.configure")
170
+ except Exception as exc:
171
+ logger.warning("Could not register LangChain handler globally: %s. "
172
+ "Use get_callback_handler() manually.", exc)
173
+
174
+ _patched = True
175
+
176
+
177
+ def get_callback_handler():
178
+ """Return the AWX callback handler for manual use.
179
+
180
+ Usage with LangChain:
181
+ from agentwatchx.integrations.langchain_cb import get_callback_handler
182
+ llm.invoke("prompt", config={"callbacks": [get_callback_handler()]})
183
+ """
184
+ return _handler_instance
185
+
186
+
187
+ def unpatch():
188
+ """Remove the global callback handler."""
189
+ global _handler_instance, _patched
190
+ _handler_instance = None
191
+ _patched = False
@@ -0,0 +1,175 @@
1
+ """
2
+ Auto-instrumentation for LlamaIndex via its callback system.
3
+
4
+ Registers an AgentWatchX handler that captures LLM call events automatically.
5
+ Supports both llama-index >= 0.10 (llama_index.core) and legacy paths.
6
+ """
7
+
8
+ import time
9
+ import logging
10
+ from typing import Any
11
+
12
+ logger = logging.getLogger("agentwatchx.integrations.llamaindex")
13
+
14
+ _client_ref = None
15
+ _handler_instance = None
16
+ _patched = False
17
+
18
+ # Resolved at patch time
19
+ _CBEventType = None
20
+ _CallbackManager = None
21
+ _BaseCallbackHandler = None
22
+ _Settings = None
23
+
24
+
25
+ def patch(awx_client):
26
+ """Register a LlamaIndex callback handler globally."""
27
+ global _client_ref, _handler_instance, _patched
28
+ global _CBEventType, _CallbackManager, _BaseCallbackHandler, _Settings
29
+
30
+ if _patched:
31
+ return
32
+ _client_ref = awx_client
33
+
34
+ # Try modern import path first (llama-index >= 0.10)
35
+ try:
36
+ from llama_index.core.callbacks import CallbackManager, CBEventType
37
+ from llama_index.core.callbacks.base_handler import BaseCallbackHandler
38
+ from llama_index.core import Settings
39
+ except ImportError:
40
+ # Legacy import path
41
+ try:
42
+ from llama_index.callbacks import CallbackManager, CBEventType
43
+ from llama_index.callbacks.base_handler import BaseCallbackHandler
44
+ from llama_index import Settings
45
+ except ImportError:
46
+ logger.debug("llama_index callback modules not found, skipping")
47
+ return
48
+
49
+ _CBEventType = CBEventType
50
+ _CallbackManager = CallbackManager
51
+ _BaseCallbackHandler = BaseCallbackHandler
52
+ _Settings = Settings
53
+
54
+ class AWXLlamaHandler(BaseCallbackHandler):
55
+ """Captures LlamaIndex LLM events and sends them as traces."""
56
+
57
+ def __init__(self):
58
+ super().__init__(
59
+ event_starts_to_trace=[CBEventType.LLM],
60
+ event_ends_to_trace=[CBEventType.LLM],
61
+ )
62
+ self._events: dict[str, dict] = {}
63
+
64
+ def on_event_start(
65
+ self,
66
+ event_type: CBEventType,
67
+ payload: dict | None = None,
68
+ event_id: str = "",
69
+ **kwargs: Any,
70
+ ) -> str:
71
+ if event_type == CBEventType.LLM:
72
+ input_text = ""
73
+ model = "unknown"
74
+ if payload:
75
+ # Try to extract from messages
76
+ messages = payload.get("messages", [])
77
+ if messages:
78
+ last = messages[-1]
79
+ input_text = str(last.content) if hasattr(last, "content") else str(last)
80
+ elif "prompt" in payload:
81
+ input_text = str(payload["prompt"])
82
+
83
+ model = (
84
+ payload.get("model_name")
85
+ or payload.get("model")
86
+ or "unknown"
87
+ )
88
+ self._events[event_id] = {
89
+ "start": time.perf_counter(),
90
+ "input_text": input_text,
91
+ "model": model,
92
+ "metadata": {"provider": "llamaindex"},
93
+ }
94
+ return event_id
95
+
96
+ def on_event_end(
97
+ self,
98
+ event_type: CBEventType,
99
+ payload: dict | None = None,
100
+ event_id: str = "",
101
+ **kwargs: Any,
102
+ ) -> None:
103
+ if event_type != CBEventType.LLM:
104
+ return
105
+ ev = self._events.pop(event_id, None)
106
+ if not ev or _client_ref is None:
107
+ return
108
+
109
+ duration_ms = (time.perf_counter() - ev["start"]) * 1000
110
+ output_text = ""
111
+ if payload:
112
+ response = payload.get("response", payload.get("completion", ""))
113
+ output_text = str(response)
114
+ # Token usage
115
+ tokens = payload.get("token_usage") or payload.get("usage")
116
+ if tokens:
117
+ ev["metadata"]["tokens"] = (
118
+ tokens if isinstance(tokens, dict) else str(tokens)
119
+ )
120
+
121
+ from agentwatchx.schemas import TraceData
122
+ try:
123
+ _client_ref.capture(TraceData(
124
+ service="llamaindex",
125
+ input_text=ev["input_text"],
126
+ output_text=output_text or None,
127
+ llm_model=ev["model"],
128
+ duration_ms=round(duration_ms, 2),
129
+ metadata=ev["metadata"],
130
+ ))
131
+ except Exception as exc:
132
+ logger.debug("Failed to capture LlamaIndex trace: %s", exc)
133
+
134
+ def start_trace(self, trace_id: str | None = None) -> None:
135
+ pass
136
+
137
+ def end_trace(
138
+ self,
139
+ trace_id: str | None = None,
140
+ trace_map: dict[str, list[str]] | None = None,
141
+ ) -> None:
142
+ pass
143
+
144
+ _handler_instance = AWXLlamaHandler()
145
+
146
+ # Register globally via Settings.callback_manager
147
+ try:
148
+ if Settings.callback_manager is None:
149
+ Settings.callback_manager = CallbackManager([_handler_instance])
150
+ else:
151
+ Settings.callback_manager.add_handler(_handler_instance)
152
+ logger.info("LlamaIndex callback handler registered globally")
153
+ except Exception as exc:
154
+ logger.warning("Could not register LlamaIndex handler globally: %s. "
155
+ "Use get_callback_handler() manually.", exc)
156
+
157
+ _patched = True
158
+
159
+
160
+ def get_callback_handler():
161
+ """Return the AWX handler for manual use with LlamaIndex.
162
+
163
+ Usage:
164
+ from agentwatchx.integrations.llamaindex_cb import get_callback_handler
165
+ from llama_index.core import Settings
166
+ Settings.callback_manager.add_handler(get_callback_handler())
167
+ """
168
+ return _handler_instance
169
+
170
+
171
+ def unpatch():
172
+ """Remove the handler."""
173
+ global _handler_instance, _patched
174
+ _handler_instance = None
175
+ _patched = False
@@ -0,0 +1,231 @@
1
+ """
2
+ Auto-instrumentation for the OpenAI Python SDK (v1+).
3
+
4
+ Patches:
5
+ - chat.completions.create (sync + async)
6
+ - Handles both regular and streaming responses
7
+
8
+ After agentwatchx.init(), every OpenAI call is traced automatically.
9
+ """
10
+
11
+ import time
12
+ import functools
13
+ import logging
14
+
15
+ logger = logging.getLogger("agentwatchx.integrations.openai")
16
+
17
+ _original_create = None
18
+ _original_async_create = None
19
+ _client_ref = None
20
+ _patched = False
21
+
22
+
23
+ def patch(awx_client):
24
+ """Monkey-patch openai chat completions."""
25
+ global _original_create, _original_async_create, _client_ref, _patched
26
+ if _patched:
27
+ return
28
+ _client_ref = awx_client
29
+
30
+ try:
31
+ from openai.resources.chat.completions import Completions
32
+ except ImportError:
33
+ logger.debug("openai chat completions module not found, skipping")
34
+ return
35
+
36
+ _original_create = Completions.create
37
+
38
+ @functools.wraps(_original_create)
39
+ def patched_create(self, *args, **kwargs):
40
+ stream = kwargs.get("stream", False)
41
+ start = time.perf_counter()
42
+
43
+ if stream:
44
+ # For streaming, wrap the iterator to capture chunks
45
+ result = _original_create(self, *args, **kwargs)
46
+ return _wrap_stream(result, kwargs, start)
47
+
48
+ error = None
49
+ result = None
50
+ try:
51
+ result = _original_create(self, *args, **kwargs)
52
+ return result
53
+ except Exception as exc:
54
+ error = exc
55
+ raise
56
+ finally:
57
+ duration_ms = (time.perf_counter() - start) * 1000
58
+ _capture_trace(kwargs, result, error, duration_ms)
59
+
60
+ Completions.create = patched_create
61
+
62
+ # Patch async
63
+ try:
64
+ from openai.resources.chat.completions import AsyncCompletions
65
+ _original_async_create = AsyncCompletions.create
66
+
67
+ @functools.wraps(_original_async_create)
68
+ async def patched_async_create(self, *args, **kwargs):
69
+ stream = kwargs.get("stream", False)
70
+ start = time.perf_counter()
71
+
72
+ if stream:
73
+ result = await _original_async_create(self, *args, **kwargs)
74
+ return _wrap_async_stream(result, kwargs, start)
75
+
76
+ error = None
77
+ result = None
78
+ try:
79
+ result = await _original_async_create(self, *args, **kwargs)
80
+ return result
81
+ except Exception as exc:
82
+ error = exc
83
+ raise
84
+ finally:
85
+ duration_ms = (time.perf_counter() - start) * 1000
86
+ _capture_trace(kwargs, result, error, duration_ms)
87
+
88
+ AsyncCompletions.create = patched_async_create
89
+ except ImportError:
90
+ pass
91
+
92
+ _patched = True
93
+ logger.info("OpenAI auto-instrumentation applied")
94
+
95
+
96
+ def unpatch():
97
+ """Restore original methods."""
98
+ global _original_create, _original_async_create, _patched
99
+ if _original_create:
100
+ from openai.resources.chat.completions import Completions
101
+ Completions.create = _original_create
102
+ _original_create = None
103
+ if _original_async_create:
104
+ from openai.resources.chat.completions import AsyncCompletions
105
+ AsyncCompletions.create = _original_async_create
106
+ _original_async_create = None
107
+ _patched = False
108
+
109
+
110
+ def _wrap_stream(stream, kwargs, start):
111
+ """Wrap a sync streaming response to capture the full output."""
112
+ chunks = []
113
+ try:
114
+ for chunk in stream:
115
+ chunks.append(chunk)
116
+ yield chunk
117
+ finally:
118
+ duration_ms = (time.perf_counter() - start) * 1000
119
+ _capture_stream_trace(kwargs, chunks, duration_ms)
120
+
121
+
122
+ async def _wrap_async_stream(stream, kwargs, start):
123
+ """Wrap an async streaming response to capture the full output."""
124
+ chunks = []
125
+ try:
126
+ async for chunk in stream:
127
+ chunks.append(chunk)
128
+ yield chunk
129
+ finally:
130
+ duration_ms = (time.perf_counter() - start) * 1000
131
+ _capture_stream_trace(kwargs, chunks, duration_ms)
132
+
133
+
134
+ def _capture_stream_trace(kwargs, chunks, duration_ms):
135
+ """Build a trace from collected stream chunks."""
136
+ if _client_ref is None or not chunks:
137
+ return
138
+
139
+ from agentwatchx.schemas import TraceData
140
+
141
+ model = kwargs.get("model", "unknown")
142
+ messages = kwargs.get("messages", [])
143
+ input_text = messages[-1].get("content", "") if messages else ""
144
+
145
+ # Reassemble streamed content
146
+ content_parts = []
147
+ for chunk in chunks:
148
+ try:
149
+ delta = chunk.choices[0].delta
150
+ if delta and delta.content:
151
+ content_parts.append(delta.content)
152
+ except (IndexError, AttributeError):
153
+ pass
154
+
155
+ output_text = "".join(content_parts)
156
+ metadata = {"provider": "openai", "streamed": True}
157
+
158
+ # Try to get usage from the last chunk (OpenAI includes it there)
159
+ try:
160
+ last = chunks[-1]
161
+ if hasattr(last, "usage") and last.usage:
162
+ metadata["prompt_tokens"] = last.usage.prompt_tokens
163
+ metadata["completion_tokens"] = last.usage.completion_tokens
164
+ metadata["total_tokens"] = last.usage.total_tokens
165
+ except (AttributeError, TypeError):
166
+ pass
167
+
168
+ try:
169
+ _client_ref.capture(TraceData(
170
+ service="openai",
171
+ input_text=input_text,
172
+ output_text=output_text or None,
173
+ llm_model=model,
174
+ duration_ms=round(duration_ms, 2),
175
+ metadata=metadata,
176
+ ))
177
+ except Exception as exc:
178
+ logger.debug("Failed to capture OpenAI stream trace: %s", exc)
179
+
180
+
181
+ def _capture_trace(kwargs, result, error, duration_ms):
182
+ """Extract trace data from a non-streaming OpenAI chat completion call."""
183
+ if _client_ref is None:
184
+ return
185
+
186
+ from agentwatchx.schemas import TraceData
187
+
188
+ model = kwargs.get("model", "unknown")
189
+ messages = kwargs.get("messages", [])
190
+ input_text = messages[-1].get("content", "") if messages else ""
191
+
192
+ output_text = None
193
+ tool_calls = []
194
+ metadata = {"provider": "openai"}
195
+
196
+ if error:
197
+ metadata["error"] = str(error)
198
+ elif result:
199
+ try:
200
+ choice = result.choices[0]
201
+ output_text = choice.message.content or ""
202
+
203
+ # Capture tool calls if the model made any
204
+ if hasattr(choice.message, "tool_calls") and choice.message.tool_calls:
205
+ from agentwatchx.schemas import ToolCallData
206
+ for tc in choice.message.tool_calls:
207
+ tool_calls.append(ToolCallData(
208
+ name=tc.function.name,
209
+ input=tc.function.arguments,
210
+ status="success",
211
+ ))
212
+
213
+ if hasattr(result, "usage") and result.usage:
214
+ metadata["prompt_tokens"] = result.usage.prompt_tokens
215
+ metadata["completion_tokens"] = result.usage.completion_tokens
216
+ metadata["total_tokens"] = result.usage.total_tokens
217
+ except (IndexError, AttributeError):
218
+ output_text = str(result)
219
+
220
+ try:
221
+ _client_ref.capture(TraceData(
222
+ service="openai",
223
+ input_text=input_text,
224
+ output_text=output_text,
225
+ llm_model=model,
226
+ duration_ms=round(duration_ms, 2),
227
+ tool_calls=tool_calls,
228
+ metadata=metadata,
229
+ ))
230
+ except Exception as exc:
231
+ logger.debug("Failed to capture OpenAI trace: %s", exc)
agentwatchx/schemas.py ADDED
@@ -0,0 +1,19 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class ToolCallData(BaseModel):
5
+ name: str
6
+ input: dict | str | None = None
7
+ output: dict | str | None = None
8
+ status: str = "success"
9
+ duration_ms: float | None = None
10
+
11
+
12
+ class TraceData(BaseModel):
13
+ service: str = "default"
14
+ input_text: str | None = None
15
+ output_text: str | None = None
16
+ llm_model: str | None = None
17
+ duration_ms: float | None = None
18
+ tool_calls: list[ToolCallData] = []
19
+ metadata: dict = {}
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: agentwatchx
3
+ Version: 0.1.0
4
+ Summary: Observability SDK for AI Agent Execution — catches hallucinations, silent failures, and missing executions
5
+ License: MIT
6
+ Project-URL: Homepage, https://agentwatchx.com
7
+ Project-URL: Documentation, https://agentwatchx.com/docs
8
+ Keywords: ai,agents,observability,llm,tracing,openai,anthropic,langchain
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Software Development :: Libraries
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: httpx>=0.25.0
18
+ Requires-Dist: pydantic>=2.0.0
19
+ Provides-Extra: openai
20
+ Requires-Dist: openai>=1.0.0; extra == "openai"
21
+ Provides-Extra: anthropic
22
+ Requires-Dist: anthropic>=0.18.0; extra == "anthropic"
23
+ Provides-Extra: langchain
24
+ Requires-Dist: langchain-core>=0.1.0; extra == "langchain"
25
+ Provides-Extra: llamaindex
26
+ Requires-Dist: llama-index-core>=0.10.0; extra == "llamaindex"
27
+ Provides-Extra: all
28
+ Requires-Dist: openai>=1.0.0; extra == "all"
29
+ Requires-Dist: anthropic>=0.18.0; extra == "all"
30
+ Requires-Dist: langchain-core>=0.1.0; extra == "all"
31
+ Requires-Dist: llama-index-core>=0.10.0; extra == "all"
32
+ Dynamic: license-file
33
+
34
+ # AgentWatchX Python SDK
35
+
36
+ Observability for AI agents. Catches hallucinations, silent failures, and missing executions automatically.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install agentwatchx
42
+ ```
43
+
44
+ ## 2-Line Integration
45
+
46
+ ```python
47
+ import agentwatchx
48
+ agentwatchx.init(api_key="your_api_key")
49
+
50
+ # That's it. Every OpenAI/Anthropic/LangChain/LlamaIndex call is now traced.
51
+ # No decorators, no wrappers, no code changes needed.
52
+ ```
53
+
54
+ ## What it captures
55
+
56
+ - Model, input, output, token usage, latency — automatically
57
+ - Tool calls made by the LLM (function calling)
58
+ - Hallucination detection — agent claims actions it never performed
59
+ - Failure masking — tool errors the agent hides from users
60
+ - Missing execution — user asks for action, agent just talks
61
+
62
+ ## Supported Libraries
63
+
64
+ | Library | Auto-instrumented |
65
+ |---|---|
66
+ | OpenAI | ✅ |
67
+ | Anthropic | ✅ |
68
+ | LangChain | ✅ |
69
+ | LlamaIndex | ✅ |
70
+
71
+ ## Configuration
72
+
73
+ ```python
74
+ agentwatchx.init(
75
+ api_key="your_api_key",
76
+ endpoint="https://api.agentwatchx.com", # or self-hosted
77
+ service="my-agent",
78
+ flush_interval=5.0,
79
+ batch_size=100,
80
+ auto_instrument=True, # set False to disable auto-patching
81
+ )
82
+ ```
83
+
84
+ ## Advanced Usage
85
+
86
+ ```python
87
+ # Manual trace decorator
88
+ @agentwatchx.trace
89
+ def my_agent(query: str):
90
+ return client.chat.completions.create(...)
91
+
92
+ # Manual logging
93
+ agentwatchx.log({"service": "my-agent", "input": "hello", "output": "world"})
94
+
95
+ # Check status
96
+ print(agentwatchx.status())
97
+ # → {"initialized": True, "instrumented": ["openai"], "buffered_traces": 0}
98
+ ```
99
+
100
+ ## Links
101
+
102
+ - [Documentation](https://agentwatchx.com/docs)
103
+ - [Dashboard](https://agentwatchx.com/dashboard)
@@ -0,0 +1,14 @@
1
+ agentwatchx/__init__.py,sha256=kFMrM1QDujlVE40h7eLyAwS1isIx8zKDR9qgaI9g_hI,3547
2
+ agentwatchx/client.py,sha256=Ne-LViVPL0bxTFxHcJ_PIa9Rw3MzT2nOXOywAKj_kP8,2643
3
+ agentwatchx/decorators.py,sha256=VfXJFhHNWqeIbhhcHAunf85DtTpbv0WP8YXhPrvoy38,1744
4
+ agentwatchx/schemas.py,sha256=Z2-iIw_KgzsmmLNDtt86wgOdrT-hARO3vfVo6AiTbvg,478
5
+ agentwatchx/integrations/__init__.py,sha256=TA-vaORvKhvDuw2H2NAdOjG8GD33dMb7PXFlsZb-FPk,1727
6
+ agentwatchx/integrations/anthropic_patch.py,sha256=t1CGK5cmfNhm77JJgc5RwpYH8DAc_XE0OKZ1tTn4yss,8328
7
+ agentwatchx/integrations/langchain_cb.py,sha256=RJJnYiB0_OJ5u2SfwAhUrUYx2kecniHQD-AUHuBJ-YE,6826
8
+ agentwatchx/integrations/llamaindex_cb.py,sha256=vIT0JqZVH6Ax2ZLBVhjJnN1OOiMMG_EzbVXyjmq2kX8,5915
9
+ agentwatchx/integrations/openai_patch.py,sha256=7vCAoUMz1YlESCvOPcSmLBbPd3keTr8QBmG5Jclk7lE,7408
10
+ agentwatchx-0.1.0.dist-info/licenses/LICENSE,sha256=ayCgDXVU9p_G32jQoiRdGKnzuvUVX9Nd6DOphbLT2lI,1068
11
+ agentwatchx-0.1.0.dist-info/METADATA,sha256=evvx63TTUIyG_G6u9rNSH_OVizUHOx3q6DQR0FbKqC8,2959
12
+ agentwatchx-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
13
+ agentwatchx-0.1.0.dist-info/top_level.txt,sha256=3viPcduYhcOK5h4rybvsYiZzjIzYTk4OLz8LEPlzS0Q,12
14
+ agentwatchx-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AgentWatchX
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ agentwatchx