Skip to content

Commit d98198c

Browse files
committed
feat(sdk): measure whole control-check elapsed time
1 parent 59b2ec5 commit d98198c

13 files changed

Lines changed: 767 additions & 18 deletions

File tree

‎models/src/agent_control_models/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
from .observability import (
7070
BatchEventsRequest,
7171
BatchEventsResponse,
72+
ControlCheckEvent,
7273
ControlExecutionEvent,
7374
ControlStats,
7475
ControlStatsResponse,
@@ -220,6 +221,7 @@
220221
"ValidateControlDataRequest",
221222
"ValidateControlDataResponse",
222223
# Observability models
224+
"ControlCheckEvent",
223225
"ControlExecutionEvent",
224226
"BatchEventsRequest",
225227
"BatchEventsResponse",

‎models/src/agent_control_models/evaluation.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,21 @@ class EvaluationResult(EvaluationResponse):
170170
This is what SDK users interact with.
171171
"""
172172

173+
control_check_id: str | None = Field(
174+
default=None,
175+
description="Unique ID of this SDK control-check invocation, when measured",
176+
)
177+
control_check_elapsed_ms: float | None = Field(
178+
default=None,
179+
ge=0,
180+
allow_inf_nan=False,
181+
description=(
182+
"SDK caller-visible elapsed milliseconds for one pre/post control check, "
183+
"including local evaluation and conditional server request; excludes the "
184+
"protected tool/LLM function"
185+
),
186+
)
187+
173188
def is_confident(self, threshold: float = 0.8) -> bool:
174189
"""
175190
Check if the result confidence exceeds a threshold.

‎models/src/agent_control_models/observability.py‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@
99
"""
1010

1111
from datetime import UTC, datetime
12-
from typing import Any, Literal
12+
from typing import Any, Literal, Self
1313
from uuid import uuid4
1414

15-
from pydantic import Field, field_validator
15+
from pydantic import Field, field_validator, model_validator
1616

1717
from .actions import (
1818
ActionDecision,
@@ -582,3 +582,31 @@ class ControlStatsResponse(BaseModel):
582582
@classmethod
583583
def validate_and_normalize_agent_name(cls, value: str) -> str:
584584
return normalize_agent_name(str(value))
585+
586+
587+
class ControlCheckEvent(BaseModel):
588+
"""One SDK control-check invocation, separate from individual control executions.
589+
590+
Start is captured at invocation and end is anchored using monotonic elapsed
591+
nanoseconds. No input/output or exception text is included. A completed check
592+
can be unsafe or contain handled evaluator errors; error/cancelled describe
593+
failures of the SDK check itself.
594+
"""
595+
596+
control_check_id: str = Field(min_length=1)
597+
trace_id: str = Field(min_length=1)
598+
span_id: str = Field(min_length=1, description="Parent step span ID")
599+
agent_name: str = Field(min_length=1)
600+
check_stage: Literal["pre", "post"]
601+
status: Literal["completed", "error", "cancelled"]
602+
elapsed_ms: float = Field(ge=0, allow_inf_nan=False)
603+
start_time_unix_nano: int = Field(ge=0)
604+
end_time_unix_nano: int = Field(ge=0)
605+
is_safe: bool | None = None
606+
607+
@model_validator(mode="after")
608+
def validate_time_order(self) -> Self:
609+
"""Reject an end before the invocation started."""
610+
if self.end_time_unix_nano < self.start_time_unix_nano:
611+
raise ValueError("control-check end must not precede its start")
612+
return self

‎sdks/python/README.md‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,58 @@ It force-flushes the provider during shutdown but leaves provider shutdown to th
118118
application. If no provider is passed, the OTEL sink first checks for a globally
119119
registered SDK provider, then falls back to its existing Agent Control-owned OTLP
120120
pipeline.
121+
122+
123+
## Whole control-check elapsed time
124+
125+
`evaluate_controls()`, `check_evaluation_with_local()` and the server-only
126+
`evaluation.check_evaluation()` return `EvaluationResult.control_check_elapsed_ms`
127+
and a unique `control_check_id`. Each value measures **one pre or post check**
128+
with a monotonic clock. It includes preparation inside the evaluation helper,
129+
local controls, and the conditional server call (network/auth refresh/retry wait
130+
included). It excludes `init()`, decorator payload preparation before the helper,
131+
the protected tool/LLM function, and export of the whole-check event itself.
132+
High-level `evaluate_controls()` step construction and client setup happen before
133+
the timed helper and are excluded, as is parent trace-context resolution.
134+
The decorator server-only fallback also measures its HTTP evaluation attempt;
135+
its already-built payload and client initialization are outside that boundary.
136+
If local evaluation fails and the decorator falls back, these are separate check
137+
attempts with separate IDs rather than one fabricated combined elapsed value.
138+
Existing per-control `execution_duration_ms` values are unchanged.
139+
140+
```python
141+
# After agent_control.init(...) has configured the SDK session.
142+
result = await agent_control.evaluate_controls(
143+
agent_name="my-agent", step_name="search", input={"query": "example"},
144+
stage="pre", step_type="tool"
145+
)
146+
print(result.control_check_id, result.control_check_elapsed_ms)
147+
```
148+
149+
Local denial, a completed result containing evaluator errors, and a check with
150+
no applicable controls still have measured elapsed time. The last case measures
151+
the check's preparation, rather than assigning zero as a placeholder. A genuinely
152+
measured zero is valid. Older/manually constructed results have `None` unless
153+
actually measured by these helpers. An SDK check that raises or is cancelled
154+
still propagates that outcome; it does not return a fabricated evaluation result.
155+
156+
With observability enabled and an `otel` sink selected, the same provider emits
157+
one `agent_control.control_check` span as a child of the correlated step. Its
158+
start is captured when the check begins and its end uses that start plus measured
159+
monotonic elapsed nanoseconds; these are not reconstructed from control children.
160+
Attributes include `agent_control.control_check_elapsed_ms`, the check ID,
161+
`check_stage`, and `control_check_status` (`completed`, `error`, `cancelled`).
162+
Safe IDs/stage/elapsed/status are also supplied in the JSON `metadata` attribute.
163+
No input/output or exception text is recorded. Existing Orbit ingestion treats
164+
this as a workflow span; it is **not** an individual shield/control span. Showing
165+
these spans/metadata in a dedicated session summary requires UI/Orbit follow-up.
166+
167+
Existing HTTP control-event ingestion accepts only individual control events;
168+
it does not receive whole-check events. Custom registered sinks may opt in by
169+
implementing `agent_control_telemetry.ControlCheckEventSink` in addition to their
170+
existing `write_events` method. Control-only sinks remain compatible. Sink
171+
failures never replace a result, exception or cancellation.
172+
173+
An elapsed value is neither an average nor the maximum of child controls.
174+
Several checks may overlap in a session, so summing their elapsed values does
175+
not establish session wall time or a counterfactual "agent overhead".

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ async def handle_input(user_message: str) -> str:
5959
from agent_control_models import (
6060
Agent,
6161
ControlAction,
62+
ControlCheckEvent,
6263
ControlDefinition,
6364
ControlMatch,
6465
ControlScope,
@@ -112,6 +113,7 @@ async def handle_input(user_message: str) -> str:
112113
sync_shutdown_observability,
113114
unregister_control_event_sink,
114115
unregister_control_event_sink_factory,
116+
write_control_check_events,
115117
write_events,
116118
)
117119
from .otel_sink import control_event_to_otel_span
@@ -1631,6 +1633,7 @@ async def main():
16311633
# Observability
16321634
"init_observability",
16331635
"add_event",
1636+
"write_control_check_events",
16341637
"write_events",
16351638
"shutdown_observability",
16361639
"is_observability_enabled",
@@ -1654,6 +1657,7 @@ async def main():
16541657
"StepSchema",
16551658
"EvaluationRequest",
16561659
"EvaluationResult",
1660+
"ControlCheckEvent",
16571661
"ControlDefinition",
16581662
"TemplateControlInput",
16591663
"TemplateDefinition",
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""SDK caller-visible timing for one control-check invocation."""
2+
3+
import asyncio
4+
import logging
5+
import time
6+
from collections.abc import Awaitable, Callable
7+
from typing import Any, Literal
8+
from uuid import uuid4
9+
10+
from agent_control_models import ControlCheckEvent, EvaluationResult
11+
12+
from .observability import is_observability_enabled, write_control_check_events
13+
14+
logger = logging.getLogger(__name__)
15+
16+
async def measure_control_check[CheckResult: (EvaluationResult, dict[str, Any])](
17+
evaluate: Callable[[], Awaitable[CheckResult]],
18+
*,
19+
agent_name: str,
20+
stage: Literal["pre", "post"],
21+
trace_id: str | None,
22+
span_id: str | None,
23+
) -> CheckResult:
24+
"""Measure the actual local-plus-remote check, retaining exceptions/cancellation.
25+
26+
The monotonic clock measures evaluation-helper work, including preparation
27+
and existing per-control event handling. Check-event construction/export is
28+
outside the elapsed boundary. Epoch time is captured only for observability;
29+
its span end is the captured start plus measured monotonic nanoseconds.
30+
"""
31+
emit_event = is_observability_enabled()
32+
start_unix_nano = time.time_ns() if emit_event else 0
33+
started = time.perf_counter_ns()
34+
result: CheckResult | None = None
35+
status: Literal["completed", "error", "cancelled"] = "error"
36+
try:
37+
result = await evaluate()
38+
status = "completed"
39+
return result
40+
except asyncio.CancelledError:
41+
status = "cancelled"
42+
raise
43+
finally:
44+
elapsed_ns = time.perf_counter_ns() - started
45+
elapsed_ms = elapsed_ns / 1_000_000
46+
check_id = str(uuid4()) if result is not None or emit_event else ""
47+
is_safe: bool | None = None
48+
if isinstance(result, EvaluationResult):
49+
result.control_check_id = check_id
50+
result.control_check_elapsed_ms = elapsed_ms
51+
is_safe = result.is_safe
52+
elif result is not None:
53+
# Preserve the decorator's raw server response, including extensions.
54+
result["control_check_id"] = check_id
55+
result["control_check_elapsed_ms"] = elapsed_ms
56+
if isinstance(result.get("is_safe"), bool):
57+
is_safe = result["is_safe"]
58+
if emit_event:
59+
try:
60+
event = ControlCheckEvent(
61+
control_check_id=check_id,
62+
trace_id=trace_id or "0" * 32,
63+
span_id=span_id or "0" * 16,
64+
agent_name=str(agent_name),
65+
check_stage=stage,
66+
status=status,
67+
elapsed_ms=elapsed_ms,
68+
start_time_unix_nano=start_unix_nano,
69+
end_time_unix_nano=start_unix_nano + elapsed_ns,
70+
is_safe=is_safe,
71+
)
72+
write_control_check_events([event])
73+
except Exception:
74+
logger.warning("Control-check telemetry failed", exc_info=True)

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

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,14 @@ async def chat(message: str) -> str:
3434
from collections.abc import Callable, Mapping, Sequence
3535
from copy import deepcopy
3636
from dataclasses import dataclass, field
37-
from typing import Any, TypeVar
37+
from typing import Any, Literal, TypeVar, cast
3838

3939
from agent_control_models import JSONObject, Step, normalize_action
4040
from agent_control_telemetry import get_trace_context_from_provider
4141

4242
from agent_control import AgentControlClient
4343
from agent_control._state import state
44+
from agent_control.control_check import measure_control_check
4445
from agent_control.evaluation import (
4546
_post_evaluation_request,
4647
_resolve_session_target,
@@ -337,6 +338,8 @@ async def _evaluate(
337338
"is_safe": result.is_safe,
338339
"confidence": result.confidence,
339340
"reason": result.reason,
341+
"control_check_id": result.control_check_id,
342+
"control_check_elapsed_ms": result.control_check_elapsed_ms,
340343
"matches": [
341344
{
342345
"control_id": m.control_id,
@@ -420,16 +423,25 @@ async def _evaluate(
420423
payload["target_type"] = target_type
421424
payload["target_id"] = target_id
422425

423-
response = await _post_evaluation_request(
424-
client,
425-
request_payload=payload,
426-
headers=headers,
427-
target_type=target_type,
428-
target_id=target_id,
426+
async def server_check() -> dict[str, Any]:
427+
response = await _post_evaluation_request(
428+
client,
429+
request_payload=payload,
430+
headers=headers,
431+
target_type=target_type,
432+
target_id=target_id,
433+
)
434+
response.raise_for_status()
435+
result_dict: dict[str, Any] = response.json()
436+
return result_dict
437+
438+
return await measure_control_check(
439+
server_check,
440+
agent_name=event_agent_name or agent_name,
441+
stage=cast(Literal["pre", "post"], stage),
442+
trace_id=trace_id,
443+
span_id=span_id,
429444
)
430-
response.raise_for_status()
431-
result_dict: dict[str, Any] = response.json()
432-
return result_dict
433445

434446

435447
def _unexpected_control_failure_message(stage: str, error: Exception) -> str:

0 commit comments

Comments
 (0)