Skip to content

Commit f58778e

Browse files
address comments
1 parent a8c40c5 commit f58778e

3 files changed

Lines changed: 208 additions & 59 deletions

File tree

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

Lines changed: 76 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from ._state import state
1919
from .client import AgentControlClient
2020
from .evaluation_events import build_control_execution_events, enqueue_observability_events
21+
from .observability import is_observability_enabled
2122
from .telemetry import emit_control_events, has_control_event_sink
2223
from .tracing import get_trace_and_span_ids
2324
from .validation import ensure_agent_name
@@ -173,8 +174,36 @@ async def check_evaluation(
173174
step: Step,
174175
stage: Literal["pre", "post"],
175176
) -> EvaluationResult:
176-
"""Check if agent interaction is safe."""
177+
"""Check if agent interaction is safe through the public SDK helper.
178+
179+
This helper preserves the default server-only evaluation path, but it also
180+
participates in the optional merged-event flow when a control event sink is
181+
registered. In that mode, the SDK asks the server to skip final event
182+
ingestion, reconstructs server events from the lightweight response, and
183+
emits them through the registered sink before returning the parsed result.
184+
185+
Args:
186+
client: Configured AgentControl client.
187+
agent_name: Agent name to evaluate against.
188+
step: Step payload to evaluate.
189+
stage: Evaluation stage, ``pre`` or ``post``.
190+
191+
Returns:
192+
The parsed evaluation result returned by the server.
193+
"""
177194
normalized_name = ensure_agent_name(agent_name)
195+
merged_emission_enabled = has_control_event_sink()
196+
trace_id = None
197+
span_id = None
198+
headers: dict[str, str] | None = None
199+
200+
if merged_emission_enabled:
201+
trace_id, span_id = get_trace_and_span_ids()
202+
headers = {
203+
"X-Trace-Id": trace_id,
204+
"X-Span-Id": span_id,
205+
"X-Agent-Control-Merge-Events": "true",
206+
}
178207

179208
request = EvaluationRequest(
180209
agent_name=normalized_name,
@@ -183,10 +212,28 @@ async def check_evaluation(
183212
)
184213
request_payload = request.model_dump(mode="json")
185214

186-
response = await client.http_client.post("/api/v1/evaluation", json=request_payload)
215+
response = await client.http_client.post(
216+
"/api/v1/evaluation",
217+
json=request_payload,
218+
headers=headers,
219+
)
187220
response.raise_for_status()
188221

189-
return cast(EvaluationResult, EvaluationResult.from_dict(response.json()))
222+
evaluation_response = EvaluationResponse.model_validate(response.json())
223+
224+
if merged_emission_enabled:
225+
server_control_lookup = _build_server_control_lookup(state.server_controls or [])
226+
server_events = build_control_execution_events(
227+
evaluation_response,
228+
request,
229+
server_control_lookup,
230+
trace_id,
231+
span_id,
232+
normalized_name,
233+
)
234+
emit_control_events(server_events)
235+
236+
return cast(EvaluationResult, EvaluationResult.from_dict(evaluation_response.model_dump()))
190237

191238

192239
async def check_evaluation_with_local(
@@ -305,6 +352,7 @@ def _with_parse_errors(result: EvaluationResult) -> EvaluationResult:
305352
return result.model_copy(update={"errors": combined_errors})
306353

307354
merged_emission_enabled = has_control_event_sink()
355+
should_reconstruct_local_events = merged_emission_enabled or is_observability_enabled()
308356

309357
local_result: EvaluationResponse | None = None
310358
local_events = []
@@ -316,20 +364,21 @@ def _with_parse_errors(result: EvaluationResult) -> EvaluationResult:
316364
if applicable_local_controls:
317365
engine = ControlEngine(applicable_local_controls, context="sdk")
318366
local_result = await engine.process(request)
319-
local_control_lookup = {
320-
control.id: control.control for control in applicable_local_controls
321-
}
322-
local_events = build_control_execution_events(
323-
local_result,
324-
request,
325-
local_control_lookup,
326-
resolved_trace_id,
327-
resolved_span_id,
328-
event_agent_name,
329-
)
367+
if should_reconstruct_local_events:
368+
local_control_lookup = {
369+
control.id: control.control for control in applicable_local_controls
370+
}
371+
local_events = build_control_execution_events(
372+
local_result,
373+
request,
374+
local_control_lookup,
375+
resolved_trace_id,
376+
resolved_span_id,
377+
event_agent_name,
378+
)
330379

331-
if not merged_emission_enabled:
332-
enqueue_observability_events(local_events)
380+
if not merged_emission_enabled:
381+
enqueue_observability_events(local_events)
333382

334383
if not local_result.is_safe:
335384
result = _with_parse_errors(EvaluationResult.model_validate(local_result.model_dump()))
@@ -354,15 +403,17 @@ def _with_parse_errors(result: EvaluationResult) -> EvaluationResult:
354403
)
355404
response.raise_for_status()
356405
server_result = EvaluationResponse.model_validate(response.json())
357-
server_control_lookup = _build_server_control_lookup(server_control_payloads)
358-
server_events = build_control_execution_events(
359-
server_result,
360-
request,
361-
server_control_lookup,
362-
resolved_trace_id,
363-
resolved_span_id,
364-
event_agent_name,
365-
)
406+
server_events = []
407+
if merged_emission_enabled:
408+
server_control_lookup = _build_server_control_lookup(server_control_payloads)
409+
server_events = build_control_execution_events(
410+
server_result,
411+
request,
412+
server_control_lookup,
413+
resolved_trace_id,
414+
resolved_span_id,
415+
event_agent_name,
416+
)
366417

367418
if local_result is not None:
368419
result = _with_parse_errors(_merge_results(local_result, server_result))

‎sdks/python/tests/test_evaluation.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ def json(self) -> dict[str, object]:
6666
},
6767
"stage": "pre",
6868
},
69+
headers=None,
6970
)
7071

7172

‎sdks/python/tests/test_observability_updates.py‎

Lines changed: 131 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,7 @@ async def test_resolves_provider_trace_context_for_local_events(self):
326326

327327
@pytest.mark.asyncio
328328
async def test_forwards_provider_trace_headers_to_server_when_ids_omitted(self):
329+
"""Server POST should resolve trace headers from the provider when omitted."""
329330
from agent_control_models import Step
330331

331332
controls = [{
@@ -355,7 +356,12 @@ async def test_forwards_provider_trace_headers_to_server_when_ids_omitted(self):
355356
client.http_client = AsyncMock()
356357
client.http_client.post = AsyncMock(return_value=mock_http_response)
357358
step = Step(type="llm", name="test-step", input="hello")
358-
set_trace_context_provider(lambda: {"trace_id": "c" * 32, "span_id": "d" * 16})
359+
set_trace_context_provider(
360+
lambda: {
361+
"trace_id": "c" * 32,
362+
"span_id": "d" * 16,
363+
}
364+
)
359365

360366
with patch("agent_control.evaluation.list_evaluators", return_value=["regex"]):
361367
await evaluation.check_evaluation_with_local(
@@ -366,67 +372,158 @@ async def test_forwards_provider_trace_headers_to_server_when_ids_omitted(self):
366372
controls=controls,
367373
)
368374

369-
headers = client.http_client.post.call_args.kwargs["headers"]
370-
assert headers["X-Trace-Id"] == "c" * 32
371-
assert headers["X-Span-Id"] == "d" * 16
372-
# Verify POST was called with headers
373375
call_kwargs = client.http_client.post.call_args
374376
headers = call_kwargs.kwargs.get("headers", {})
375-
assert headers["X-Trace-Id"] == "aaaa1111bbbb2222cccc3333dddd4444"
376-
assert headers["X-Span-Id"] == "eeee5555ffff6666"
377+
assert headers["X-Trace-Id"] == "c" * 32
378+
assert headers["X-Span-Id"] == "d" * 16
377379

380+
381+
class TestCheckEvaluation:
378382
@pytest.mark.asyncio
379-
async def test_forwards_provider_trace_headers_to_server_when_ids_omitted(self):
380-
"""Server POST should resolve trace headers from the provider when omitted."""
383+
async def test_default_path_keeps_server_only_behavior(self):
381384
from agent_control_models import Step
382385

386+
mock_http_response = MagicMock()
387+
mock_http_response.raise_for_status = MagicMock()
388+
mock_http_response.json.return_value = {
389+
"is_safe": True,
390+
"confidence": 0.9,
391+
"matches": None,
392+
"errors": None,
393+
"non_matches": None,
394+
}
395+
396+
client = MagicMock()
397+
client.http_client = AsyncMock()
398+
client.http_client.post = AsyncMock(return_value=mock_http_response)
399+
step = Step(type="llm", name="test-step", input="hello")
400+
401+
with patch("agent_control.evaluation.has_control_event_sink", return_value=False), \
402+
patch("agent_control.evaluation.emit_control_events") as mock_emit:
403+
result = await evaluation.check_evaluation(
404+
client=client,
405+
agent_name="agent-000000000001",
406+
step=step,
407+
stage="pre",
408+
)
409+
410+
call_kwargs = client.http_client.post.call_args.kwargs
411+
assert call_kwargs["headers"] is None
412+
mock_emit.assert_not_called()
413+
assert result.is_safe is True
414+
assert result.confidence == 0.9
415+
416+
@pytest.mark.asyncio
417+
async def test_merged_event_sink_emits_reconstructed_server_events(self):
418+
from agent_control_models import Step
419+
420+
controls = [
421+
{
422+
"id": 2,
423+
"name": "server-ctrl",
424+
"control": {
425+
"condition": {
426+
"evaluator": {"name": "regex", "config": {"pattern": "test"}},
427+
"selector": {"path": "input"},
428+
},
429+
"action": {"decision": "allow"},
430+
"execution": "server",
431+
},
432+
}
433+
]
434+
435+
mock_http_response = MagicMock()
436+
mock_http_response.raise_for_status = MagicMock()
437+
mock_http_response.json.return_value = {
438+
"is_safe": True,
439+
"confidence": 0.9,
440+
"matches": [
441+
{
442+
"control_id": 2,
443+
"control_name": "server-ctrl",
444+
"action": "allow",
445+
"control_execution_id": "ce-server",
446+
"result": {"matched": True, "confidence": 0.4},
447+
}
448+
],
449+
"errors": None,
450+
"non_matches": None,
451+
}
452+
453+
client = MagicMock()
454+
client.http_client = AsyncMock()
455+
client.http_client.post = AsyncMock(return_value=mock_http_response)
456+
step = Step(type="llm", name="test-step", input="hello")
457+
458+
with patch("agent_control.evaluation.has_control_event_sink", return_value=True), \
459+
patch("agent_control.evaluation.get_trace_and_span_ids", return_value=("a" * 32, "b" * 16)), \
460+
patch("agent_control.evaluation.emit_control_events") as mock_emit, \
461+
patch.object(evaluation.state, "server_controls", controls):
462+
result = await evaluation.check_evaluation(
463+
client=client,
464+
agent_name="agent-000000000001",
465+
step=step,
466+
stage="pre",
467+
)
468+
469+
headers = client.http_client.post.call_args.kwargs["headers"]
470+
assert headers["X-Trace-Id"] == "a" * 32
471+
assert headers["X-Span-Id"] == "b" * 16
472+
assert headers["X-Agent-Control-Merge-Events"] == "true"
473+
474+
mock_emit.assert_called_once()
475+
emitted_events = mock_emit.call_args.args[0]
476+
assert len(emitted_events) == 1
477+
assert emitted_events[0].control_id == 2
478+
assert emitted_events[0].trace_id == "a" * 32
479+
assert emitted_events[0].span_id == "b" * 16
480+
assert emitted_events[0].metadata["primary_evaluator"] == "regex"
481+
assert result.matches is not None
482+
assert len(result.matches) == 1
483+
484+
@pytest.mark.asyncio
485+
async def test_skips_local_event_reconstruction_when_nothing_consumes_events(self):
486+
from agent_control_models import EvaluationResponse, Step
487+
383488
controls = [{
384489
"id": 1,
385-
"name": "server-ctrl",
490+
"name": "local-ctrl",
386491
"control": {
387492
"condition": {
388493
"evaluator": {"name": "regex", "config": {"pattern": "test"}},
389494
"selector": {"path": "input"},
390495
},
391-
"action": {"decision": "deny"},
392-
"execution": "server",
496+
"action": {"decision": "allow"},
497+
"execution": "sdk",
393498
},
394499
}]
395500

396-
mock_http_response = MagicMock()
397-
mock_http_response.json.return_value = {
398-
"is_safe": True,
399-
"confidence": 1.0,
400-
"matches": None,
401-
"errors": None,
402-
"non_matches": None,
403-
}
404-
mock_http_response.raise_for_status = MagicMock()
501+
mock_response = EvaluationResponse(is_safe=True, confidence=1.0)
502+
mock_engine = MagicMock()
503+
mock_engine.process = AsyncMock(return_value=mock_response)
405504

406505
client = MagicMock()
407506
client.http_client = AsyncMock()
408-
client.http_client.post = AsyncMock(return_value=mock_http_response)
409507
step = Step(type="llm", name="test-step", input="hello")
410-
set_trace_context_provider(
411-
lambda: {
412-
"trace_id": "c" * 32,
413-
"span_id": "d" * 16,
414-
}
415-
)
416508

417-
with patch("agent_control.evaluation.list_evaluators", return_value=["regex"]):
418-
await evaluation.check_evaluation_with_local(
509+
with patch("agent_control.evaluation.ControlEngine", return_value=mock_engine), \
510+
patch("agent_control.evaluation.list_evaluators", return_value=["regex"]), \
511+
patch("agent_control.evaluation.has_control_event_sink", return_value=False), \
512+
patch("agent_control.evaluation.is_observability_enabled", return_value=False), \
513+
patch("agent_control.evaluation.build_control_execution_events") as mock_build, \
514+
patch("agent_control.evaluation.enqueue_observability_events") as mock_enqueue:
515+
result = await evaluation.check_evaluation_with_local(
419516
client=client,
420517
agent_name="agent-000000000001",
421518
step=step,
422519
stage="pre",
423520
controls=controls,
424521
)
425522

426-
call_kwargs = client.http_client.post.call_args
427-
headers = call_kwargs.kwargs.get("headers", {})
428-
assert headers["X-Trace-Id"] == "c" * 32
429-
assert headers["X-Span-Id"] == "d" * 16
523+
mock_build.assert_not_called()
524+
mock_enqueue.assert_not_called()
525+
assert result.is_safe is True
526+
assert result.confidence == 1.0
430527

431528

432529
# =============================================================================

0 commit comments

Comments
 (0)