Skip to content

Commit ff1e344

Browse files
update docstring
1 parent d532300 commit ff1e344

5 files changed

Lines changed: 190 additions & 28 deletions

File tree

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

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
from ._state import state
1919
from .client import AgentControlClient
20-
from .evaluation_events import build_control_execution_events, deliver_oss_events
20+
from .evaluation_events import build_control_execution_events, enqueue_observability_events
2121
from .telemetry import emit_control_events, has_control_event_sink
2222
from .tracing import get_trace_and_span_ids
2323
from .validation import ensure_agent_name
@@ -49,7 +49,20 @@ def _get_applicable_controls(
4949
def _build_server_control_lookup(
5050
server_control_payloads: list[dict[str, Any]],
5151
) -> dict[int, ControlDefinition]:
52-
"""Return best-effort parsed server control definitions keyed by control ID."""
52+
"""Build a best-effort lookup of server control definitions.
53+
54+
The merged-event path reconstructs server-side events in the SDK after the
55+
server returns a lightweight ``EvaluationResponse``. This helper parses the
56+
cached server control payloads so the shared event builder can reconstruct
57+
those events locally.
58+
59+
Args:
60+
server_control_payloads: Raw cached server control payloads.
61+
62+
Returns:
63+
A mapping of control ID to parsed ``ControlDefinition`` for every
64+
payload that can be parsed locally.
65+
"""
5366
control_lookup: dict[int, ControlDefinition] = {}
5467

5568
for control in server_control_payloads:
@@ -107,7 +120,20 @@ def _merge_results(
107120
local_result: EvaluationResponse,
108121
server_result: EvaluationResponse,
109122
) -> EvaluationResult:
110-
"""Merge local and server evaluation results."""
123+
"""Merge local and server evaluation results into one SDK-facing result.
124+
125+
This helper merges only evaluation semantics. Event reconstruction happens
126+
later so the response shape can stay lightweight regardless of which event
127+
ingestion path is used.
128+
129+
Args:
130+
local_result: Evaluation response produced by SDK-local controls.
131+
server_result: Evaluation response produced by server-side controls.
132+
133+
Returns:
134+
A merged ``EvaluationResult`` with combined matches, errors,
135+
non-matches, and the strictest safety/confidence outcome.
136+
"""
111137
is_safe = local_result.is_safe and server_result.is_safe
112138
confidence = min(local_result.confidence, server_result.confidence)
113139

@@ -173,7 +199,33 @@ async def check_evaluation_with_local(
173199
span_id: str | None = None,
174200
event_agent_name: str | None = None,
175201
) -> EvaluationResult:
176-
"""Check if agent interaction is safe, running local controls first."""
202+
"""Evaluate controls with local-first execution and configurable event flow.
203+
204+
This is the main decision boundary between the two supported event
205+
ingestion styles:
206+
- default behavior: local events are reconstructed and queued immediately in
207+
the SDK, while server-side events are still emitted by the server
208+
- merged-event behavior: local and server events are reconstructed in the
209+
SDK and emitted once through a registered sink
210+
211+
In both cases, the evaluation result itself stays lightweight and event
212+
reconstruction happens after evaluation completes.
213+
214+
Args:
215+
client: Configured AgentControl client.
216+
agent_name: Agent name to evaluate against.
217+
step: Step payload to evaluate.
218+
stage: Evaluation stage, ``pre`` or ``post``.
219+
controls: Cached control payloads used to split local vs server
220+
execution.
221+
trace_id: Optional explicit trace ID.
222+
span_id: Optional explicit span ID.
223+
event_agent_name: Optional override for the agent name stamped on
224+
reconstructed events.
225+
226+
Returns:
227+
A merged evaluation result across local and server execution.
228+
"""
177229
normalized_name = ensure_agent_name(agent_name)
178230
resolved_trace_id = trace_id
179231
resolved_span_id = span_id
@@ -264,7 +316,9 @@ def _with_parse_errors(result: EvaluationResult) -> EvaluationResult:
264316
if applicable_local_controls:
265317
engine = ControlEngine(applicable_local_controls, context="sdk")
266318
local_result = await engine.process(request)
267-
local_control_lookup = {control.id: control.control for control in applicable_local_controls}
319+
local_control_lookup = {
320+
control.id: control.control for control in applicable_local_controls
321+
}
268322
local_events = build_control_execution_events(
269323
local_result,
270324
request,
@@ -275,7 +329,7 @@ def _with_parse_errors(result: EvaluationResult) -> EvaluationResult:
275329
)
276330

277331
if not merged_emission_enabled:
278-
deliver_oss_events(local_events)
332+
enqueue_observability_events(local_events)
279333

280334
if not local_result.is_safe:
281335
result = _with_parse_errors(EvaluationResult.model_validate(local_result.model_dump()))

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

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,28 @@ def build_control_execution_events(
125125
span_id: str | None,
126126
agent_name: str | None,
127127
) -> list[ControlExecutionEvent]:
128-
"""Construct final ControlExecutionEvents from an evaluation response."""
128+
"""Reconstruct control execution events from an evaluation response.
129+
130+
This is the shared reconstruction step used by both supported ingestion
131+
styles:
132+
- the default SDK observability path, where reconstructed local events are
133+
queued into the existing SDK batcher
134+
- the merged-event path, where local and server events are reconstructed in
135+
the SDK and emitted together through a registered sink
136+
137+
Args:
138+
response: Evaluation response containing matches, errors, and
139+
non-matches.
140+
request: Original evaluation request used to derive stage and
141+
``applies_to``.
142+
control_lookup: Parsed controls keyed by control ID.
143+
trace_id: Optional trace ID for correlation.
144+
span_id: Optional span ID for correlation.
145+
agent_name: Optional override for the agent name stamped on events.
146+
147+
Returns:
148+
A list of reconstructed ``ControlExecutionEvent`` objects.
149+
"""
129150
resolved_trace_id, resolved_span_id = _resolve_event_trace_context(trace_id, span_id)
130151
resolved_agent_name = agent_name or request.agent_name
131152
now = datetime.now(UTC)
@@ -170,8 +191,18 @@ def build_control_execution_events(
170191
return events
171192

172193

173-
def deliver_oss_events(events: list[ControlExecutionEvent]) -> None:
174-
"""Send reconstructed events through the existing OSS SDK observability path."""
194+
def enqueue_observability_events(events: list[ControlExecutionEvent]) -> None:
195+
"""Enqueue reconstructed events through the existing SDK observability path.
196+
197+
This preserves the default SDK behavior of forwarding local events through
198+
the existing observability batcher rather than a custom merged-event sink.
199+
200+
Args:
201+
events: Reconstructed control execution events to enqueue.
202+
203+
Returns:
204+
None.
205+
"""
175206
if not is_observability_enabled():
176207
return
177208

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

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,33 @@
1010

1111

1212
def set_control_event_sink(sink: ControlEventSink | None) -> None:
13-
"""Register a sink for merged control execution events."""
13+
"""Register a sink for merged control execution events.
14+
15+
Registering a sink enables the optional merged-event path, where the SDK
16+
reconstructs local and server events and emits them together after merging
17+
results.
18+
19+
Args:
20+
sink: Sink callback to receive merged control execution events, or
21+
``None`` to clear the current sink.
22+
23+
Returns:
24+
None.
25+
"""
1426
global _control_event_sink
1527
_control_event_sink = sink
1628

1729

1830
def emit_control_events(events: list[ControlExecutionEvent]) -> None:
19-
"""Emit merged control execution events to the registered sink."""
31+
"""Emit merged control execution events to the registered sink.
32+
33+
Args:
34+
events: Merged control execution events to emit.
35+
36+
Returns:
37+
None. Sink failures are swallowed so evaluation behavior is not changed
38+
by telemetry issues.
39+
"""
2040
if not events or _control_event_sink is None:
2141
return
2242

@@ -28,7 +48,14 @@ def emit_control_events(events: list[ControlExecutionEvent]) -> None:
2848

2949

3050
def has_control_event_sink() -> bool:
31-
"""Return whether a merged control event sink is currently registered."""
51+
"""Return whether the optional merged-event path is enabled.
52+
53+
Args:
54+
None.
55+
56+
Returns:
57+
``True`` when a merged control event sink has been registered.
58+
"""
3259
return _control_event_sink is not None
3360

3461

‎sdks/python/tests/test_observability_updates.py‎

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from agent_control.evaluation import _ControlAdapter, _merge_results
88
from agent_control.evaluation_events import (
99
build_control_execution_events,
10-
deliver_oss_events,
10+
enqueue_observability_events,
1111
map_applies_to,
1212
)
1313
from agent_control.telemetry.trace_context import (
@@ -183,7 +183,7 @@ def test_composite_control_uses_representative_observability_identity(self):
183183
assert event.metadata["all_evaluators"] == ["regex"]
184184
assert event.metadata["all_selector_paths"] == ["input", "output"]
185185

186-
def test_deliver_oss_events_uses_existing_batcher(self):
186+
def test_enqueue_observability_events_uses_existing_batcher(self):
187187
from agent_control_models import ControlExecutionEvent
188188

189189
events = [
@@ -203,7 +203,7 @@ def test_deliver_oss_events_uses_existing_batcher(self):
203203

204204
with patch("agent_control.evaluation_events.is_observability_enabled", return_value=True), \
205205
patch("agent_control.evaluation_events.add_event") as mock_add:
206-
deliver_oss_events(events)
206+
enqueue_observability_events(events)
207207

208208
mock_add.assert_called_once_with(events[0])
209209

@@ -253,7 +253,7 @@ async def test_delivers_local_events_in_oss_mode(self):
253253

254254
with patch("agent_control.evaluation.ControlEngine", return_value=mock_engine), \
255255
patch("agent_control.evaluation.list_evaluators", return_value=["regex"]), \
256-
patch("agent_control.evaluation.deliver_oss_events") as mock_deliver:
256+
patch("agent_control.evaluation.enqueue_observability_events") as mock_enqueue:
257257
result = await evaluation.check_evaluation_with_local(
258258
client=client,
259259
agent_name="agent-000000000001",
@@ -265,8 +265,8 @@ async def test_delivers_local_events_in_oss_mode(self):
265265
event_agent_name="test-agent",
266266
)
267267

268-
mock_deliver.assert_called_once()
269-
delivered_events = mock_deliver.call_args.args[0]
268+
mock_enqueue.assert_called_once()
269+
delivered_events = mock_enqueue.call_args.args[0]
270270
assert len(delivered_events) == 1
271271
assert delivered_events[0].trace_id == "abc123"
272272
assert delivered_events[0].span_id == "def456"
@@ -275,9 +275,20 @@ async def test_delivers_local_events_in_oss_mode(self):
275275

276276
@pytest.mark.asyncio
277277
async def test_resolves_provider_trace_context_for_local_events(self):
278-
from agent_control_models import EvaluationResponse, Step
278+
from agent_control_models import ControlMatch, EvaluationResponse, EvaluatorResult, Step
279279

280-
mock_response = EvaluationResponse(is_safe=True, confidence=1.0)
280+
mock_response = EvaluationResponse(
281+
is_safe=True,
282+
confidence=1.0,
283+
non_matches=[
284+
ControlMatch(
285+
control_id=1,
286+
control_name="test-ctrl",
287+
action="allow",
288+
result=EvaluatorResult(matched=False, confidence=0.1),
289+
)
290+
],
291+
)
281292
mock_engine = MagicMock()
282293
mock_engine.process = AsyncMock(return_value=mock_response)
283294
controls = [{
@@ -300,7 +311,7 @@ async def test_resolves_provider_trace_context_for_local_events(self):
300311

301312
with patch("agent_control.evaluation.ControlEngine", return_value=mock_engine), \
302313
patch("agent_control.evaluation.list_evaluators", return_value=["regex"]), \
303-
patch("agent_control.evaluation.deliver_oss_events") as mock_deliver:
314+
patch("agent_control.evaluation.enqueue_observability_events") as mock_enqueue:
304315
await evaluation.check_evaluation_with_local(
305316
client=client,
306317
agent_name="agent-000000000001",
@@ -309,7 +320,7 @@ async def test_resolves_provider_trace_context_for_local_events(self):
309320
controls=controls,
310321
)
311322

312-
delivered_events = mock_deliver.call_args.args[0]
323+
delivered_events = mock_enqueue.call_args.args[0]
313324
assert delivered_events[0].trace_id == "a" * 32
314325
assert delivered_events[0].span_id == "b" * 16
315326

@@ -500,7 +511,7 @@ async def test_merged_event_sink_emits_reconstructed_local_and_server_events_onc
500511
patch("agent_control.evaluation.list_evaluators", return_value=["regex"]), \
501512
patch("agent_control.evaluation.has_control_event_sink", return_value=True), \
502513
patch("agent_control.evaluation.emit_control_events") as mock_emit, \
503-
patch("agent_control.evaluation.deliver_oss_events") as mock_deliver:
514+
patch("agent_control.evaluation.enqueue_observability_events") as mock_enqueue:
504515
result = await evaluation.check_evaluation_with_local(
505516
client=client,
506517
agent_name="agent-000000000001",
@@ -512,7 +523,7 @@ async def test_merged_event_sink_emits_reconstructed_local_and_server_events_onc
512523
event_agent_name="test-agent",
513524
)
514525

515-
mock_deliver.assert_not_called()
526+
mock_enqueue.assert_not_called()
516527
mock_emit.assert_called_once()
517528
merged_events = mock_emit.call_args.args[0]
518529
assert len(merged_events) == 2

‎server/src/agent_control_server/endpoints/evaluation.py‎

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -274,10 +274,24 @@ def _build_observability_events(
274274
control_lookup: dict,
275275
total_duration_ms: float,
276276
) -> list[ControlExecutionEvent]:
277-
"""Create observability events for all evaluated controls.
278-
279-
Uses control_execution_id from the engine response to ensure correlation
280-
between SDK logs and server observability events.
277+
"""Build observability events for all evaluated controls.
278+
279+
This preserves the existing server-side event shape while allowing the
280+
merged-event path to skip server-side ingestion and keep the response
281+
lightweight.
282+
283+
Args:
284+
response: Raw evaluation response from the engine.
285+
request: Original evaluation request.
286+
trace_id: Trace ID to stamp on emitted events.
287+
span_id: Span ID to stamp on emitted events.
288+
agent_name: Agent name to stamp on emitted events.
289+
applies_to: Observability applies_to value derived from the step type.
290+
control_lookup: Controls keyed by control ID.
291+
total_duration_ms: Total request execution duration in milliseconds.
292+
293+
Returns:
294+
A list of reconstructed server-side control execution events.
281295
"""
282296
events: list[ControlExecutionEvent] = []
283297
now = datetime.now(UTC)
@@ -398,3 +412,28 @@ async def _ingest_observability_events(
398412
f"Dropped {result.dropped} observability events, "
399413
f"processed {result.processed}"
400414
)
415+
416+
417+
async def _emit_observability_events(
418+
response: EvaluationResponse,
419+
request: EvaluationRequest,
420+
trace_id: str,
421+
span_id: str,
422+
agent_name: str,
423+
applies_to: Literal["llm_call", "tool_call"],
424+
control_lookup: dict,
425+
total_duration_ms: float,
426+
ingestor: EventIngestor | None,
427+
) -> None:
428+
"""Backward-compatible wrapper around build + ingest observability helpers."""
429+
events = _build_observability_events(
430+
response=response,
431+
request=request,
432+
trace_id=trace_id,
433+
span_id=span_id,
434+
agent_name=agent_name,
435+
applies_to=applies_to,
436+
control_lookup=control_lookup,
437+
total_duration_ms=total_duration_ms,
438+
)
439+
await _ingest_observability_events(events, ingestor)

0 commit comments

Comments
 (0)