From 9de0cd07a3e52dfe71828498d7478af2e0bec843 Mon Sep 17 00:00:00 2001 From: Yixin Date: Thu, 24 Sep 2026 16:19:06 -0700 Subject: [PATCH 1/3] fix(sdk): resolve all condition leaf selectors into control event input Composite controls (and/or/not) previously collapsed control-execution event input down to a single representative leaf's value via _composite_metadata(), hiding the other selector values that fed the decision. Reconstruct the full {selector_path: value} map by resolving every leaf in the condition tree (via observability_identity's all_selector_paths and select_data) against the original step, so the emitted event/span input reflects everything the control actually evaluated. Co-Authored-By: Claude Code --- .../src/agent_control/evaluation_events.py | 6 ++ .../tests/test_observability_updates.py | 73 ++++++++++++++++++- 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/sdks/python/src/agent_control/evaluation_events.py b/sdks/python/src/agent_control/evaluation_events.py index 3a2c1a60..e13c499c 100644 --- a/sdks/python/src/agent_control/evaluation_events.py +++ b/sdks/python/src/agent_control/evaluation_events.py @@ -3,6 +3,7 @@ from collections.abc import Mapping from datetime import UTC, datetime +from agent_control_engine.selectors import select_data from agent_control_models import ( ControlDefinition, ControlDefinitionRuntime, @@ -118,6 +119,11 @@ def _build_events_for_matches( if control_def is not None: selector_path, evaluator_name, identity_metadata = observability_metadata(control_def) event_metadata.update(identity_metadata) + all_selector_paths = identity_metadata.get("all_selector_paths") or [] + if all_selector_paths: + event_metadata["input"] = { + path: select_data(request.step, path) for path in all_selector_paths + } events.append( ControlExecutionEvent( diff --git a/sdks/python/tests/test_observability_updates.py b/sdks/python/tests/test_observability_updates.py index 475705c2..3ad07c19 100644 --- a/sdks/python/tests/test_observability_updates.py +++ b/sdks/python/tests/test_observability_updates.py @@ -279,7 +279,7 @@ def test_builds_custom_type_event_without_llm_classification(self): assert events[0].applies_to == "retriever_call" assert events[0].model_dump(mode="json")["applies_to"] == "retriever_call" - def test_uses_safe_selected_data_preview_as_event_input(self): + def test_resolves_all_selector_paths_into_event_input(self): response = self._make_response( matches=[ self._make_match( @@ -328,6 +328,38 @@ def test_uses_safe_selected_data_preview_as_event_input(self): assert "selected_data_preview" not in events[0].metadata assert "engine_selected_data" not in events[0].metadata assert "engine_selected_data_preview" not in events[0].metadata + # The resolved-from-step value for the control's selector path wins over + # any stale debug preview captured at evaluation time. + assert events[0].metadata["input"] == {"input": "hello"} + + def test_falls_back_to_selected_data_preview_when_control_definition_is_missing(self): + response = self._make_response( + matches=[ + self._make_match( + 1, + "ctrl-1", + metadata={ + "selected_data_preview": { + "type": "dict", + "value": {"prompt": "raw sensitive input"}, + "truncated": False, + }, + }, + ) + ] + ) + request = self._make_request() + + events = build_control_execution_events( + response, + request, + {}, + "trace123", + "span456", + "test-agent", + ) + + assert len(events) == 1 assert events[0].metadata["input"] == {"prompt": "raw sensitive input"} def test_composite_control_uses_representative_observability_identity(self): @@ -370,6 +402,45 @@ def test_composite_control_uses_representative_observability_identity(self): assert event.metadata["leaf_count"] == 2 assert event.metadata["all_evaluators"] == ["regex"] assert event.metadata["all_selector_paths"] == ["input", "output"] + # input reflects every leaf's resolved value, not just the + # representative selector chosen for observability identity. + assert event.metadata["input"] == {"input": "hello", "output": None} + + def test_or_condition_resolves_input_for_every_leaf_selector(self): + response = self._make_response(matches=[self._make_match(1, "ctrl-1")]) + request = self._make_request(step_type="tool") + control_lookup = { + 1: self._make_control( + 1, + "ctrl-1", + { + "or": [ + { + "selector": {"path": "input.query"}, + "evaluator": {"name": "regex", "config": {"pattern": "hello"}}, + }, + { + "selector": {"path": "output"}, + "evaluator": {"name": "regex", "config": {"pattern": "done"}}, + }, + ] + }, + ).control + } + + events = build_control_execution_events( + response, + request, + control_lookup, + "trace123", + "span456", + "test-agent", + ) + + assert len(events) == 1 + event = events[0] + assert event.metadata["all_selector_paths"] == ["input.query", "output"] + assert event.metadata["input"] == {"input.query": "hello", "output": None} def test_preserves_error_message_parity_by_result_category(self): from agent_control_models import ControlMatch, EvaluationResponse, EvaluatorResult From bb24fc98531966df9f1acbed3d8622687e2af0e3 Mon Sep 17 00:00:00 2001 From: Yixin Date: Thu, 24 Sep 2026 16:46:57 -0700 Subject: [PATCH 2/3] fix: annotate rows type in list_bindings to satisfy mypy The untyped _apply_filters helper makes page_stmt infer as Any, which cascades to Any for rows and leaves mypy unable to infer a concrete element type for the list() call. Co-Authored-By: Claude Code --- server/src/agent_control_server/services/control_bindings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/agent_control_server/services/control_bindings.py b/server/src/agent_control_server/services/control_bindings.py index f6b04d44..b4118bd6 100644 --- a/server/src/agent_control_server/services/control_bindings.py +++ b/server/src/agent_control_server/services/control_bindings.py @@ -279,7 +279,7 @@ def _apply_filters(stmt): # type: ignore[no-untyped-def] if cursor is not None: page_stmt = page_stmt.where(ControlBinding.id < cursor) result = await self._db.execute(page_stmt.limit(limit + 1)) - rows = list(result.scalars().all()) + rows: list[ControlBinding] = list(result.scalars().all()) has_more = len(rows) > limit if has_more: rows = rows[:limit] From 040b57688930301d3be8ef83cb9f00455cb98e20 Mon Sep 17 00:00:00 2001 From: Yixin Date: Thu, 24 Sep 2026 16:53:26 -0700 Subject: [PATCH 3/3] fix: cast all_selector_paths to list[str] to satisfy mypy identity_metadata is typed as dict[str, object], so .get(...) yields object, which mypy correctly flags as not iterable in the dict comprehension below. Co-Authored-By: Claude Code --- sdks/python/src/agent_control/evaluation_events.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sdks/python/src/agent_control/evaluation_events.py b/sdks/python/src/agent_control/evaluation_events.py index e13c499c..46c9ac15 100644 --- a/sdks/python/src/agent_control/evaluation_events.py +++ b/sdks/python/src/agent_control/evaluation_events.py @@ -2,6 +2,7 @@ from collections.abc import Mapping from datetime import UTC, datetime +from typing import cast from agent_control_engine.selectors import select_data from agent_control_models import ( @@ -119,7 +120,9 @@ def _build_events_for_matches( if control_def is not None: selector_path, evaluator_name, identity_metadata = observability_metadata(control_def) event_metadata.update(identity_metadata) - all_selector_paths = identity_metadata.get("all_selector_paths") or [] + all_selector_paths = cast( + "list[str]", identity_metadata.get("all_selector_paths") or [] + ) if all_selector_paths: event_metadata["input"] = { path: select_data(request.step, path) for path in all_selector_paths