flightdeck-sensor 0.1.0a1__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.
@@ -0,0 +1,32 @@
1
+ Metadata-Version: 2.4
2
+ Name: flightdeck-sensor
3
+ Version: 0.1.0a1
4
+ Summary: In-process agent observability sensor for Flightdeck
5
+ License-Expression: Apache-2.0
6
+ Classifier: Development Status :: 3 - Alpha
7
+ Classifier: Intended Audience :: Developers
8
+ Classifier: License :: OSI Approved :: Apache Software License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.9
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.9
17
+ Provides-Extra: anthropic
18
+ Requires-Dist: anthropic>=0.20; extra == 'anthropic'
19
+ Provides-Extra: dev
20
+ Requires-Dist: httpx>=0.25; extra == 'dev'
21
+ Requires-Dist: mypy>=1.8; extra == 'dev'
22
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
23
+ Requires-Dist: pytest>=7.0; extra == 'dev'
24
+ Requires-Dist: ruff>=0.3; extra == 'dev'
25
+ Provides-Extra: openai
26
+ Requires-Dist: openai>=1.0; extra == 'openai'
27
+ Requires-Dist: tiktoken>=0.5; extra == 'openai'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # flightdeck-sensor
31
+
32
+ In-process agent observability sensor for [Flightdeck](https://github.com/flightdeckhq/flightdeck).
@@ -0,0 +1,3 @@
1
+ # flightdeck-sensor
2
+
3
+ In-process agent observability sensor for [Flightdeck](https://github.com/flightdeckhq/flightdeck).
@@ -0,0 +1 @@
1
+ """flightdeck-sensor: in-process agent observability for Flightdeck."""
@@ -0,0 +1,34 @@
1
+ """Exceptions raised by flightdeck-sensor."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class BudgetExceededError(Exception):
7
+ """Raised when the token budget is exhausted and policy is BLOCK."""
8
+
9
+ def __init__(
10
+ self,
11
+ session_id: str,
12
+ tokens_used: int,
13
+ token_limit: int,
14
+ ) -> None:
15
+ self.session_id = session_id
16
+ self.tokens_used = tokens_used
17
+ self.token_limit = token_limit
18
+ super().__init__(
19
+ f"Token budget exceeded: {tokens_used}/{token_limit} "
20
+ f"(session {session_id})"
21
+ )
22
+
23
+
24
+ class DirectiveError(Exception):
25
+ """Raised when a directive requires halting and halt policy is active."""
26
+
27
+ def __init__(self, action: str, reason: str) -> None:
28
+ self.action = action
29
+ self.reason = reason
30
+ super().__init__(f"Directive {action}: {reason}")
31
+
32
+
33
+ class ConfigurationError(Exception):
34
+ """Raised when init() receives invalid arguments."""
@@ -0,0 +1,84 @@
1
+ """Local token budget enforcement cache.
2
+
3
+ Holds the current policy thresholds (pulled from the control plane via
4
+ directive envelope) and evaluates them on every LLM call.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import threading
10
+ from typing import Any
11
+
12
+ from flightdeck_sensor.core.types import PolicyDecision
13
+
14
+
15
+ class PolicyCache:
16
+ """Thread-safe local cache of the token budget policy.
17
+
18
+ ``check()`` is called on every LLM call before and after the actual
19
+ provider request. It is deliberately cheap -- all data is in memory,
20
+ no I/O, no locking beyond a fast ``threading.Lock`` for the fire-once
21
+ flag.
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ token_limit: int | None = None,
27
+ warn_at_pct: int = 80,
28
+ degrade_at_pct: int = 90,
29
+ block_at_pct: int = 100,
30
+ degrade_to: str | None = None,
31
+ ) -> None:
32
+ self.token_limit = token_limit
33
+ self.warn_at_pct = warn_at_pct
34
+ self.degrade_at_pct = degrade_at_pct
35
+ self.block_at_pct = block_at_pct
36
+ self.degrade_to = degrade_to
37
+
38
+ self._warned = False
39
+ self._lock = threading.Lock()
40
+
41
+ def check(self, tokens_used: int, estimated: int) -> PolicyDecision:
42
+ """Evaluate thresholds against *tokens_used* + *estimated*.
43
+
44
+ Returns the highest-severity decision that applies:
45
+
46
+ * :attr:`PolicyDecision.BLOCK` -- budget exhausted, call must not
47
+ proceed.
48
+ * :attr:`PolicyDecision.DEGRADE` -- budget nearly exhausted, swap
49
+ to a cheaper model.
50
+ * :attr:`PolicyDecision.WARN` -- approaching limit (fires once
51
+ per session).
52
+ * :attr:`PolicyDecision.ALLOW` -- under all thresholds.
53
+ """
54
+ if self.token_limit is None:
55
+ return PolicyDecision.ALLOW
56
+
57
+ projected = tokens_used + estimated
58
+ pct = (projected * 100) // self.token_limit
59
+
60
+ if pct >= self.block_at_pct:
61
+ return PolicyDecision.BLOCK
62
+
63
+ if pct >= self.degrade_at_pct:
64
+ return PolicyDecision.DEGRADE
65
+
66
+ if pct >= self.warn_at_pct:
67
+ with self._lock:
68
+ if not self._warned:
69
+ self._warned = True
70
+ return PolicyDecision.WARN
71
+ return PolicyDecision.ALLOW
72
+
73
+ return PolicyDecision.ALLOW
74
+
75
+ def update(self, policy_dict: dict[str, Any]) -> None:
76
+ """Atomically replace all fields from a directive payload."""
77
+ self.token_limit = policy_dict.get("token_limit", self.token_limit)
78
+ self.warn_at_pct = policy_dict.get("warn_at_pct", self.warn_at_pct)
79
+ self.degrade_at_pct = policy_dict.get("degrade_at_pct", self.degrade_at_pct)
80
+ self.block_at_pct = policy_dict.get("block_at_pct", self.block_at_pct)
81
+ self.degrade_to = policy_dict.get("degrade_to", self.degrade_to)
82
+ # Reset warn flag when policy changes
83
+ with self._lock:
84
+ self._warned = False
@@ -0,0 +1,271 @@
1
+ """Session lifecycle management for flightdeck-sensor.
2
+
3
+ A ``Session`` represents one running instance of an agent. It holds the
4
+ sensor configuration, manages the heartbeat daemon thread, registers
5
+ process-exit handlers, and posts lifecycle events to the control plane.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import atexit
11
+ import logging
12
+ import os
13
+ import signal
14
+ import socket
15
+ import threading
16
+ from datetime import datetime, timezone
17
+ from typing import TYPE_CHECKING, Any
18
+
19
+ from flightdeck_sensor.core.policy import PolicyCache
20
+ from flightdeck_sensor.core.types import (
21
+ Directive,
22
+ DirectiveAction,
23
+ EventType,
24
+ SensorConfig,
25
+ SessionState,
26
+ StatusResponse,
27
+ TokenUsage,
28
+ )
29
+
30
+ if TYPE_CHECKING:
31
+ from flightdeck_sensor.transport.client import ControlPlaneClient
32
+
33
+ _log = logging.getLogger("flightdeck_sensor.core.session")
34
+
35
+ _HEARTBEAT_INTERVAL_SECS = 30
36
+
37
+
38
+ class Session:
39
+ """Manages the lifecycle of a single sensor session."""
40
+
41
+ def __init__(
42
+ self,
43
+ config: SensorConfig,
44
+ client: ControlPlaneClient,
45
+ ) -> None:
46
+ self.config = config
47
+ self.client = client
48
+ self.policy = PolicyCache()
49
+
50
+ self._state = SessionState.ACTIVE
51
+ self._tokens_used = 0
52
+ self._token_limit: int | None = None
53
+ self._lock = threading.Lock()
54
+
55
+ self._stopped = threading.Event()
56
+ self._heartbeat_thread: threading.Thread | None = None
57
+ self._host = socket.gethostname()
58
+ self._framework: str | None = None
59
+ self._model: str | None = None
60
+
61
+ # ------------------------------------------------------------------
62
+ # Public API
63
+ # ------------------------------------------------------------------
64
+
65
+ def start(self) -> None:
66
+ """Fire SESSION_START and begin the heartbeat daemon thread."""
67
+ self._post_event(EventType.SESSION_START)
68
+ self._start_heartbeat()
69
+ self._register_handlers()
70
+ if not self.config.quiet:
71
+ _log.info(
72
+ "Flightdeck session started: flavor=%s session=%s",
73
+ self.config.agent_flavor,
74
+ self.config.session_id,
75
+ )
76
+
77
+ def end(self) -> None:
78
+ """Fire SESSION_END and stop the heartbeat thread.
79
+
80
+ Safe to call multiple times -- second call is a no-op.
81
+ """
82
+ if self._state == SessionState.CLOSED:
83
+ return
84
+ self._state = SessionState.CLOSED
85
+ self._stopped.set()
86
+ if self._heartbeat_thread is not None:
87
+ self._heartbeat_thread.join(timeout=5)
88
+ self._post_event(EventType.SESSION_END)
89
+ self.client.close()
90
+ if not self.config.quiet:
91
+ _log.info(
92
+ "Flightdeck session ended: session=%s tokens=%d",
93
+ self.config.session_id,
94
+ self._tokens_used,
95
+ )
96
+
97
+ def record_usage(self, usage: TokenUsage) -> None:
98
+ """Atomically increment session token counts."""
99
+ with self._lock:
100
+ self._tokens_used += usage.total
101
+
102
+ def record_model(self, model: str) -> None:
103
+ """Record the model used in the most recent call."""
104
+ self._model = model
105
+
106
+ def record_framework(self, framework: str) -> None:
107
+ """Record the framework if detected."""
108
+ self._framework = framework
109
+
110
+ def post_call_event(
111
+ self,
112
+ event_type: EventType,
113
+ usage: TokenUsage,
114
+ model: str,
115
+ latency_ms: int,
116
+ tool_name: str | None = None,
117
+ ) -> Directive | None:
118
+ """Post a call event and return any received directive."""
119
+ self.record_usage(usage)
120
+ self.record_model(model)
121
+ return self._post_event(
122
+ event_type,
123
+ model=model,
124
+ tokens_input=usage.input_tokens,
125
+ tokens_output=usage.output_tokens,
126
+ tokens_total=usage.total,
127
+ latency_ms=latency_ms,
128
+ tool_name=tool_name,
129
+ )
130
+
131
+ def get_status(self) -> StatusResponse:
132
+ """Build a status snapshot of the current session."""
133
+ with self._lock:
134
+ tokens = self._tokens_used
135
+ limit = self._token_limit
136
+ pct: float | None = None
137
+ if limit is not None and limit > 0:
138
+ pct = round((tokens / limit) * 100, 1)
139
+ return StatusResponse(
140
+ session_id=self.config.session_id,
141
+ flavor=self.config.agent_flavor,
142
+ agent_type=self.config.agent_type,
143
+ state=self._state,
144
+ tokens_used=tokens,
145
+ token_limit=limit,
146
+ pct_used=pct,
147
+ )
148
+
149
+ @property
150
+ def state(self) -> SessionState:
151
+ return self._state
152
+
153
+ @property
154
+ def tokens_used(self) -> int:
155
+ with self._lock:
156
+ return self._tokens_used
157
+
158
+ @property
159
+ def token_limit(self) -> int | None:
160
+ return self._token_limit
161
+
162
+ # ------------------------------------------------------------------
163
+ # Heartbeat
164
+ # ------------------------------------------------------------------
165
+
166
+ def _start_heartbeat(self) -> None:
167
+ self._heartbeat_thread = threading.Thread(
168
+ target=self._heartbeat_loop,
169
+ daemon=True,
170
+ name="flightdeck-heartbeat",
171
+ )
172
+ self._heartbeat_thread.start()
173
+
174
+ def _heartbeat_loop(self) -> None:
175
+ """Daemon thread: post heartbeat every 30 s until stopped."""
176
+ while not self._stopped.wait(timeout=_HEARTBEAT_INTERVAL_SECS):
177
+ directive = self.client.post_heartbeat(self.config.session_id)
178
+ if directive is not None:
179
+ self._apply_directive(directive)
180
+
181
+ # ------------------------------------------------------------------
182
+ # Event posting
183
+ # ------------------------------------------------------------------
184
+
185
+ def _post_event(
186
+ self,
187
+ event_type: EventType,
188
+ **extra: Any,
189
+ ) -> Directive | None:
190
+ """Build the full event payload and POST it to the control plane."""
191
+ payload = self._build_payload(event_type, **extra)
192
+ directive = self.client.post_event(payload)
193
+ if directive is not None:
194
+ self._apply_directive(directive)
195
+ return directive
196
+
197
+ def _build_payload(
198
+ self,
199
+ event_type: EventType,
200
+ **extra: Any,
201
+ ) -> dict[str, Any]:
202
+ with self._lock:
203
+ tokens_used_session = self._tokens_used
204
+
205
+ payload: dict[str, Any] = {
206
+ "session_id": self.config.session_id,
207
+ "flavor": self.config.agent_flavor,
208
+ "agent_type": self.config.agent_type,
209
+ "event_type": event_type.value,
210
+ "host": self._host,
211
+ "framework": self._framework,
212
+ "model": self._model,
213
+ "tokens_input": None,
214
+ "tokens_output": None,
215
+ "tokens_total": None,
216
+ "tokens_used_session": tokens_used_session,
217
+ "token_limit_session": self._token_limit,
218
+ "latency_ms": None,
219
+ "tool_name": None,
220
+ "tool_input": None,
221
+ "tool_result": None,
222
+ "has_content": False,
223
+ "content": None,
224
+ "timestamp": datetime.now(timezone.utc).isoformat(),
225
+ }
226
+ payload.update(extra)
227
+ return payload
228
+
229
+ # ------------------------------------------------------------------
230
+ # Directives
231
+ # ------------------------------------------------------------------
232
+
233
+ def _apply_directive(self, directive: Directive) -> None:
234
+ """Handle a directive received from the control plane."""
235
+ if directive.action == DirectiveAction.POLICY_UPDATE:
236
+ _log.info("Policy update received")
237
+ elif directive.action in (
238
+ DirectiveAction.SHUTDOWN,
239
+ DirectiveAction.SHUTDOWN_FLAVOR,
240
+ ):
241
+ _log.warning(
242
+ "Shutdown directive received: %s (reason: %s)",
243
+ directive.action.value,
244
+ directive.reason,
245
+ )
246
+ else:
247
+ _log.info("Directive received: %s", directive.action.value)
248
+
249
+ # ------------------------------------------------------------------
250
+ # Process exit handlers
251
+ # ------------------------------------------------------------------
252
+
253
+ def _register_handlers(self) -> None:
254
+ """Register atexit and signal handlers for clean shutdown."""
255
+ atexit.register(self.end)
256
+ # Only register signal handlers on the main thread
257
+ if threading.current_thread() is threading.main_thread():
258
+ self._register_signal(signal.SIGTERM)
259
+ if os.name != "nt":
260
+ self._register_signal(signal.SIGINT)
261
+
262
+ def _register_signal(self, sig: signal.Signals) -> None:
263
+ """Install a signal handler that calls end() and re-raises."""
264
+ prev = signal.getsignal(sig)
265
+
266
+ def _handler(signum: int, frame: Any) -> None:
267
+ self.end()
268
+ if callable(prev):
269
+ prev(signum, frame)
270
+
271
+ signal.signal(sig, _handler)
@@ -0,0 +1,96 @@
1
+ """Pure data types for flightdeck-sensor. No external dependencies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+ import uuid
7
+ from dataclasses import dataclass, field
8
+
9
+
10
+ class SessionState(enum.Enum):
11
+ """Lifecycle state of a sensor session."""
12
+
13
+ ACTIVE = "active"
14
+ IDLE = "idle"
15
+ STALE = "stale"
16
+ CLOSED = "closed"
17
+ LOST = "lost"
18
+
19
+
20
+ class EventType(enum.Enum):
21
+ """All event types the sensor can emit."""
22
+
23
+ SESSION_START = "session_start"
24
+ SESSION_END = "session_end"
25
+ HEARTBEAT = "heartbeat"
26
+ PRE_CALL = "pre_call"
27
+ POST_CALL = "post_call"
28
+ TOOL_CALL = "tool_call"
29
+
30
+
31
+ class DirectiveAction(enum.Enum):
32
+ """Actions the control plane can instruct the sensor to take."""
33
+
34
+ SHUTDOWN = "shutdown"
35
+ SHUTDOWN_FLAVOR = "shutdown_flavor"
36
+ DEGRADE = "degrade"
37
+ THROTTLE = "throttle"
38
+ POLICY_UPDATE = "policy_update"
39
+ CHECKPOINT = "checkpoint"
40
+
41
+
42
+ class PolicyDecision(enum.Enum):
43
+ """Result of a policy check against current token usage."""
44
+
45
+ ALLOW = "allow"
46
+ WARN = "warn"
47
+ DEGRADE = "degrade"
48
+ BLOCK = "block"
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class TokenUsage:
53
+ """Token counts from a single LLM call."""
54
+
55
+ input_tokens: int = 0
56
+ output_tokens: int = 0
57
+
58
+ @property
59
+ def total(self) -> int:
60
+ return self.input_tokens + self.output_tokens
61
+
62
+
63
+ @dataclass
64
+ class SensorConfig:
65
+ """Configuration for a sensor session."""
66
+
67
+ server: str
68
+ token: str
69
+ capture_prompts: bool = False
70
+ unavailable_policy: str = "continue"
71
+ agent_flavor: str = "unknown"
72
+ agent_type: str = "autonomous"
73
+ session_id: str = field(default_factory=lambda: str(uuid.uuid4()))
74
+ quiet: bool = False
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class Directive:
79
+ """A control-plane directive received in the event response envelope."""
80
+
81
+ action: DirectiveAction
82
+ reason: str
83
+ grace_period_ms: int = 5000
84
+
85
+
86
+ @dataclass
87
+ class StatusResponse:
88
+ """Current status of the sensor session, returned by get_status()."""
89
+
90
+ session_id: str
91
+ flavor: str
92
+ agent_type: str
93
+ state: SessionState
94
+ tokens_used: int
95
+ token_limit: int | None
96
+ pct_used: float | None
@@ -0,0 +1,96 @@
1
+ """Anthropic provider: token estimation and usage extraction.
2
+
3
+ Reimplements the patterns from tokencap -- this is a standalone
4
+ implementation with no dependency on tokencap.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ import logging
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from flightdeck_sensor.core.types import TokenUsage
14
+
15
+ if TYPE_CHECKING:
16
+ from flightdeck_sensor.providers.protocol import PromptContent
17
+
18
+ _log = logging.getLogger("flightdeck_sensor.providers.anthropic")
19
+
20
+
21
+ class AnthropicProvider:
22
+ """Provider adapter for the Anthropic Python SDK.
23
+
24
+ All methods are safe to call on the hot path. None of them raise
25
+ exceptions -- failures return zero/empty defaults.
26
+ """
27
+
28
+ def __init__(self, capture_prompts: bool = False) -> None:
29
+ self._capture_prompts = capture_prompts
30
+
31
+ def estimate_tokens(self, request_kwargs: dict[str, Any]) -> int:
32
+ """Estimate input tokens using a character-based heuristic.
33
+
34
+ Uses ``len(str(messages)) // 4`` as a conservative approximation.
35
+ The actual token count is reconciled after the call via
36
+ ``extract_usage()``.
37
+ """
38
+ try:
39
+ messages = request_kwargs.get("messages", [])
40
+ system = request_kwargs.get("system", "")
41
+ tools = request_kwargs.get("tools", [])
42
+ text = str(messages) + str(system) + str(tools)
43
+ return len(text) // 4
44
+ except Exception:
45
+ return 0
46
+
47
+ def extract_usage(self, response: Any) -> TokenUsage:
48
+ """Extract actual token counts from an Anthropic response.
49
+
50
+ Handles both sync ``Message`` objects and raw response wrappers.
51
+ Returns ``TokenUsage(0, 0)`` on any failure -- never raises.
52
+ """
53
+ try:
54
+ obj = response
55
+ # Handle raw response wrappers
56
+ if hasattr(obj, "parse") and callable(obj.parse):
57
+ with contextlib.suppress(Exception):
58
+ obj = obj.parse()
59
+
60
+ usage = getattr(obj, "usage", None)
61
+ if usage is None:
62
+ return TokenUsage(input_tokens=0, output_tokens=0)
63
+
64
+ input_tokens = getattr(usage, "input_tokens", 0) or 0
65
+ output_tokens = getattr(usage, "output_tokens", 0) or 0
66
+
67
+ # Include cache tokens in input count for accurate totals
68
+ cache_read = getattr(usage, "cache_read_input_tokens", 0) or 0
69
+ cache_write = getattr(usage, "cache_creation_input_tokens", 0) or 0
70
+
71
+ return TokenUsage(
72
+ input_tokens=input_tokens + cache_read + cache_write,
73
+ output_tokens=output_tokens,
74
+ )
75
+ except Exception:
76
+ return TokenUsage(input_tokens=0, output_tokens=0)
77
+
78
+ def extract_content(
79
+ self,
80
+ request_kwargs: dict[str, Any],
81
+ response: Any,
82
+ ) -> PromptContent | None:
83
+ """Extract prompt content for storage.
84
+
85
+ Returns ``None`` in Phase 1 -- prompt capture is not implemented
86
+ until Phase 5.
87
+ """
88
+ return None
89
+
90
+ def get_model(self, request_kwargs: dict[str, Any]) -> str:
91
+ """Extract the model name from request kwargs."""
92
+ try:
93
+ model: str = request_kwargs["model"]
94
+ return model
95
+ except (KeyError, TypeError):
96
+ return ""
@@ -0,0 +1,124 @@
1
+ """OpenAI provider: token estimation and usage extraction.
2
+
3
+ Reimplements the patterns from tokencap -- this is a standalone
4
+ implementation with no dependency on tokencap.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ import logging
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from flightdeck_sensor.core.types import TokenUsage
14
+
15
+ if TYPE_CHECKING:
16
+ from flightdeck_sensor.providers.protocol import PromptContent
17
+
18
+ _log = logging.getLogger("flightdeck_sensor.providers.openai")
19
+
20
+
21
+ def _try_tiktoken_count(messages: list[Any], model: str) -> int | None:
22
+ """Attempt to count tokens using tiktoken if installed.
23
+
24
+ Returns ``None`` if tiktoken is unavailable or fails.
25
+ """
26
+ try:
27
+ import tiktoken
28
+
29
+ try:
30
+ enc = tiktoken.encoding_for_model(model)
31
+ except KeyError:
32
+ enc = tiktoken.get_encoding("cl100k_base")
33
+
34
+ total = 0
35
+ for msg in messages:
36
+ # Per-message overhead (role, content separators)
37
+ total += 4
38
+ if isinstance(msg, dict):
39
+ for value in msg.values():
40
+ total += len(enc.encode(str(value)))
41
+ total += 2 # reply priming
42
+ return total
43
+ except Exception:
44
+ return None
45
+
46
+
47
+ class OpenAIProvider:
48
+ """Provider adapter for the OpenAI Python SDK.
49
+
50
+ All methods are safe to call on the hot path. None of them raise
51
+ exceptions -- failures return zero/empty defaults.
52
+ """
53
+
54
+ def __init__(self, capture_prompts: bool = False) -> None:
55
+ self._capture_prompts = capture_prompts
56
+
57
+ def estimate_tokens(self, request_kwargs: dict[str, Any]) -> int:
58
+ """Estimate input tokens using tiktoken if available, else char//4.
59
+
60
+ tiktoken gives accurate counts for OpenAI models. The character
61
+ heuristic is a conservative fallback.
62
+ """
63
+ try:
64
+ messages = request_kwargs.get("messages", [])
65
+ model = request_kwargs.get("model", "")
66
+
67
+ # Try tiktoken first
68
+ count = _try_tiktoken_count(messages, model)
69
+ if count is not None:
70
+ return count
71
+
72
+ # Fallback: character-based heuristic
73
+ tools = request_kwargs.get("tools", [])
74
+ text = str(messages) + str(tools)
75
+ return len(text) // 4
76
+ except Exception:
77
+ return 0
78
+
79
+ def extract_usage(self, response: Any) -> TokenUsage:
80
+ """Extract actual token counts from an OpenAI response.
81
+
82
+ Handles both sync ``ChatCompletion`` objects and raw wrappers.
83
+ Returns ``TokenUsage(0, 0)`` on any failure -- never raises.
84
+ """
85
+ try:
86
+ obj = response
87
+ # Handle raw response wrappers
88
+ if hasattr(obj, "parse") and callable(obj.parse):
89
+ with contextlib.suppress(Exception):
90
+ obj = obj.parse()
91
+
92
+ usage = getattr(obj, "usage", None)
93
+ if usage is None:
94
+ return TokenUsage(input_tokens=0, output_tokens=0)
95
+
96
+ prompt_tokens = getattr(usage, "prompt_tokens", 0) or 0
97
+ completion_tokens = getattr(usage, "completion_tokens", 0) or 0
98
+
99
+ return TokenUsage(
100
+ input_tokens=prompt_tokens,
101
+ output_tokens=completion_tokens,
102
+ )
103
+ except Exception:
104
+ return TokenUsage(input_tokens=0, output_tokens=0)
105
+
106
+ def extract_content(
107
+ self,
108
+ request_kwargs: dict[str, Any],
109
+ response: Any,
110
+ ) -> PromptContent | None:
111
+ """Extract prompt content for storage.
112
+
113
+ Returns ``None`` in Phase 1 -- prompt capture is not implemented
114
+ until Phase 5.
115
+ """
116
+ return None
117
+
118
+ def get_model(self, request_kwargs: dict[str, Any]) -> str:
119
+ """Extract the model name from request kwargs."""
120
+ try:
121
+ model: str = request_kwargs["model"]
122
+ return model
123
+ except (KeyError, TypeError):
124
+ return ""
@@ -0,0 +1,60 @@
1
+ """Provider protocol and shared content dataclass."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING, Any, Protocol
7
+
8
+ if TYPE_CHECKING:
9
+ from flightdeck_sensor.core.types import TokenUsage
10
+
11
+
12
+ class Provider(Protocol):
13
+ """Interface every LLM provider adapter must implement.
14
+
15
+ All methods must be safe to call on the hot path. None of them
16
+ may raise exceptions -- failures return zero/empty defaults.
17
+ """
18
+
19
+ def estimate_tokens(self, request_kwargs: dict[str, Any]) -> int:
20
+ """Estimate input token count before the call. Never raises."""
21
+ ...
22
+
23
+ def extract_usage(self, response: Any) -> TokenUsage:
24
+ """Extract actual token counts from the provider response. Never raises."""
25
+ ...
26
+
27
+ def extract_content(
28
+ self,
29
+ request_kwargs: dict[str, Any],
30
+ response: Any,
31
+ ) -> PromptContent | None:
32
+ """Extract prompt content when capture_prompts is enabled.
33
+
34
+ Returns None when capture is disabled or on any error.
35
+ Never raises.
36
+ """
37
+ ...
38
+
39
+ def get_model(self, request_kwargs: dict[str, Any]) -> str:
40
+ """Extract the model name from request kwargs. Returns '' on failure."""
41
+ ...
42
+
43
+
44
+ @dataclass
45
+ class PromptContent:
46
+ """Raw content extracted from a single LLM call.
47
+
48
+ Provider terminology is preserved exactly -- no normalization.
49
+ Anthropic uses 'system' as a separate field; OpenAI embeds it in messages.
50
+ """
51
+
52
+ system: str | None
53
+ messages: list[dict[str, Any]]
54
+ tools: list[dict[str, Any]] | None
55
+ response: dict[str, Any]
56
+ provider: str
57
+ model: str
58
+ session_id: str
59
+ event_id: str
60
+ captured_at: str
File without changes
@@ -0,0 +1,124 @@
1
+ """HTTP transport to the Flightdeck control plane.
2
+
3
+ Uses only the Python standard library (``urllib.request``).
4
+ No ``requests``, no ``httpx`` -- zero required dependencies.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import urllib.request
12
+ from typing import Any
13
+ from urllib.error import HTTPError, URLError
14
+
15
+ from flightdeck_sensor.core.exceptions import DirectiveError
16
+ from flightdeck_sensor.core.types import Directive, DirectiveAction
17
+ from flightdeck_sensor.transport.retry import with_retry
18
+
19
+ _log = logging.getLogger("flightdeck_sensor.transport.client")
20
+
21
+ _TIMEOUT_SECS = 10
22
+
23
+
24
+ class ControlPlaneClient:
25
+ """Fire-and-forget HTTP client for sensor → control plane communication.
26
+
27
+ On connectivity failure the behaviour depends on *unavailable_policy*:
28
+
29
+ * ``"continue"`` -- log a warning, return ``None``, agent proceeds.
30
+ * ``"halt"`` -- raise :class:`DirectiveError`, agent must stop.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ server: str,
36
+ token: str,
37
+ unavailable_policy: str = "continue",
38
+ ) -> None:
39
+ self._base_url = server.rstrip("/")
40
+ self._token = token
41
+ self._unavailable_policy = unavailable_policy
42
+
43
+ # ------------------------------------------------------------------
44
+ # Public API
45
+ # ------------------------------------------------------------------
46
+
47
+ def post_event(self, payload: dict[str, Any]) -> Directive | None:
48
+ """POST an event to ``/v1/events`` and return any embedded directive."""
49
+ return self._post("/v1/events", payload)
50
+
51
+ def post_heartbeat(self, session_id: str) -> Directive | None:
52
+ """POST a heartbeat to ``/v1/heartbeat``."""
53
+ return self._post("/v1/heartbeat", {"session_id": session_id})
54
+
55
+ def close(self) -> None:
56
+ """No-op -- stdlib ``urllib`` has no persistent connection to close."""
57
+
58
+ # ------------------------------------------------------------------
59
+ # Internals
60
+ # ------------------------------------------------------------------
61
+
62
+ def _post(self, path: str, body: dict[str, Any]) -> Directive | None:
63
+ """POST JSON and parse the response envelope for a directive."""
64
+ url = f"{self._base_url}{path}"
65
+ data = json.dumps(body).encode()
66
+ req = urllib.request.Request(
67
+ url,
68
+ data=data,
69
+ headers={
70
+ "Content-Type": "application/json",
71
+ "Authorization": f"Bearer {self._token}",
72
+ },
73
+ method="POST",
74
+ )
75
+
76
+ try:
77
+ raw = with_retry(lambda: self._do_request(req))
78
+ except (URLError, OSError, TimeoutError, ConnectionError) as exc:
79
+ return self._handle_unavailable(exc)
80
+
81
+ return self._parse_directive(raw)
82
+
83
+ def _do_request(self, req: urllib.request.Request) -> dict[str, Any]:
84
+ """Execute a single HTTP request and return the parsed JSON body.
85
+
86
+ Raises on HTTP 5xx so that :func:`with_retry` can retry.
87
+ HTTP 4xx is a caller bug -- log and return a neutral response.
88
+ """
89
+ try:
90
+ with urllib.request.urlopen(req, timeout=_TIMEOUT_SECS) as resp:
91
+ body: dict[str, Any] = json.loads(resp.read().decode())
92
+ return body
93
+ except HTTPError as exc:
94
+ if exc.code >= 500:
95
+ raise # let retry handle it
96
+ # 4xx -- caller bug, not retryable
97
+ _log.warning("Control plane returned HTTP %d: %s", exc.code, exc.reason)
98
+ return {"status": "ok", "directive": None}
99
+
100
+ def _handle_unavailable(self, exc: BaseException) -> Directive | None:
101
+ """Apply the unavailability policy after exhausting retries."""
102
+ if self._unavailable_policy == "halt":
103
+ raise DirectiveError(
104
+ action="halt",
105
+ reason=f"Control plane unreachable: {exc}",
106
+ )
107
+ _log.warning("Control plane unreachable (policy=continue): %s", exc)
108
+ return None
109
+
110
+ @staticmethod
111
+ def _parse_directive(body: dict[str, Any]) -> Directive | None:
112
+ """Extract a :class:`Directive` from the response envelope, if present."""
113
+ raw = body.get("directive")
114
+ if raw is None:
115
+ return None
116
+ try:
117
+ return Directive(
118
+ action=DirectiveAction(raw["action"]),
119
+ reason=raw.get("reason", ""),
120
+ grace_period_ms=raw.get("grace_period_ms", 5000),
121
+ )
122
+ except (KeyError, ValueError) as exc:
123
+ _log.warning("Malformed directive in response: %s (%s)", raw, exc)
124
+ return None
@@ -0,0 +1,53 @@
1
+ """Exponential backoff retry for transient HTTP failures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from typing import Callable, TypeVar
8
+ from urllib.error import URLError
9
+
10
+ _T = TypeVar("_T")
11
+
12
+ _log = logging.getLogger("flightdeck_sensor.transport.retry")
13
+
14
+
15
+ def with_retry(
16
+ fn: Callable[[], _T],
17
+ *,
18
+ max_attempts: int = 3,
19
+ backoff_base: float = 0.5,
20
+ retryable: tuple[type[BaseException], ...] = (
21
+ ConnectionError,
22
+ TimeoutError,
23
+ URLError,
24
+ OSError,
25
+ ),
26
+ ) -> _T:
27
+ """Execute *fn* with exponential backoff on transient failures.
28
+
29
+ Retries on connection errors, timeouts, and ``URLError``.
30
+ HTTP 5xx responses must be converted to exceptions by the caller
31
+ before reaching this function.
32
+
33
+ Raises the final exception unchanged after *max_attempts* failures.
34
+ """
35
+ last_exc: BaseException | None = None
36
+ for attempt in range(max_attempts):
37
+ try:
38
+ return fn()
39
+ except retryable as exc:
40
+ last_exc = exc
41
+ if attempt < max_attempts - 1:
42
+ delay = backoff_base * (2**attempt)
43
+ _log.warning(
44
+ "Transient failure (attempt %d/%d), retrying in %.1fs: %s",
45
+ attempt + 1,
46
+ max_attempts,
47
+ delay,
48
+ exc,
49
+ )
50
+ time.sleep(delay)
51
+ # Should never reach here without last_exc being set, but mypy needs the assert.
52
+ assert last_exc is not None
53
+ raise last_exc
@@ -0,0 +1,60 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "flightdeck-sensor"
7
+ version = "0.1.0a1"
8
+ description = "In-process agent observability sensor for Flightdeck"
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ requires-python = ">=3.9"
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Intended Audience :: Developers",
15
+ "License :: OSI Approved :: Apache Software License",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.9",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Typing :: Typed",
23
+ ]
24
+
25
+ [project.optional-dependencies]
26
+ anthropic = ["anthropic>=0.20"]
27
+ openai = ["openai>=1.0", "tiktoken>=0.5"]
28
+ dev = [
29
+ "pytest>=7.0",
30
+ "pytest-asyncio>=0.21",
31
+ "mypy>=1.8",
32
+ "ruff>=0.3",
33
+ "httpx>=0.25",
34
+ ]
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["flightdeck_sensor"]
38
+
39
+ [tool.mypy]
40
+ strict = true
41
+ warn_return_any = true
42
+ warn_unused_configs = true
43
+ disallow_untyped_defs = true
44
+ disallow_incomplete_defs = true
45
+ check_untyped_defs = true
46
+ disallow_any_generics = true
47
+ no_implicit_optional = true
48
+ warn_redundant_casts = true
49
+ warn_unused_ignores = true
50
+
51
+ [tool.ruff]
52
+ target-version = "py39"
53
+ line-length = 100
54
+
55
+ [tool.ruff.lint]
56
+ select = ["E", "F", "I", "N", "W", "UP", "B", "SIM", "TCH"]
57
+
58
+ [tool.pytest.ini_options]
59
+ testpaths = ["tests"]
60
+ asyncio_mode = "auto"