ai-watcher 0.2.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,152 @@
1
+ Metadata-Version: 2.4
2
+ Name: ai-watcher
3
+ Version: 0.2.0
4
+ Summary: AI agent observability and control — AgentWatch SDK
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://agentwatch.vercel.app
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+
10
+ # ai-watcher
11
+
12
+ Python SDK for [AgentWatch](https://agentwatch.vercel.app) — AI agent observability and control.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pip install ai-watcher
18
+ ```
19
+
20
+ ## Quickstart
21
+
22
+ ```bash
23
+ export AGENTWATCH_API_KEY=aw_live_...
24
+ ```
25
+
26
+ ### One-liner wrapper (Lambda / serverless)
27
+
28
+ ```python
29
+ from agentwatch import track_llm
30
+
31
+ result = track_llm(
32
+ 'classify-document',
33
+ lambda: openai.chat.completions.create(...),
34
+ {
35
+ 'human_id': customer_id,
36
+ 'agent_name': 'doc-classifier',
37
+ 'model': 'gpt-4o',
38
+ 'framework': 'aws-lambda',
39
+ 'session_name': 'Classify: invoice',
40
+ 'input': {'doc_type': 'invoice', 'pages': 3},
41
+ }
42
+ )
43
+ ```
44
+
45
+ ### Chained model calls (multi-step pipeline)
46
+
47
+ ```python
48
+ from agentwatch import track_chain
49
+
50
+ results = track_chain(
51
+ steps=[
52
+ {
53
+ 'action': 'extract',
54
+ 'model': 'gpt-4o',
55
+ 'fn': lambda: openai.chat.completions.create(...),
56
+ 'input': {'pages': 3},
57
+ },
58
+ {
59
+ 'action': 'classify',
60
+ 'model': 'claude-sonnet-4-20250514',
61
+ 'fn': lambda: anthropic.messages.create(...),
62
+ 'input': {'text': '...'},
63
+ },
64
+ ],
65
+ opts={
66
+ 'human_id': customer_id,
67
+ 'agent_name': 'shipping-pipeline',
68
+ 'session_name': 'Process Shipping Document',
69
+ }
70
+ )
71
+ ```
72
+
73
+ Both wrappers are zero-dependency and **never break your app** — if AgentWatch
74
+ is unreachable, the underlying function still runs.
75
+
76
+ ### Session context manager (full control)
77
+
78
+ ```python
79
+ from agentwatch import Session, tool
80
+
81
+ with Session(human_id="you@example.com") as session:
82
+
83
+ @tool(session)
84
+ def search_web(query: str) -> str:
85
+ return "results..."
86
+
87
+ result = search_web("AI agent security")
88
+ ```
89
+
90
+ ### Full Session config
91
+
92
+ ```python
93
+ from agentwatch import Session, tool
94
+
95
+ with Session(
96
+ api_key="aw_live_...",
97
+ human_id="sarah@acme.com",
98
+ agent_name="billing-agent",
99
+ agent_version="2.0.0",
100
+ model="claude-sonnet-4-20250514",
101
+ system_prompt="You are a billing assistant.",
102
+ tools=["send_invoice", "fetch_invoice"],
103
+ framework="langchain",
104
+ ) as session:
105
+
106
+ @tool(session, action_class="send", data_scope="financial")
107
+ def send_invoice(recipient: str, amount: float) -> dict:
108
+ return {"sent": True}
109
+
110
+ send_invoice("client@acme.com", 1200.00)
111
+ ```
112
+
113
+ ## API reference
114
+
115
+ ### `track_llm(action, fn, opts)`
116
+
117
+ | Field | Type | Description |
118
+ |---|---|---|
119
+ | `action` | `str` | Name of the operation (e.g. `"classify-document"`) |
120
+ | `fn` | `Callable` | Zero-argument callable that makes the model call |
121
+ | `opts` | `dict` | Session options (see below) |
122
+
123
+ ### `track_chain(steps, opts)`
124
+
125
+ Each step: `{'action': str, 'fn': callable, 'model': str, 'input': dict}`
126
+
127
+ ### Common opts fields
128
+
129
+ | Key | Default | Description |
130
+ |---|---|---|
131
+ | `human_id` | `"anonymous"` | User or customer identifier |
132
+ | `agent_name` | `"agent"` | Name of the agent/pipeline |
133
+ | `model` | `"unknown"` | Default model (overridden per step in `track_chain`) |
134
+ | `framework` | `"python"` | Runtime (e.g. `"aws-lambda"`, `"langchain"`) |
135
+ | `session_name` | action name | Human-readable session title in the dashboard |
136
+ | `input` | `None` | Input metadata to log with the event |
137
+
138
+ ## Exceptions
139
+
140
+ | Exception | When raised |
141
+ |---|---|
142
+ | `ExecutionBlockedException` | Policy blocked the tool call |
143
+ | `HitlDeniedException` | Human reviewer denied the action |
144
+ | `AgentwatchAPIError` | Non-2xx response from the API |
145
+ | `AgentwatchConnectionError` | Network error after retries |
146
+
147
+ ## Environment variables
148
+
149
+ | Variable | Default | Description |
150
+ |---|---|---|
151
+ | `AGENTWATCH_API_KEY` | — | Required. Your `aw_live_...` key. |
152
+ | `AGENTWATCH_API_URL` | `https://agentwatch.vercel.app` | Override for self-hosted. |
@@ -0,0 +1,11 @@
1
+ aiwatcher/__init__.py,sha256=SzibganpvCJXCsAzCxyELEABHaVGJoJBsalK_wev4t0,504
2
+ aiwatcher/chain.py,sha256=bee9qscWDqLk0wGaJz4teCL0-HdVqiopqYqgj8pzD3g,791
3
+ aiwatcher/client.py,sha256=e5--MGfSqjJ5zqHqf7LLH2vdZBsoE8Q-SfxMiFjT5ns,9190
4
+ aiwatcher/decorators.py,sha256=FM-buesVUVb-avKIxKlKN-ewjIz8t1P5_UKEpV2n4rk,2202
5
+ aiwatcher/exceptions.py,sha256=Bxh7FJOvd79-k6-kr6Rv90M9qD10aJHjP0FIiK6mgBk,592
6
+ aiwatcher/fingerprint.py,sha256=u0PW3tCRBMxZzi6BuwWT46OZhm9DPLie9Mv2gl4R-_g,690
7
+ aiwatcher/session.py,sha256=yoBrm3ZY4l0BztRIsiCESVAn91iVfgML9l-3I1YGc3w,6116
8
+ ai_watcher-0.2.0.dist-info/METADATA,sha256=yay6PujTqrvilWwTFU2jIKaSi6bBm2I9uTHNtBhDAFY,4014
9
+ ai_watcher-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
10
+ ai_watcher-0.2.0.dist-info/top_level.txt,sha256=IZFJhP-8jxhjDMOk_PVGtn2f5Mj7h8qchRZPvdeKZ7E,10
11
+ ai_watcher-0.2.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 @@
1
+ aiwatcher
aiwatcher/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ from .client import track_chain, track_llm
2
+ from .decorators import tool
3
+ from .exceptions import (
4
+ AgentwatchAPIError,
5
+ AgentwatchConnectionError,
6
+ AgentwatchError,
7
+ ExecutionBlockedException,
8
+ HitlDeniedException,
9
+ )
10
+ from .session import Session
11
+
12
+ __all__ = [
13
+ 'Session',
14
+ 'tool',
15
+ 'track_llm',
16
+ 'track_chain',
17
+ 'ExecutionBlockedException',
18
+ 'HitlDeniedException',
19
+ 'AgentwatchError',
20
+ 'AgentwatchAPIError',
21
+ 'AgentwatchConnectionError',
22
+ ]
23
+
24
+ __version__ = '0.2.0'
aiwatcher/chain.py ADDED
@@ -0,0 +1,29 @@
1
+ import hashlib
2
+ import json
3
+ from typing import Any, Optional
4
+
5
+
6
+ def stable_json(value: Any) -> str:
7
+ """Compact, key-sorted JSON — must match TypeScript stableStringify in src/lib/chain.ts."""
8
+ return json.dumps(value, sort_keys=True, separators=(',', ':'))
9
+
10
+
11
+ def compute_entry_hash(
12
+ session_id: str,
13
+ sequence_num: int,
14
+ event_type: str,
15
+ action: Optional[str],
16
+ input_data: Any,
17
+ output_data: Any,
18
+ prev_hash: str,
19
+ ) -> str:
20
+ payload = '|'.join([
21
+ session_id,
22
+ str(sequence_num),
23
+ event_type,
24
+ action or '',
25
+ stable_json(input_data), # json.dumps(None) → 'null', matches TS stableStringify(null)
26
+ stable_json(output_data),
27
+ prev_hash,
28
+ ])
29
+ return hashlib.sha256(payload.encode('utf-8')).hexdigest()
aiwatcher/client.py ADDED
@@ -0,0 +1,253 @@
1
+ import json
2
+ import sys
3
+ import time
4
+ import urllib.error
5
+ import urllib.request
6
+ from typing import Any, Callable, Dict, List, Optional, TypeVar
7
+
8
+ from .exceptions import AgentwatchAPIError, AgentwatchConnectionError
9
+
10
+ T = TypeVar('T')
11
+
12
+
13
+ class AgentwatchClient:
14
+ def __init__(
15
+ self,
16
+ api_url: str,
17
+ api_key: str,
18
+ max_retries: int = 3,
19
+ timeout: int = 30,
20
+ ):
21
+ self.api_url = api_url.rstrip('/')
22
+ self.api_key = api_key
23
+ self.max_retries = max_retries
24
+ self.timeout = timeout
25
+
26
+ def post(self, path: str, data: Dict[str, Any]) -> Dict[str, Any]:
27
+ url = f"{self.api_url}{path}"
28
+ # Keep falsy values (0, False, [], {}) but drop None
29
+ clean = {k: v for k, v in data.items() if v is not None}
30
+ payload = json.dumps(clean).encode('utf-8')
31
+
32
+ last_exc: Optional[Exception] = None
33
+ for attempt in range(self.max_retries):
34
+ try:
35
+ req = urllib.request.Request(
36
+ url,
37
+ data=payload,
38
+ headers={
39
+ 'Content-Type': 'application/json',
40
+ 'X-API-Key': self.api_key,
41
+ },
42
+ method='POST',
43
+ )
44
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
45
+ return json.loads(resp.read().decode('utf-8'))
46
+ except urllib.error.HTTPError as e:
47
+ body = e.read().decode('utf-8', errors='replace')
48
+ raise AgentwatchAPIError(e.code, body) from e
49
+ except urllib.error.URLError as e:
50
+ last_exc = e
51
+ if attempt < self.max_retries - 1:
52
+ time.sleep(min(2 ** attempt, 8))
53
+
54
+ raise AgentwatchConnectionError(
55
+ f"POST {path} failed after {self.max_retries} attempts: {last_exc}"
56
+ )
57
+
58
+ def get(self, path: str) -> Dict[str, Any]:
59
+ url = f"{self.api_url}{path}"
60
+ req = urllib.request.Request(
61
+ url,
62
+ headers={'X-API-Key': self.api_key},
63
+ method='GET',
64
+ )
65
+ try:
66
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
67
+ return json.loads(resp.read().decode('utf-8'))
68
+ except urllib.error.HTTPError as e:
69
+ body = e.read().decode('utf-8', errors='replace')
70
+ raise AgentwatchAPIError(e.code, body) from e
71
+
72
+ def poll_hitl(
73
+ self,
74
+ hitl_id: str,
75
+ timeout: int = 900,
76
+ poll_interval: int = 5,
77
+ ) -> Dict[str, Any]:
78
+ path = f"/api/v1/ingest/hitl/{hitl_id}/status"
79
+ deadline = time.monotonic() + timeout
80
+
81
+ while time.monotonic() < deadline:
82
+ try:
83
+ data = self.get(path)
84
+ if data.get('status') != 'pending':
85
+ return data
86
+ except Exception:
87
+ pass
88
+ remaining = deadline - time.monotonic()
89
+ if remaining <= 0:
90
+ break
91
+ time.sleep(min(poll_interval, max(remaining, 0)))
92
+
93
+ return {'status': 'expired'}
94
+
95
+
96
+ # ── Convenience wrappers ──────────────────────────────────────────────────────
97
+ # Lazy import of Session inside functions to avoid the circular import that
98
+ # would occur if imported at module level (session.py imports AgentwatchClient).
99
+
100
+ def track_llm(
101
+ action: str,
102
+ fn: Callable[[], T],
103
+ opts: Optional[Dict[str, Any]] = None,
104
+ ) -> T:
105
+ """
106
+ Wrap any synchronous AI/LLM call with AgentWatch telemetry.
107
+ Zero dependencies. Never breaks the host app.
108
+
109
+ Usage:
110
+ from aiwatcher import track_llm
111
+
112
+ result = track_llm('classify-document',
113
+ lambda: openai.chat.completions.create(...),
114
+ {
115
+ 'human_id': customer_id,
116
+ 'agent_name': 'doc-classifier',
117
+ 'model': 'gpt-4o',
118
+ 'framework': 'aws-lambda',
119
+ 'session_name': 'Classify: invoice',
120
+ 'input': {'doc_type': 'invoice', 'pages': 3}
121
+ }
122
+ )
123
+ """
124
+ from .session import Session # lazy — session.py imports this module; defer to call time
125
+
126
+ o = opts or {}
127
+
128
+ try:
129
+ session = Session(
130
+ human_id=o.get('human_id', 'anonymous'),
131
+ agent_name=o.get('agent_name', 'agent'),
132
+ framework=o.get('framework', 'python'),
133
+ model=o.get('model', 'unknown'),
134
+ metadata={'session_name': o.get('session_name', action)},
135
+ product_context=o.get('product_context'),
136
+ )
137
+ session.__enter__()
138
+ print(f"[AgentWatch] session/start: {session.session_id}", file=sys.stderr)
139
+ except Exception as e:
140
+ print(f"[AgentWatch] ERROR in {action}: {type(e).__name__}: {e}", file=sys.stderr)
141
+ return fn() # AgentWatch down — still run fn()
142
+
143
+ t0 = time.monotonic()
144
+ caught: Optional[BaseException] = None
145
+ result: Any = None
146
+
147
+ try:
148
+ result = fn()
149
+ latency_ms = int((time.monotonic() - t0) * 1000)
150
+ try:
151
+ session.log_event(
152
+ event_type='llm_call',
153
+ agent_name=session.agent_name,
154
+ action=action,
155
+ input_data=o.get('input'),
156
+ output_data={'success': True},
157
+ model=o.get('model'),
158
+ latency_ms=latency_ms,
159
+ product_context=o.get('product_context'),
160
+ outcome_context=o.get('outcome_context'),
161
+ )
162
+ print(f"[AgentWatch] event posted seq={session.sequence_num}: {action}", file=sys.stderr)
163
+ except Exception as e:
164
+ print(f"[AgentWatch] ERROR in {action}: {type(e).__name__}: {e}", file=sys.stderr)
165
+ except Exception as e:
166
+ caught = e
167
+ finally:
168
+ try:
169
+ session.__exit__(type(caught) if caught else None, caught, None)
170
+ except Exception:
171
+ pass
172
+
173
+ if caught is not None:
174
+ raise caught # type: ignore[misc]
175
+ return result # type: ignore[return-value]
176
+
177
+
178
+ def track_chain(
179
+ steps: List[Dict[str, Any]],
180
+ opts: Optional[Dict[str, Any]] = None,
181
+ ) -> List[Any]:
182
+ """
183
+ Wrap chained model calls — one session, multiple events.
184
+ Each step: {'action': str, 'fn': callable, 'model': str, 'input': dict}
185
+
186
+ Usage:
187
+ results = track_chain([
188
+ {'action': 'ocr', 'model': 'gpt-4o',
189
+ 'fn': lambda: openai.chat(...), 'input': {'pages': 3}},
190
+ {'action': 'classify', 'model': 'claude-sonnet-4-20250514',
191
+ 'fn': lambda: anthropic.messages(...), 'input': {'text': '...'}}
192
+ ], {
193
+ 'human_id': customer_id,
194
+ 'agent_name': 'shipping-pipeline',
195
+ 'session_name': 'Process Shipping Document'
196
+ })
197
+ """
198
+ from .session import Session # lazy — see note in track_llm
199
+
200
+ o = opts or {}
201
+ first_model = steps[0].get('model', 'unknown') if steps else 'unknown'
202
+
203
+ try:
204
+ session = Session(
205
+ human_id=o.get('human_id', 'anonymous'),
206
+ agent_name=o.get('agent_name', 'agent'),
207
+ framework=o.get('framework', 'python'),
208
+ model=o.get('model', first_model),
209
+ metadata={'session_name': o.get('session_name', 'chained-workflow')},
210
+ product_context=o.get('product_context'),
211
+ )
212
+ session.__enter__()
213
+ print(f"[AgentWatch] session/start: {session.session_id}", file=sys.stderr)
214
+ except Exception as e:
215
+ print(f"[AgentWatch] ERROR session/start: {type(e).__name__}: {e}", file=sys.stderr)
216
+ return [step['fn']() for step in steps] # AgentWatch down — still run steps
217
+
218
+ t0 = time.monotonic()
219
+ results: List[Any] = []
220
+ caught: Optional[BaseException] = None
221
+
222
+ try:
223
+ for step in steps:
224
+ t_step = time.monotonic()
225
+ step_result = step['fn']()
226
+ latency_ms = int((time.monotonic() - t_step) * 1000)
227
+ results.append(step_result)
228
+ try:
229
+ session.log_event(
230
+ event_type='llm_call',
231
+ agent_name=session.agent_name,
232
+ action=step['action'],
233
+ input_data=step.get('input'),
234
+ output_data={'success': True},
235
+ model=step.get('model', o.get('model', 'unknown')),
236
+ latency_ms=latency_ms,
237
+ product_context=step.get('product_context', o.get('product_context')),
238
+ outcome_context=step.get('outcome_context'),
239
+ )
240
+ print(f"[AgentWatch] event posted seq={session.sequence_num}: {step['action']}", file=sys.stderr)
241
+ except Exception as e:
242
+ print(f"[AgentWatch] ERROR in {step['action']}: {type(e).__name__}: {e}", file=sys.stderr)
243
+ except Exception as e:
244
+ caught = e
245
+ finally:
246
+ try:
247
+ session.__exit__(type(caught) if caught else None, caught, None)
248
+ except Exception:
249
+ pass
250
+
251
+ if caught is not None:
252
+ raise caught # type: ignore[misc]
253
+ return results
@@ -0,0 +1,67 @@
1
+ import time
2
+ from functools import wraps
3
+ from typing import Any, Callable
4
+
5
+ from .exceptions import ExecutionBlockedException, HitlDeniedException
6
+
7
+
8
+ def tool(
9
+ session: Any,
10
+ action_class: str = 'read',
11
+ data_scope: str = 'any',
12
+ ) -> Callable:
13
+ """
14
+ Decorator that wraps a function with AgentWatch observability and policy enforcement.
15
+
16
+ Usage:
17
+ @tool(session, action_class="send", data_scope="financial")
18
+ def send_invoice(recipient: str, amount: float) -> dict:
19
+ ...
20
+ """
21
+ def decorator(func: Callable) -> Callable:
22
+ @wraps(func)
23
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
24
+ start = time.monotonic()
25
+ input_data = {'args': list(args), 'kwargs': kwargs}
26
+
27
+ resp = session.log_event(
28
+ event_type='tool_call',
29
+ agent_name=session.agent_name,
30
+ action=func.__name__,
31
+ input_data=input_data,
32
+ output_data=None,
33
+ action_class=action_class,
34
+ data_scope=data_scope,
35
+ )
36
+
37
+ if resp.get('block'):
38
+ raise ExecutionBlockedException(
39
+ f"Policy blocked execution of '{func.__name__}'"
40
+ )
41
+
42
+ if resp.get('hitl_required'):
43
+ hitl_id = resp.get('hitl_request_id')
44
+ if hitl_id:
45
+ approval = session.client.poll_hitl(hitl_id, timeout=900)
46
+ if approval.get('status') == 'denied':
47
+ raise HitlDeniedException(
48
+ f"Human denied execution of '{func.__name__}'"
49
+ )
50
+
51
+ result = func(*args, **kwargs)
52
+ latency = int((time.monotonic() - start) * 1000)
53
+
54
+ session.log_event(
55
+ event_type='tool_result',
56
+ agent_name=session.agent_name,
57
+ action=func.__name__,
58
+ input_data=input_data,
59
+ output_data=result,
60
+ latency_ms=latency,
61
+ action_class=action_class,
62
+ data_scope=data_scope,
63
+ )
64
+
65
+ return result
66
+ return wrapper
67
+ return decorator
@@ -0,0 +1,21 @@
1
+ class AgentwatchError(Exception):
2
+ """Base class for all AgentWatch SDK errors."""
3
+
4
+
5
+ class AgentwatchAPIError(AgentwatchError):
6
+ def __init__(self, status_code: int, body: str):
7
+ self.status_code = status_code
8
+ self.body = body
9
+ super().__init__(f"API error {status_code}: {body}")
10
+
11
+
12
+ class AgentwatchConnectionError(AgentwatchError):
13
+ pass
14
+
15
+
16
+ class ExecutionBlockedException(AgentwatchError):
17
+ """Raised when a tool call is blocked by policy."""
18
+
19
+
20
+ class HitlDeniedException(AgentwatchError):
21
+ """Raised when a human reviewer denied the requested action."""
@@ -0,0 +1,25 @@
1
+ import hashlib
2
+ import json
3
+ from typing import Any, Dict, List, Optional
4
+
5
+
6
+ def _sha256(text: str) -> str:
7
+ return hashlib.sha256(text.encode('utf-8')).hexdigest()
8
+
9
+
10
+ def compute_fingerprint(
11
+ model: Optional[str] = None,
12
+ system_prompt: Optional[str] = None,
13
+ tools: Optional[List[str]] = None,
14
+ config: Optional[Dict[str, Any]] = None,
15
+ ) -> Dict[str, Any]:
16
+ fp: Dict[str, Any] = {
17
+ 'model': model or 'unknown',
18
+ 'prompt_hash': _sha256(system_prompt or ''),
19
+ 'tools': sorted(tools or []),
20
+ }
21
+ if config is not None:
22
+ fp['config_hash'] = _sha256(
23
+ json.dumps(config, sort_keys=True, separators=(',', ':'))
24
+ )
25
+ return fp
aiwatcher/session.py ADDED
@@ -0,0 +1,162 @@
1
+ import os
2
+ import time
3
+ from datetime import datetime, timezone
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ from .chain import compute_entry_hash
7
+ from .client import AgentwatchClient
8
+ from .fingerprint import compute_fingerprint
9
+
10
+
11
+ class Session:
12
+ def __init__(
13
+ self,
14
+ human_id: str,
15
+ api_key: Optional[str] = None,
16
+ api_url: Optional[str] = None,
17
+ **kwargs: Any,
18
+ ):
19
+ self.api_key = api_key or os.environ.get('AGENTWATCH_API_KEY')
20
+ self.api_url = (
21
+ api_url
22
+ or os.environ.get('AGENTWATCH_API_URL', 'https://agentwatch-pi.vercel.app')
23
+ )
24
+
25
+ if not self.api_key:
26
+ raise ValueError(
27
+ "api_key is required. Pass it directly or set AGENTWATCH_API_KEY env var."
28
+ )
29
+
30
+ self.human_id: str = human_id
31
+ self.agent_name: str = kwargs.get('agent_name', 'default-agent')
32
+ self.agent_version: Optional[str] = kwargs.get('agent_version')
33
+ self.model: Optional[str] = kwargs.get('model')
34
+ self.system_prompt: Optional[str] = kwargs.get('system_prompt')
35
+ self.tools: List[str] = kwargs.get('tools') or []
36
+ self.framework: Optional[str] = kwargs.get('framework')
37
+ self.metadata: Optional[Dict[str, Any]] = kwargs.get('metadata')
38
+ self.product_context: Optional[Dict[str, Any]] = kwargs.get('product_context')
39
+ self.human_metadata: Optional[Dict[str, Any]] = kwargs.get('human_metadata')
40
+
41
+ self.sequence_num: int = 0
42
+ self.prev_hash: str = 'genesis'
43
+ self.session_id: Optional[str] = None
44
+ self.policy: Optional[Dict[str, Any]] = None
45
+ self.fingerprint_status: Optional[str] = None
46
+ self._start_time: Optional[float] = None
47
+
48
+ self.client = AgentwatchClient(self.api_url, self.api_key)
49
+
50
+ def __enter__(self) -> 'Session':
51
+ self._start_time = time.monotonic()
52
+
53
+ fingerprint = compute_fingerprint(
54
+ model=self.model,
55
+ system_prompt=self.system_prompt,
56
+ tools=self.tools,
57
+ )
58
+
59
+ try:
60
+ resp = self.client.post('/api/v1/ingest/session/start', {
61
+ 'human_id': self.human_id,
62
+ 'human_metadata': self.human_metadata,
63
+ 'agent_name': self.agent_name,
64
+ 'agent_version': self.agent_version,
65
+ 'fingerprint': fingerprint,
66
+ 'framework': self.framework,
67
+ 'metadata': self.metadata,
68
+ 'product_context': self.product_context,
69
+ })
70
+ self.session_id = resp['session_id']
71
+ self.policy = resp.get('policy')
72
+ self.fingerprint_status = resp.get('fingerprint_status')
73
+ except Exception as e:
74
+ # API unreachable — degrade silently. session_id stays None.
75
+ # log_event() and __exit__() are no-ops when session_id is None.
76
+ import sys
77
+ print(f'[AIWatcher] session/start failed (offline mode): {e}', file=sys.stderr)
78
+
79
+ return self
80
+
81
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> bool:
82
+ if self.session_id and self._start_time is not None:
83
+ elapsed_ms = int((time.monotonic() - self._start_time) * 1000)
84
+ status = 'error' if exc_type is not None else 'completed'
85
+ try:
86
+ self.client.post('/api/v1/ingest/session/end', {
87
+ 'session_id': self.session_id,
88
+ 'status': status,
89
+ 'duration_ms': elapsed_ms,
90
+ })
91
+ except Exception:
92
+ pass # Never suppress the original exception
93
+ return False # Propagate any exception from the with-block
94
+
95
+ def log_event(
96
+ self,
97
+ event_type: str,
98
+ agent_name: str,
99
+ action: Optional[str],
100
+ input_data: Any,
101
+ output_data: Any,
102
+ model: Optional[str] = None,
103
+ tokens_in: Optional[int] = None,
104
+ tokens_out: Optional[int] = None,
105
+ cost_usd: Optional[float] = None,
106
+ latency_ms: Optional[int] = None,
107
+ action_class: str = 'read',
108
+ data_scope: str = 'any',
109
+ product_context: Optional[Dict[str, Any]] = None,
110
+ outcome_context: Optional[Dict[str, Any]] = None,
111
+ ) -> Dict[str, Any]:
112
+ if not self.session_id:
113
+ # Offline mode — API unreachable at session start, skip silently
114
+ return {}
115
+
116
+ self.sequence_num += 1
117
+ resolved_product_context = product_context or self.product_context
118
+ if resolved_product_context:
119
+ input_data = {
120
+ **(input_data if isinstance(input_data, dict) else {'value': input_data}),
121
+ 'product_context': resolved_product_context,
122
+ }
123
+ if outcome_context:
124
+ output_data = {
125
+ **(output_data if isinstance(output_data, dict) else {'value': output_data}),
126
+ 'outcome_context': outcome_context,
127
+ }
128
+
129
+ entry_hash = compute_entry_hash(
130
+ session_id=self.session_id,
131
+ sequence_num=self.sequence_num,
132
+ event_type=event_type,
133
+ action=action,
134
+ input_data=input_data,
135
+ output_data=output_data,
136
+ prev_hash=self.prev_hash,
137
+ )
138
+
139
+ now = datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')
140
+
141
+ resp = self.client.post('/api/v1/ingest/event', {
142
+ 'session_id': self.session_id,
143
+ 'event_type': event_type,
144
+ 'agent_name': agent_name,
145
+ 'action': action,
146
+ 'sequence_num': self.sequence_num,
147
+ 'input_data': input_data,
148
+ 'output_data': output_data,
149
+ 'model': model,
150
+ 'tokens_in': tokens_in,
151
+ 'tokens_out': tokens_out,
152
+ 'cost_usd': cost_usd,
153
+ 'latency_ms': latency_ms,
154
+ 'prev_hash': self.prev_hash,
155
+ 'entry_hash': entry_hash,
156
+ 'timestamp': now,
157
+ 'action_class': action_class,
158
+ 'data_scope': data_scope,
159
+ })
160
+
161
+ self.prev_hash = entry_hash
162
+ return resp