Skip to content

Commit 6496502

Browse files
Add configurable event sinks
1 parent efd0271 commit 6496502

7 files changed

Lines changed: 480 additions & 28 deletions

File tree

‎sdks/python/src/agent_control/__init__.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,13 @@ async def handle_input(user_message: str) -> str:
9595
sync_shutdown_observability,
9696
)
9797
from .telemetry import (
98+
ControlEventSink,
99+
clear_control_event_sink,
98100
clear_trace_context_provider,
101+
emit_control_events,
99102
get_trace_context_from_provider,
103+
has_control_event_sink,
104+
set_control_event_sink,
100105
set_trace_context_provider,
101106
)
102107
from .tracing import (
@@ -477,6 +482,7 @@ async def handle(message: str):
477482

478483
# Re-init behavior: always stop existing loop before mutating shared agent/session globals.
479484
_stop_policy_refresh_loop()
485+
clear_control_event_sink()
480486

481487
# Configure logging if provided (do this early before any logging happens)
482488
if log_config:
@@ -641,6 +647,8 @@ def _reset_state() -> None:
641647
"""Clear all global SDK state."""
642648
global _session_generation
643649

650+
clear_control_event_sink()
651+
644652
with _session_lock:
645653
_session_generation += 1
646654
state.current_agent = None
@@ -1313,6 +1321,11 @@ async def main():
13131321
"set_trace_context_provider",
13141322
"get_trace_context_from_provider",
13151323
"clear_trace_context_provider",
1324+
"ControlEventSink",
1325+
"set_control_event_sink",
1326+
"has_control_event_sink",
1327+
"emit_control_events",
1328+
"clear_control_event_sink",
13161329
# Observability
13171330
"init_observability",
13181331
"add_event",

‎sdks/python/src/agent_control/evaluation.py‎

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from .client import AgentControlClient
2020
from .evaluation_events import build_control_execution_events, enqueue_observability_events
2121
from .observability import is_observability_enabled
22+
from .telemetry.event_sink import emit_control_events
2223
from .tracing import get_trace_and_span_ids
2324
from .validation import ensure_agent_name
2425

@@ -116,6 +117,19 @@ def _has_applicable_prefiltered_server_controls(
116117
)
117118

118119

120+
def _has_merged_event_delivery() -> bool:
121+
"""Return whether merged event delivery has an active downstream consumer.
122+
123+
Args:
124+
None.
125+
126+
Returns:
127+
``True`` when observability is enabled, which is the prerequisite for
128+
merged event creation and final delivery.
129+
"""
130+
return is_observability_enabled()
131+
132+
119133
def _is_merged_event_mode_enabled(
120134
agent_name: str,
121135
client: AgentControlClient | None = None,
@@ -124,8 +138,8 @@ def _is_merged_event_mode_enabled(
124138
125139
Merged event creation is a session-scoped option that depends on
126140
initialized SDK state: an initialized agent, cached server controls for the
127-
same agent, and observability enabled so the merged batch will actually be
128-
consumed by the built-in queue/Postgres path.
141+
same agent, observability enabled, and a valid initialized session so the
142+
merged batch can be delivered safely through the configured ingestion path.
129143
130144
Args:
131145
agent_name: Normalized agent name for the current request.
@@ -135,9 +149,9 @@ def _is_merged_event_mode_enabled(
135149
136150
Returns:
137151
``True`` when the current SDK session has enough state to reconstruct
138-
and enqueue merged events safely.
152+
and deliver merged events safely.
139153
"""
140-
if not state.merge_events or not is_observability_enabled():
154+
if not state.merge_events or not _has_merged_event_delivery():
141155
return False
142156

143157
current_agent = state.current_agent
@@ -216,8 +230,10 @@ async def check_evaluation(
216230
also participate in merged event creation when the initialized SDK session
217231
has ``merge_events`` enabled. In that mode, the SDK asks the server to skip
218232
final event ingestion, reconstructs server events from the lightweight
219-
response, and enqueues them through the existing observability pipeline
220-
before returning the parsed result.
233+
response, and delivers them before returning the parsed result. Final
234+
delivery uses the configured event ingestion path: the existing built-in
235+
queue by default or a registered custom sink. Event creation and delivery
236+
are both gated on observability being enabled.
221237
222238
Args:
223239
client: Configured AgentControl client.
@@ -268,7 +284,7 @@ async def check_evaluation(
268284
span_id,
269285
normalized_name,
270286
)
271-
enqueue_observability_events(server_events)
287+
emit_control_events(server_events)
272288

273289
return cast(EvaluationResult, EvaluationResult.from_dict(evaluation_response.model_dump()))
274290

@@ -290,10 +306,11 @@ async def check_evaluation_with_local(
290306
- default behavior: local events are reconstructed and queued immediately in
291307
the SDK, while server-side events are still emitted by the server
292308
- merged-event behavior: local and server events are reconstructed in the
293-
SDK and enqueued once through the built-in observability path
309+
SDK and delivered once through the configured event ingestion path
294310
295311
In both cases, the evaluation result itself stays lightweight and event
296-
reconstruction happens after evaluation completes.
312+
reconstruction happens after evaluation completes. Event creation and
313+
delivery are both gated on observability being enabled.
297314
298315
Args:
299316
client: Configured AgentControl client.
@@ -420,7 +437,7 @@ def _with_parse_errors(result: EvaluationResult) -> EvaluationResult:
420437
if not local_result.is_safe:
421438
result = _with_parse_errors(EvaluationResult.model_validate(local_result.model_dump()))
422439
if merged_emission_enabled:
423-
enqueue_observability_events(local_events)
440+
emit_control_events(local_events)
424441
return result
425442

426443
if _has_applicable_prefiltered_server_controls(server_control_payloads, request):
@@ -455,18 +472,18 @@ def _with_parse_errors(result: EvaluationResult) -> EvaluationResult:
455472
if local_result is not None:
456473
result = _with_parse_errors(_merge_results(local_result, server_result))
457474
if merged_emission_enabled:
458-
enqueue_observability_events(local_events + server_events)
475+
emit_control_events(local_events + server_events)
459476
return result
460477

461478
result = _with_parse_errors(EvaluationResult.model_validate(server_result.model_dump()))
462479
if merged_emission_enabled:
463-
enqueue_observability_events(server_events)
480+
emit_control_events(server_events)
464481
return result
465482

466483
if local_result is not None:
467484
result = _with_parse_errors(EvaluationResult.model_validate(local_result.model_dump()))
468485
if merged_emission_enabled:
469-
enqueue_observability_events(local_events)
486+
emit_control_events(local_events)
470487
return result
471488

472489
return _with_parse_errors(EvaluationResult(is_safe=True, confidence=1.0))

‎sdks/python/src/agent_control/telemetry/__init__.py‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
"""Telemetry interfaces for provider-agnostic tracing."""
1+
"""Telemetry interfaces for provider-agnostic tracing and event delivery."""
2+
from .event_sink import (
3+
ControlEventSink,
4+
clear_control_event_sink,
5+
emit_control_events,
6+
has_control_event_sink,
7+
set_control_event_sink,
8+
)
29
from .trace_context import (
310
TraceContext,
411
TraceContextProvider,
@@ -8,9 +15,14 @@
815
)
916

1017
__all__ = [
18+
"ControlEventSink",
1119
"TraceContext",
1220
"TraceContextProvider",
21+
"clear_control_event_sink",
1322
"clear_trace_context_provider",
23+
"emit_control_events",
1424
"get_trace_context_from_provider",
25+
"has_control_event_sink",
26+
"set_control_event_sink",
1527
"set_trace_context_provider",
1628
]
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""Control event delivery interfaces for SDK observability."""
2+
3+
from collections.abc import Callable
4+
5+
from agent_control_models import ControlExecutionEvent
6+
7+
from ..evaluation_events import enqueue_observability_events
8+
from ..observability import get_logger
9+
10+
ControlEventSink = Callable[[list[ControlExecutionEvent]], None]
11+
12+
_control_event_sink: ControlEventSink | None = None
13+
_logger = get_logger(__name__)
14+
15+
16+
def set_control_event_sink(sink: ControlEventSink | None) -> None:
17+
"""Register a custom sink for control event delivery.
18+
19+
Args:
20+
sink: Callable that consumes a batch of ``ControlExecutionEvent``
21+
instances. Pass ``None`` to clear any existing sink.
22+
23+
Returns:
24+
None.
25+
"""
26+
global _control_event_sink
27+
_control_event_sink = sink
28+
29+
30+
def clear_control_event_sink() -> None:
31+
"""Clear the currently registered control event sink.
32+
33+
Args:
34+
None.
35+
36+
Returns:
37+
None.
38+
"""
39+
set_control_event_sink(None)
40+
41+
42+
def has_control_event_sink() -> bool:
43+
"""Return whether a custom control event sink is registered.
44+
45+
Args:
46+
None.
47+
48+
Returns:
49+
``True`` when a custom sink is registered, otherwise ``False``.
50+
"""
51+
return _control_event_sink is not None
52+
53+
54+
def emit_control_events(events: list[ControlExecutionEvent]) -> None:
55+
"""Deliver control events through the configured ingestion path.
56+
57+
By default, events continue through the existing SDK observability queue.
58+
When a custom sink is registered, the same created events are delivered to
59+
that sink instead. Event creation mode remains a separate concern.
60+
61+
Args:
62+
events: Control execution events that have already been created.
63+
64+
Returns:
65+
None.
66+
"""
67+
if not events:
68+
return
69+
70+
if _control_event_sink is not None:
71+
try:
72+
_control_event_sink(events)
73+
except Exception:
74+
_logger.exception("Custom control event sink failed while delivering events")
75+
return
76+
77+
enqueue_observability_events(events)
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""Tests for configurable control event sinks."""
2+
3+
from unittest.mock import MagicMock, patch
4+
5+
from agent_control.telemetry.event_sink import (
6+
clear_control_event_sink,
7+
emit_control_events,
8+
has_control_event_sink,
9+
set_control_event_sink,
10+
)
11+
from agent_control_models import ControlExecutionEvent
12+
13+
14+
def _make_event() -> ControlExecutionEvent:
15+
return ControlExecutionEvent(
16+
trace_id="a" * 32,
17+
span_id="b" * 16,
18+
agent_name="agent-000000000001",
19+
control_id=1,
20+
control_name="ctrl-1",
21+
check_stage="pre",
22+
applies_to="llm_call",
23+
action="allow",
24+
matched=True,
25+
confidence=1.0,
26+
)
27+
28+
29+
def teardown_function() -> None:
30+
clear_control_event_sink()
31+
32+
33+
def test_emit_control_events_uses_default_enqueue_path_without_custom_sink() -> None:
34+
events = [_make_event()]
35+
36+
with patch("agent_control.telemetry.event_sink.enqueue_observability_events") as mock_enqueue:
37+
emit_control_events(events)
38+
39+
mock_enqueue.assert_called_once_with(events)
40+
41+
42+
def test_emit_control_events_uses_registered_custom_sink() -> None:
43+
events = [_make_event()]
44+
sink = MagicMock()
45+
set_control_event_sink(sink)
46+
47+
with patch("agent_control.telemetry.event_sink.enqueue_observability_events") as mock_enqueue:
48+
emit_control_events(events)
49+
50+
sink.assert_called_once_with(events)
51+
mock_enqueue.assert_not_called()
52+
53+
54+
def test_emit_control_events_swallows_custom_sink_errors() -> None:
55+
events = [_make_event()]
56+
sink = MagicMock(side_effect=RuntimeError("boom"))
57+
set_control_event_sink(sink)
58+
59+
with patch("agent_control.telemetry.event_sink._logger") as mock_logger, \
60+
patch("agent_control.telemetry.event_sink.enqueue_observability_events") as mock_enqueue:
61+
emit_control_events(events)
62+
63+
sink.assert_called_once_with(events)
64+
mock_logger.exception.assert_called_once()
65+
mock_enqueue.assert_not_called()
66+
67+
68+
def test_emit_control_events_noops_for_empty_event_batch() -> None:
69+
sink = MagicMock()
70+
set_control_event_sink(sink)
71+
72+
with patch("agent_control.telemetry.event_sink.enqueue_observability_events") as mock_enqueue:
73+
emit_control_events([])
74+
75+
sink.assert_not_called()
76+
mock_enqueue.assert_not_called()
77+
78+
79+
def test_has_control_event_sink_tracks_registration() -> None:
80+
assert has_control_event_sink() is False
81+
82+
set_control_event_sink(lambda events: None)
83+
assert has_control_event_sink() is True
84+
85+
clear_control_event_sink()
86+
assert has_control_event_sink() is False

0 commit comments

Comments
 (0)