flightdeck-sensor 0.7.1__tar.gz → 0.8.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/CLAUDE.md +1 -1
  2. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/PKG-INFO +3 -3
  3. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/README.md +2 -2
  4. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/flightdeck_sensor/compat/__init__.py +2 -2
  5. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/flightdeck_sensor/compat/crewai_mcp.py +1 -1
  6. flightdeck_sensor-0.8.0/flightdeck_sensor/transport/body_limit.py +154 -0
  7. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/flightdeck_sensor/transport/client.py +15 -4
  8. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/pyproject.toml +1 -1
  9. flightdeck_sensor-0.8.0/tests/unit/test_body_limit.py +206 -0
  10. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_provider_enum.py +2 -2
  11. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_transport.py +21 -0
  12. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/uv.lock +1 -1
  13. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/.gitignore +0 -0
  14. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/Makefile +0 -0
  15. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/flightdeck_sensor/__init__.py +0 -0
  16. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/flightdeck_sensor/config.py +0 -0
  17. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/flightdeck_sensor/core/__init__.py +0 -0
  18. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/flightdeck_sensor/core/agent_id.py +0 -0
  19. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/flightdeck_sensor/core/context.py +0 -0
  20. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/flightdeck_sensor/core/errors.py +0 -0
  21. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/flightdeck_sensor/core/exceptions.py +0 -0
  22. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/flightdeck_sensor/core/mcp_policy.py +0 -0
  23. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/flightdeck_sensor/core/policy.py +0 -0
  24. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/flightdeck_sensor/core/schemas.py +0 -0
  25. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/flightdeck_sensor/core/session.py +0 -0
  26. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/flightdeck_sensor/core/types.py +0 -0
  27. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/flightdeck_sensor/interceptor/__init__.py +0 -0
  28. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/flightdeck_sensor/interceptor/anthropic.py +0 -0
  29. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/flightdeck_sensor/interceptor/base.py +0 -0
  30. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/flightdeck_sensor/interceptor/crewai.py +0 -0
  31. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/flightdeck_sensor/interceptor/langgraph.py +0 -0
  32. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/flightdeck_sensor/interceptor/litellm.py +0 -0
  33. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/flightdeck_sensor/interceptor/mcp.py +0 -0
  34. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/flightdeck_sensor/interceptor/mcp_identity.py +0 -0
  35. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/flightdeck_sensor/interceptor/openai.py +0 -0
  36. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/flightdeck_sensor/interceptor/platform.py +0 -0
  37. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/flightdeck_sensor/provider.py +0 -0
  38. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/flightdeck_sensor/providers/__init__.py +0 -0
  39. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/flightdeck_sensor/providers/anthropic.py +0 -0
  40. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/flightdeck_sensor/providers/litellm.py +0 -0
  41. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/flightdeck_sensor/providers/openai.py +0 -0
  42. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/flightdeck_sensor/providers/protocol.py +0 -0
  43. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/flightdeck_sensor/py.typed +0 -0
  44. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/flightdeck_sensor/transport/__init__.py +0 -0
  45. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/flightdeck_sensor/transport/retry.py +0 -0
  46. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/__init__.py +0 -0
  47. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/conftest.py +0 -0
  48. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/__init__.py +0 -0
  49. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_agent_id.py +0 -0
  50. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_agent_type_validation.py +0 -0
  51. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_anthropic_resource_patch.py +0 -0
  52. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_compat_crewai_mcp.py +0 -0
  53. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_context.py +0 -0
  54. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_cross_agent_messages.py +0 -0
  55. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_custom_directives.py +0 -0
  56. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_egress_url_validation.py +0 -0
  57. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_errors.py +0 -0
  58. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_framework_attribution.py +0 -0
  59. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_interceptor.py +0 -0
  60. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_interceptor_crewai.py +0 -0
  61. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_interceptor_langgraph.py +0 -0
  62. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_interceptor_litellm.py +0 -0
  63. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_llm_event_enrichment.py +0 -0
  64. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_mcp_2x_paths.py +0 -0
  65. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_mcp_availability.py +0 -0
  66. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_mcp_identity.py +0 -0
  67. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_mcp_interceptor.py +0 -0
  68. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_mcp_policy_emission_enriched.py +0 -0
  69. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_mcp_policy_enforcement.py +0 -0
  70. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_mcp_policy_enforcement_all_paths.py +0 -0
  71. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_openai_parse_responses.py +0 -0
  72. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_originating_event_id.py +0 -0
  73. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_patch.py +0 -0
  74. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_policy.py +0 -0
  75. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_policy_decision_summary.py +0 -0
  76. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_policy_directive_events.py +0 -0
  77. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_policy_emission_enriched.py +0 -0
  78. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_policy_events.py +0 -0
  79. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_prompt_capture.py +0 -0
  80. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_providers.py +0 -0
  81. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_session.py +0 -0
  82. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_session_lifecycle_enrichment.py +0 -0
  83. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_stop_reason_errors.py +0 -0
  84. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_streaming_usage.py +0 -0
  85. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_token_cost_fields.py +0 -0
  86. {flightdeck_sensor-0.7.1 → flightdeck_sensor-0.8.0}/tests/unit/test_tool_capture_destination.py +0 -0
@@ -16,7 +16,7 @@
16
16
 
17
17
  29. **Token counting carries over from tokencap.** Do not rewrite the counting logic.
18
18
  Extend it. Pre-call estimation, post-call reconciliation, delta correction -- these
19
- are proven and must not be changed without Supervisor approval.
19
+ are proven and must not be changed without the maintainer's approval.
20
20
 
21
21
  30. **capture_prompts defaults to False.** The default init() call never captures
22
22
  content. Always verify this default has not been accidentally changed.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: flightdeck-sensor
3
- Version: 0.7.1
3
+ Version: 0.8.0
4
4
  Summary: In-process agent observability sensor for Flightdeck
5
5
  Project-URL: Repository, https://github.com/flightdeckhq/flightdeck
6
6
  Author-email: Omri Arnon <omri.arnon@gmail.com>
@@ -222,8 +222,8 @@ the single-count assertion and the capture round-trip).
222
222
  if framework reports surface calls that slip past the current
223
223
  coverage, file a new issue.
224
224
  - **`litellm.text_completion` / `atext_completion` and
225
- `litellm.responses` / `aresponses`.** Neither is wrapped (root
226
- `README.md` Roadmap), but some routes are still reported. The legacy
225
+ `litellm.responses` / `aresponses`.** Neither is wrapped (planned in
226
+ [#170](https://github.com/flightdeckhq/flightdeck/issues/170)), but some routes are still reported. The legacy
227
227
  completion API calls `completion` through the name bound in
228
228
  `litellm.main`, which the module-attribute swap does not reach; on
229
229
  an OpenAI chat model the OpenAI interceptor reports the SDK call it
@@ -162,8 +162,8 @@ the single-count assertion and the capture round-trip).
162
162
  if framework reports surface calls that slip past the current
163
163
  coverage, file a new issue.
164
164
  - **`litellm.text_completion` / `atext_completion` and
165
- `litellm.responses` / `aresponses`.** Neither is wrapped (root
166
- `README.md` Roadmap), but some routes are still reported. The legacy
165
+ `litellm.responses` / `aresponses`.** Neither is wrapped (planned in
166
+ [#170](https://github.com/flightdeckhq/flightdeck/issues/170)), but some routes are still reported. The legacy
167
167
  completion API calls `completion` through the name bound in
168
168
  `litellm.main`, which the module-attribute swap does not reach; on
169
169
  an OpenAI chat model the OpenAI interceptor reports the SDK call it
@@ -11,6 +11,6 @@ Stability: these helpers exist as workarounds for upstream bugs.
11
11
  Each module's docstring names the upstream package + version where
12
12
  the bug was observed and points at the README "Known framework
13
13
  constraints" section. The helpers ship behind their own modules so
14
- removing one as upstream fixes land is a clean delete + Roadmap
15
- checkbox tick, not a sweep through the sensor proper.
14
+ removing one as upstream fixes land is a clean delete that closes
15
+ its roadmap issue, not a sweep through the sensor proper.
16
16
  """
@@ -30,7 +30,7 @@ path, raw ``model_json_schema()`` calls) sees the cleaned schema.
30
30
 
31
31
  Remove this module once mcpadapt emits valid schemas. See README
32
32
  "Known framework constraints" for the operator-facing explanation
33
- and the README Roadmap for the removal checkbox.
33
+ and roadmap issue #193 for the removal.
34
34
  """
35
35
 
36
36
  from __future__ import annotations
@@ -0,0 +1,154 @@
1
+ """Keep every event body the sensor POSTs within the server's size limit.
2
+
3
+ Ingestion reads at most 1 MiB of an event body, and NATS carries the
4
+ event on with the same 1 MiB ``max_payload``; a larger body is rejected
5
+ whole (413 from a proxy with nginx's default limit, 400 from ingestion
6
+ when the cut body fails to parse), and the call's token counts with it.
7
+ A captured prompt, a tool result or a sub-agent message can cross that.
8
+ An event over the limit has the strings of its captured ``content`` cut,
9
+ each to the same largest length that fits, ending in a marker; when no
10
+ cut fits, it is sent without content. The Claude Code plugin does the
11
+ same (``plugin/hooks/scripts/core/poster.mjs``).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+ from typing import Any
19
+
20
+ _log = logging.getLogger("flightdeck_sensor.transport.body_limit")
21
+
22
+ #: Largest event body the sensor sends, as measured by
23
+ #: :func:`encoded_size`; the remaining quarter of the 1 MiB limit covers
24
+ #: the token id and name ingestion adds to a ``session_start``.
25
+ MAX_EVENT_BODY_BYTES = 768 * 1024
26
+
27
+ # Characters ingestion's Go encoder writes as six-byte unicode escapes
28
+ # when it re-encodes a session_start (one byte each here).
29
+ _GO_ESCAPED = (b"<", b">", b"&")
30
+
31
+
32
+ def encoded_size(body: bytes) -> int:
33
+ """The size of a JSON body once ingestion has re-encoded it.
34
+
35
+ The sensor's ``json.dumps`` already escapes every non-ASCII
36
+ character, which Go writes as shorter UTF-8, so text grows only by
37
+ ``<``, ``>`` and ``&``. Go also writes some floats longer than
38
+ Python does (``1e16`` as ``10000000000000000``), which this does not
39
+ count; a ``session_start``, the one event ingestion re-encodes,
40
+ carries text content. Every event is measured this way; for the
41
+ others it only over-counts.
42
+ """
43
+ return len(body) + 5 * sum(body.count(ch) for ch in _GO_ESCAPED)
44
+
45
+
46
+ def truncation_marker(kept_bytes: int, total_bytes: int) -> str:
47
+ """The text that replaces the cut end of a string in oversized content."""
48
+ return f"… [flightdeck: kept {kept_bytes} of {total_bytes} bytes to fit the event size limit]"
49
+
50
+
51
+ def _cap_strings(value: Any, cap_bytes: int) -> Any:
52
+ """Every string in ``value`` longer than ``cap_bytes`` of UTF-8, cut and marked."""
53
+ if isinstance(value, str):
54
+ # ``surrogatepass`` keeps a lone surrogate (from a
55
+ # ``surrogateescape`` decode) from raising; the cut drops it.
56
+ raw = value.encode("utf-8", "surrogatepass")
57
+ if len(raw) <= cap_bytes:
58
+ return value
59
+ # ``errors="ignore"`` drops a code point the cut split.
60
+ kept = raw[:cap_bytes].decode("utf-8", errors="ignore")
61
+ marked = kept + truncation_marker(len(kept.encode("utf-8", "surrogatepass")), len(raw))
62
+ # A string barely over the cap would grow by the marker; keep it.
63
+ return marked if len(marked.encode("utf-8", "surrogatepass")) < len(raw) else value
64
+ if isinstance(value, list):
65
+ return [_cap_strings(item, cap_bytes) for item in value]
66
+ if isinstance(value, dict):
67
+ return {key: _cap_strings(item, cap_bytes) for key, item in value.items()}
68
+ return value
69
+
70
+
71
+ def _longest_string_bytes(value: Any) -> int:
72
+ if isinstance(value, str):
73
+ return len(value.encode("utf-8", "surrogatepass"))
74
+ items = value if isinstance(value, list) else value.values() if isinstance(value, dict) else ()
75
+ return max((_longest_string_bytes(item) for item in items), default=0)
76
+
77
+
78
+ def _serialize_cut(payload: dict[str, Any], content: Any, cap: int) -> bytes:
79
+ return json.dumps({**payload, "content": _cap_strings(content, cap)}).encode()
80
+
81
+
82
+ # Payload fields holding a sub-agent message stub that points at the
83
+ # event's content (``{has_content: true, content_bytes, captured_at}``).
84
+ _MESSAGE_STUB_FIELDS = ("incoming_message", "outgoing_message")
85
+
86
+
87
+ def _without_content(payload: dict[str, Any]) -> dict[str, Any]:
88
+ """``payload`` sent without its content: no stub still claims it."""
89
+ out = {**payload, "has_content": False, "content": None}
90
+ for field in _MESSAGE_STUB_FIELDS:
91
+ stub = out.get(field)
92
+ if isinstance(stub, dict) and stub.get("has_content"):
93
+ out[field] = {**stub, "has_content": False}
94
+ return out
95
+
96
+
97
+ def serialize_event(payload: dict[str, Any]) -> bytes:
98
+ """The JSON body for one event, kept within :data:`MAX_EVENT_BODY_BYTES`.
99
+
100
+ An event without content, or one over the limit even without it, is
101
+ sent as it is.
102
+ """
103
+ body = json.dumps(payload).encode()
104
+ size = encoded_size(body)
105
+ content = payload.get("content")
106
+ if size <= MAX_EVENT_BODY_BYTES or content is None:
107
+ return body
108
+ event_type = payload.get("event_type")
109
+ bare = json.dumps(_without_content(payload)).encode()
110
+ if encoded_size(bare) > MAX_EVENT_BODY_BYTES:
111
+ _log.warning(
112
+ "%s event is %d bytes and over the event size limit without its content; sent as it is",
113
+ event_type,
114
+ size,
115
+ )
116
+ return body
117
+ # The body only grows with the cap, so when even an empty cap does
118
+ # not fit, no cap does; that skips the search (each step serialises
119
+ # the whole event).
120
+ if encoded_size(_serialize_cut(payload, content, 0)) > MAX_EVENT_BODY_BYTES:
121
+ _log.warning(
122
+ "%s event is %d bytes and its content cannot be cut to fit the event size limit; "
123
+ "sent without it",
124
+ event_type,
125
+ size,
126
+ )
127
+ return bare
128
+ lo, hi = 0, min(_longest_string_bytes(content), MAX_EVENT_BODY_BYTES)
129
+ best: bytes | None = None
130
+ best_cap = 0
131
+ # Binary search for the largest per-string length that fits.
132
+ while lo <= hi:
133
+ cap = (lo + hi) // 2
134
+ candidate = _serialize_cut(payload, content, cap)
135
+ if encoded_size(candidate) <= MAX_EVENT_BODY_BYTES:
136
+ best, best_cap, lo = candidate, cap, cap + 1
137
+ else:
138
+ hi = cap - 1
139
+ if best is not None:
140
+ _log.warning(
141
+ "%s event is %d bytes; content strings over %d bytes were cut "
142
+ "to fit the event size limit",
143
+ event_type,
144
+ size,
145
+ best_cap,
146
+ )
147
+ return best
148
+ _log.warning(
149
+ "%s event is %d bytes and its content cannot be cut to fit the event size limit; "
150
+ "sent without it",
151
+ event_type,
152
+ size,
153
+ )
154
+ return bare
@@ -19,6 +19,7 @@ from pydantic import ValidationError
19
19
 
20
20
  from flightdeck_sensor.core.exceptions import DirectiveError
21
21
  from flightdeck_sensor.core.types import Directive, DirectiveAction
22
+ from flightdeck_sensor.transport.body_limit import serialize_event
22
23
  from flightdeck_sensor.transport.retry import with_retry
23
24
 
24
25
  if TYPE_CHECKING:
@@ -78,7 +79,7 @@ class ControlPlaneClient:
78
79
  field defaults to ``False`` for backward compatibility with
79
80
  older ingestion deployments.
80
81
  """
81
- return self._post("/v1/events", payload)
82
+ return self._post("/v1/events", payload, data=serialize_event(payload))
82
83
 
83
84
  def sync_directives(
84
85
  self,
@@ -150,10 +151,20 @@ class ControlPlaneClient:
150
151
  # Internals
151
152
  # ------------------------------------------------------------------
152
153
 
153
- def _post(self, path: str, body: dict[str, Any]) -> tuple[Directive | None, bool]:
154
- """POST JSON and parse the response envelope for directive + attached."""
154
+ def _post(
155
+ self,
156
+ path: str,
157
+ body: dict[str, Any],
158
+ data: bytes | None = None,
159
+ ) -> tuple[Directive | None, bool]:
160
+ """POST JSON and parse the response envelope for directive + attached.
161
+
162
+ ``data`` is the already-encoded body when the caller has one
163
+ (an event, kept within the size limit by ``serialize_event``).
164
+ """
155
165
  url = f"{self._base_url}{path}"
156
- data = json.dumps(body).encode()
166
+ if data is None:
167
+ data = json.dumps(body).encode()
157
168
  req = urllib.request.Request(
158
169
  url,
159
170
  data=data,
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "flightdeck-sensor"
7
- version = "0.7.1"
7
+ version = "0.8.0"
8
8
  description = "In-process agent observability sensor for Flightdeck"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -0,0 +1,206 @@
1
+ """transport/body_limit.py: every event body stays within the server's
2
+ 1 MiB limit, less headroom, by cutting the strings of captured content."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import logging
8
+ import re
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from flightdeck_sensor.transport import body_limit
12
+ from flightdeck_sensor.transport.body_limit import (
13
+ MAX_EVENT_BODY_BYTES,
14
+ _cap_strings,
15
+ encoded_size,
16
+ serialize_event,
17
+ truncation_marker,
18
+ )
19
+
20
+ if TYPE_CHECKING:
21
+ import pytest
22
+
23
+ _MARKER_RE = re.compile(r"… \[flightdeck: kept (\d+) of (\d+) bytes to fit the event size limit\]$")
24
+
25
+
26
+ def _post_call(content: Any) -> dict[str, Any]:
27
+ return {
28
+ "event_type": "post_call",
29
+ "session_id": "s1",
30
+ "tokens_input": 42,
31
+ "has_content": True,
32
+ "content": content,
33
+ }
34
+
35
+
36
+ def test_an_event_within_the_limit_is_sent_unchanged(caplog: pytest.LogCaptureFixture) -> None:
37
+ payload = _post_call({"messages": [{"role": "user", "content": "hi"}]})
38
+ with caplog.at_level(logging.WARNING):
39
+ assert serialize_event(payload) == json.dumps(payload).encode()
40
+ assert caplog.records == []
41
+
42
+
43
+ def test_oversized_content_is_cut_and_the_tokens_kept(caplog: pytest.LogCaptureFixture) -> None:
44
+ big = "a" * (3 * 1024 * 1024)
45
+ payload = _post_call(
46
+ {
47
+ "provider": "anthropic",
48
+ "messages": [{"role": "user", "content": big}],
49
+ "response": {"content": [{"type": "text", "text": "short"}]},
50
+ }
51
+ )
52
+ with caplog.at_level(logging.WARNING):
53
+ body = serialize_event(payload)
54
+ assert encoded_size(body) <= MAX_EVENT_BODY_BYTES
55
+ sent = json.loads(body)
56
+ assert sent["tokens_input"] == 42
57
+ assert sent["has_content"] is True
58
+ assert sent["content"]["provider"] == "anthropic"
59
+ assert sent["content"]["response"] == payload["content"]["response"]
60
+ cut = sent["content"]["messages"][0]["content"]
61
+ match = _MARKER_RE.search(cut)
62
+ assert match, cut[-120:]
63
+ kept, total = int(match.group(1)), int(match.group(2))
64
+ assert total == len(big)
65
+ assert cut == "a" * kept + truncation_marker(kept, total)
66
+ assert kept > MAX_EVENT_BODY_BYTES * 0.9
67
+ assert len(caplog.records) == 1
68
+ assert "content strings over" in caplog.records[0].getMessage()
69
+ assert "aaaa" not in caplog.records[0].getMessage()
70
+
71
+
72
+ def test_every_long_string_gets_the_same_cap() -> None:
73
+ payload = _post_call(
74
+ {"system": "b" * 600_000, "messages": [{"role": "user", "content": "c" * 600_000}]}
75
+ )
76
+ sent = json.loads(serialize_event(payload))
77
+
78
+ def kept(s: str) -> int:
79
+ m = _MARKER_RE.search(s)
80
+ assert m
81
+ return int(m.group(1))
82
+
83
+ assert kept(sent["content"]["system"]) == kept(sent["content"]["messages"][0]["content"])
84
+
85
+
86
+ def test_a_multibyte_character_is_never_split() -> None:
87
+ payload = _post_call({"messages": [{"role": "user", "content": "\U0001f600" * 400_000}]})
88
+ body = serialize_event(payload)
89
+ assert encoded_size(body) <= MAX_EVENT_BODY_BYTES
90
+ text = json.loads(body)["content"]["messages"][0]["content"]
91
+ assert "�" not in text
92
+ assert re.match(r"^(\U0001f600)+… \[flightdeck: kept \d+ of 1600000 bytes", text)
93
+
94
+
95
+ def test_markup_heavy_content_fits_once_go_re_encodes_it() -> None:
96
+ # Ingestion's Go encoder writes <, > and & as six-byte escapes when
97
+ # it re-encodes a session_start.
98
+ payload = {
99
+ "event_type": "session_start",
100
+ "session_id": "s1",
101
+ "has_content": True,
102
+ "content": {"response": {"body": "<a>" * 240_000}},
103
+ }
104
+ body = serialize_event(payload)
105
+ assert encoded_size(body) <= MAX_EVENT_BODY_BYTES
106
+ assert len(body) + 5 * (body.count(b"<") + body.count(b">")) <= MAX_EVENT_BODY_BYTES
107
+
108
+
109
+ def test_encoded_size_counts_go_escapes() -> None:
110
+ assert encoded_size(b'{"a":"x"}') == 9
111
+ assert encoded_size(b'{"a":"<&>"}') == 11 + 3 * 5
112
+
113
+
114
+ def test_without_any_fitting_cut_the_event_goes_without_content(
115
+ caplog: pytest.LogCaptureFixture,
116
+ ) -> None:
117
+ blocks = [{"type": "text", "text": ""} for _ in range(60_000)]
118
+ payload = _post_call({"messages": [], "response": {"content": blocks}})
119
+ with caplog.at_level(logging.WARNING):
120
+ sent = json.loads(serialize_event(payload))
121
+ assert sent["content"] is None
122
+ assert sent["has_content"] is False
123
+ assert sent["tokens_input"] == 42
124
+ assert "cannot be cut" in caplog.records[0].getMessage()
125
+
126
+
127
+ def test_an_event_over_the_limit_without_content_keeps_it(caplog: pytest.LogCaptureFixture) -> None:
128
+ payload = {
129
+ **_post_call({"messages": [{"role": "user", "content": "hi"}]}),
130
+ "note": "q" * MAX_EVENT_BODY_BYTES,
131
+ }
132
+ with caplog.at_level(logging.WARNING):
133
+ assert serialize_event(payload) == json.dumps(payload).encode()
134
+ assert "without its content" in caplog.records[0].getMessage()
135
+
136
+
137
+ def test_an_oversized_event_with_no_content_is_sent_as_it_is() -> None:
138
+ payload = {"event_type": "tool_call", "content": None, "note": "q" * MAX_EVENT_BODY_BYTES}
139
+ assert serialize_event(payload) == json.dumps(payload).encode()
140
+
141
+
142
+ def test_a_string_the_marker_would_lengthen_is_kept() -> None:
143
+ short = "s" * 70
144
+ assert _cap_strings(short, 10) == short
145
+ long = "l" * 200
146
+ assert _cap_strings(long, 10) == "l" * 10 + truncation_marker(10, 200)
147
+ assert _cap_strings({"a": [short, long], "n": 3, "b": None}, 10) == {
148
+ "a": [short, "l" * 10 + truncation_marker(10, 200)],
149
+ "n": 3,
150
+ "b": None,
151
+ }
152
+
153
+
154
+ def test_a_cut_never_splits_a_multibyte_character() -> None:
155
+ # Each emoji is four bytes; a six-byte cap lands inside the second.
156
+ cut = _cap_strings("\U0001f600" * 40, 6)
157
+ assert "\ufffd" not in cut
158
+ assert cut == "\U0001f600" + truncation_marker(4, 160)
159
+
160
+
161
+ def test_a_lone_surrogate_in_oversized_content_is_cut_not_raised() -> None:
162
+ # A tool result decoded with errors="surrogateescape" carries \udcXX.
163
+ payload = {
164
+ "event_type": "session_start",
165
+ "has_content": True,
166
+ "content": {"response": {"body": "\udc80" * 300_000}},
167
+ }
168
+ body = serialize_event(payload)
169
+ assert encoded_size(body) <= MAX_EVENT_BODY_BYTES
170
+ assert json.loads(body)["has_content"] is True
171
+
172
+
173
+ def test_dropping_the_content_clears_a_message_stub_that_points_at_it() -> None:
174
+ payload = {
175
+ "event_type": "session_start",
176
+ "has_content": True,
177
+ "incoming_message": {"has_content": True, "content_bytes": 3_000_000, "captured_at": "t"},
178
+ "content": {"response": {"body": [{"k": i} for i in range(200_000)]}},
179
+ }
180
+ sent = json.loads(serialize_event(payload))
181
+ assert sent["content"] is None
182
+ assert sent["has_content"] is False
183
+ assert sent["incoming_message"] == {
184
+ "has_content": False,
185
+ "content_bytes": 3_000_000,
186
+ "captured_at": "t",
187
+ }
188
+
189
+
190
+ def test_content_with_nothing_to_cut_skips_the_search(monkeypatch: pytest.MonkeyPatch) -> None:
191
+ calls = []
192
+ real = body_limit._serialize_cut
193
+
194
+ def counting(payload: dict[str, Any], content: Any, cap: int) -> bytes:
195
+ calls.append(cap)
196
+ return real(payload, content, cap)
197
+
198
+ monkeypatch.setattr(body_limit, "_serialize_cut", counting)
199
+ vectors = [[0.123456789] * 3072 for _ in range(50)]
200
+ sent = json.loads(serialize_event(_post_call({"embedding_output": vectors})))
201
+ assert sent["content"] is None
202
+ assert calls == [0]
203
+
204
+
205
+ def test_the_budget_leaves_room_under_the_one_mib_limit() -> None:
206
+ assert MAX_EVENT_BODY_BYTES <= 0.8 * 1024 * 1024
@@ -66,8 +66,8 @@ def test_provider_has_exactly_six_members() -> None:
66
66
  value-side drift; this one catches the count-side).
67
67
 
68
68
  Sub-agent observability added two targets (CREWAI, LANGGRAPH). AutoGen
69
- support is on the Roadmap and ships in a future PR alongside
70
- LLM-call interception for it.
69
+ is archived upstream and not planned; its successor, Microsoft Agent
70
+ Framework, is roadmap issue #185.
71
71
  """
72
72
  assert set(Provider) == {
73
73
  Provider.ANTHROPIC,
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  import logging
6
7
  import threading
7
8
  import time
@@ -12,6 +13,7 @@ import pytest
12
13
 
13
14
  from flightdeck_sensor.core.exceptions import DirectiveError
14
15
  from flightdeck_sensor.core.types import Directive, DirectiveAction
16
+ from flightdeck_sensor.transport.body_limit import MAX_EVENT_BODY_BYTES
15
17
  from flightdeck_sensor.transport.client import (
16
18
  _MAX_QUEUE_SIZE,
17
19
  ControlPlaneClient,
@@ -64,6 +66,25 @@ def test_post_event_returns_attached_true_when_backend_attaches(
64
66
  assert attached is True
65
67
 
66
68
 
69
+ def test_post_event_keeps_an_oversized_event_within_the_body_limit(
70
+ mock_control_plane: Any,
71
+ ) -> None:
72
+ client = ControlPlaneClient(mock_control_plane["url"], "tok")
73
+ payload = {
74
+ "session_id": "123",
75
+ "event_type": "post_call",
76
+ "tokens_input": 900_000,
77
+ "has_content": True,
78
+ "content": {"messages": [{"role": "user", "content": "x" * (2 * 1024 * 1024)}]},
79
+ }
80
+ client.post_event(payload)
81
+ [request] = mock_control_plane["requests"]
82
+ sent = request["body"]
83
+ assert sent["tokens_input"] == 900_000
84
+ assert len(json.dumps(sent)) <= MAX_EVENT_BODY_BYTES
85
+ assert sent["content"]["messages"][0]["content"].endswith("bytes to fit the event size limit]")
86
+
87
+
67
88
  def test_connectivity_failure_continue_returns_none() -> None:
68
89
  client = ControlPlaneClient("http://127.0.0.1:1", "tok", unavailable_policy="continue")
69
90
  directive, attached = client.post_event({"session_id": "x", "event_type": "post_call"})
@@ -1263,7 +1263,7 @@ wheels = [
1263
1263
 
1264
1264
  [[package]]
1265
1265
  name = "flightdeck-sensor"
1266
- version = "0.7.1"
1266
+ version = "0.8.0"
1267
1267
  source = { editable = "." }
1268
1268
  dependencies = [
1269
1269
  { name = "pydantic", version = "2.12.5", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.14' and extra == 'extra-17-flightdeck-sensor-dev') or (extra == 'extra-17-flightdeck-sensor-dev' and extra == 'group-17-flightdeck-sensor-sdk-majors')" },