Skip to content

Commit c0d9a57

Browse files
ensure control sink exists for merged mode
1 parent f58778e commit c0d9a57

5 files changed

Lines changed: 75 additions & 6 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,7 @@ async def handle(message: str):
477477

478478
# Re-init behavior: always stop existing loop before mutating shared agent/session globals.
479479
_stop_policy_refresh_loop()
480+
clear_control_event_sink()
480481

481482
# Configure logging if provided (do this early before any logging happens)
482483
if log_config:
@@ -647,6 +648,7 @@ def _reset_state() -> None:
647648
state.server_controls = None
648649
state.server_url = None
649650
state.api_key = None
651+
clear_control_event_sink()
650652

651653

652654
async def ashutdown() -> None:

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

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,32 @@ def _has_applicable_prefiltered_server_controls(
117117
)
118118

119119

120+
def _is_merged_event_mode_enabled(agent_name: str) -> bool:
121+
"""Return whether SDK-side merged event emission is safe for this request.
122+
123+
Merged reconstruction depends on initialized SDK session state:
124+
a registered sink, an initialized agent, and a cached server-control
125+
snapshot for the same agent. If any of those prerequisites are missing,
126+
evaluation falls back to the default behavior where the server remains the
127+
final emitter for server-side events.
128+
129+
Args:
130+
agent_name: Normalized agent name for the current request.
131+
132+
Returns:
133+
``True`` when the current SDK session has enough state to reconstruct
134+
and emit merged events safely.
135+
"""
136+
if not has_control_event_sink():
137+
return False
138+
139+
current_agent = state.current_agent
140+
if current_agent is None or current_agent.agent_name != agent_name:
141+
return False
142+
143+
return state.server_controls is not None
144+
145+
120146
def _merge_results(
121147
local_result: EvaluationResponse,
122148
server_result: EvaluationResponse,
@@ -192,7 +218,7 @@ async def check_evaluation(
192218
The parsed evaluation result returned by the server.
193219
"""
194220
normalized_name = ensure_agent_name(agent_name)
195-
merged_emission_enabled = has_control_event_sink()
221+
merged_emission_enabled = _is_merged_event_mode_enabled(normalized_name)
196222
trace_id = None
197223
span_id = None
198224
headers: dict[str, str] | None = None
@@ -351,7 +377,7 @@ def _with_parse_errors(result: EvaluationResult) -> EvaluationResult:
351377
combined_errors = (result.errors or []) + parse_errors
352378
return result.model_copy(update={"errors": combined_errors})
353379

354-
merged_emission_enabled = has_control_event_sink()
380+
merged_emission_enabled = _is_merged_event_mode_enabled(normalized_name)
355381
should_reconstruct_local_events = merged_emission_enabled or is_observability_enabled()
356382

357383
local_result: EvaluationResponse | None = None

‎sdks/python/tests/test_init_step_merge.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,11 @@ class DoesNotExist: ...
2020
@pytest.fixture(autouse=True)
2121
def _clean_registry() -> Generator[None, None, None]:
2222
"""Ensure each test starts with an empty step registry."""
23+
agent_control._reset_state()
2324
clear()
2425
yield
2526
clear()
27+
agent_control._reset_state()
2628

2729

2830
def test_init_passes_merged_steps_to_register_agent(

‎sdks/python/tests/test_observability_updates.py‎

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,7 @@ async def test_default_path_keeps_server_only_behavior(self):
398398
client.http_client.post = AsyncMock(return_value=mock_http_response)
399399
step = Step(type="llm", name="test-step", input="hello")
400400

401-
with patch("agent_control.evaluation.has_control_event_sink", return_value=False), \
401+
with patch("agent_control.evaluation._is_merged_event_mode_enabled", return_value=False), \
402402
patch("agent_control.evaluation.emit_control_events") as mock_emit:
403403
result = await evaluation.check_evaluation(
404404
client=client,
@@ -455,7 +455,7 @@ async def test_merged_event_sink_emits_reconstructed_server_events(self):
455455
client.http_client.post = AsyncMock(return_value=mock_http_response)
456456
step = Step(type="llm", name="test-step", input="hello")
457457

458-
with patch("agent_control.evaluation.has_control_event_sink", return_value=True), \
458+
with patch("agent_control.evaluation._is_merged_event_mode_enabled", return_value=True), \
459459
patch("agent_control.evaluation.get_trace_and_span_ids", return_value=("a" * 32, "b" * 16)), \
460460
patch("agent_control.evaluation.emit_control_events") as mock_emit, \
461461
patch.object(evaluation.state, "server_controls", controls):
@@ -508,7 +508,7 @@ async def test_skips_local_event_reconstruction_when_nothing_consumes_events(sel
508508

509509
with patch("agent_control.evaluation.ControlEngine", return_value=mock_engine), \
510510
patch("agent_control.evaluation.list_evaluators", return_value=["regex"]), \
511-
patch("agent_control.evaluation.has_control_event_sink", return_value=False), \
511+
patch("agent_control.evaluation._is_merged_event_mode_enabled", return_value=False), \
512512
patch("agent_control.evaluation.is_observability_enabled", return_value=False), \
513513
patch("agent_control.evaluation.build_control_execution_events") as mock_build, \
514514
patch("agent_control.evaluation.enqueue_observability_events") as mock_enqueue:
@@ -525,6 +525,42 @@ async def test_skips_local_event_reconstruction_when_nothing_consumes_events(sel
525525
assert result.is_safe is True
526526
assert result.confidence == 1.0
527527

528+
@pytest.mark.asyncio
529+
async def test_check_evaluation_falls_back_when_initialized_agent_does_not_match(self):
530+
from agent_control_models import Step
531+
532+
mock_http_response = MagicMock()
533+
mock_http_response.raise_for_status = MagicMock()
534+
mock_http_response.json.return_value = {
535+
"is_safe": True,
536+
"confidence": 0.9,
537+
"matches": None,
538+
"errors": None,
539+
"non_matches": None,
540+
}
541+
542+
client = MagicMock()
543+
client.http_client = AsyncMock()
544+
client.http_client.post = AsyncMock(return_value=mock_http_response)
545+
step = Step(type="llm", name="test-step", input="hello")
546+
547+
with patch("agent_control.evaluation.has_control_event_sink", return_value=True), \
548+
patch("agent_control.evaluation.emit_control_events") as mock_emit, \
549+
patch.object(evaluation.state, "current_agent", MagicMock(agent_name="agent-000000000002")), \
550+
patch.object(evaluation.state, "server_controls", []):
551+
result = await evaluation.check_evaluation(
552+
client=client,
553+
agent_name="agent-000000000001",
554+
step=step,
555+
stage="pre",
556+
)
557+
558+
call_kwargs = client.http_client.post.call_args.kwargs
559+
assert call_kwargs["headers"] is None
560+
mock_emit.assert_not_called()
561+
assert result.is_safe is True
562+
assert result.confidence == 0.9
563+
528564

529565
# =============================================================================
530566
# control_decorators non_matches dict conversion
@@ -606,7 +642,7 @@ async def test_merged_event_sink_emits_reconstructed_local_and_server_events_onc
606642

607643
with patch("agent_control.evaluation.ControlEngine", return_value=mock_engine), \
608644
patch("agent_control.evaluation.list_evaluators", return_value=["regex"]), \
609-
patch("agent_control.evaluation.has_control_event_sink", return_value=True), \
645+
patch("agent_control.evaluation._is_merged_event_mode_enabled", return_value=True), \
610646
patch("agent_control.evaluation.emit_control_events") as mock_emit, \
611647
patch("agent_control.evaluation.enqueue_observability_events") as mock_enqueue:
612648
result = await evaluation.check_evaluation_with_local(

‎sdks/python/tests/test_shutdown.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import agent_control.observability as obs_mod
1616
from agent_control._state import state
1717
from agent_control.observability import EventBatcher
18+
from agent_control.telemetry.event_sink import has_control_event_sink, set_control_event_sink
1819

1920

2021
def _make_started_batcher() -> EventBatcher:
@@ -64,6 +65,7 @@ def test_shutdown_resets_state(self):
6465
state.server_controls = [{"name": "test"}]
6566
state.server_url = "http://localhost:8000"
6667
state.api_key = "key"
68+
set_control_event_sink(lambda events: None)
6769

6870
agent_control.shutdown()
6971

@@ -72,6 +74,7 @@ def test_shutdown_resets_state(self):
7274
assert state.server_controls is None
7375
assert state.server_url is None
7476
assert state.api_key is None
77+
assert has_control_event_sink() is False
7578

7679
def test_shutdown_idempotent(self):
7780
agent_control.shutdown()

0 commit comments

Comments
 (0)