Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions engine/src/agent_control_engine/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import functools
import logging
import os
import time
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any, Literal, Protocol
Expand Down Expand Up @@ -201,6 +202,7 @@ class _EvalTask:
item: ControlWithIdentity
task: asyncio.Task[None] | None = None
result: EvaluatorResult | None = None
execution_duration_ms: float | None = None


@dataclass
Expand Down Expand Up @@ -673,6 +675,7 @@ async def process(

async def evaluate_control(eval_task: _EvalTask) -> None:
"""Evaluate a single control, respecting cancellation and timeout."""
started_at = time.perf_counter()
try:
evaluation = await self._evaluate_condition(
eval_task.item,
Expand Down Expand Up @@ -701,6 +704,9 @@ async def evaluate_control(eval_task: _EvalTask) -> None:
error_msg,
message_prefix="Condition evaluation failed",
)
finally:
if eval_task.result is not None:
eval_task.execution_duration_ms = (time.perf_counter() - started_at) * 1000

# Create and start all tasks
for eval_task in eval_tasks:
Expand Down Expand Up @@ -753,6 +759,7 @@ async def wait_for_deny() -> None:
control_name=eval_task.item.name,
action=eval_task.item.control.action.decision,
result=eval_task.result,
execution_duration_ms=eval_task.execution_duration_ms,
steering_context=eval_task.item.control.action.steering_context,
)
)
Expand All @@ -776,6 +783,7 @@ async def wait_for_deny() -> None:
control_name=eval_task.item.name,
action=eval_task.item.control.action.decision,
result=eval_task.result,
execution_duration_ms=eval_task.execution_duration_ms,
steering_context=steer_ctx,
)
)
Expand All @@ -792,6 +800,7 @@ async def wait_for_deny() -> None:
control_name=eval_task.item.name,
action=eval_task.item.control.action.decision,
result=eval_task.result,
execution_duration_ms=eval_task.execution_duration_ms,
steering_context=eval_task.item.control.action.steering_context,
)
)
Expand Down
254 changes: 254 additions & 0 deletions engine/tests/test_execution_duration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
"""Public engine contract tests for per-control execution durations."""

import asyncio
from dataclasses import dataclass
from typing import Any, Literal
from unittest.mock import AsyncMock, Mock

import pytest
from agent_control_engine.core import ControlEngine
from agent_control_evaluators import Evaluator, EvaluatorMetadata, register_evaluator
from agent_control_models import ControlDefinition, EvaluationRequest, EvaluatorResult, Step
from pydantic import BaseModel


class DurationConfig(BaseModel):
matched: bool = False
raises_error: bool = False
blocks: bool = False


class DurationEvaluator(Evaluator[DurationConfig]):
metadata = EvaluatorMetadata(
name="test-duration",
version="1.0.0",
description="Returns a configured outcome or waits for cancellation",
)
config_model = DurationConfig

async def evaluate(self, data: Any) -> EvaluatorResult:
# Yield once so concurrent controls all start before one completes.
await asyncio.sleep(0)
if self.config.blocks:
await asyncio.Event().wait()
if self.config.raises_error:
raise RuntimeError("evaluation failed")
return EvaluatorResult(matched=self.config.matched, confidence=1.0)


@dataclass
class DurationControl:
id: int
name: str
control: ControlDefinition


@pytest.fixture(autouse=True)
def register_duration_evaluator() -> None:
register_evaluator(DurationEvaluator)


@pytest.fixture
def evaluation_request() -> EvaluationRequest:
return EvaluationRequest(
agent_name="duration-test-agent",
step=Step(type="llm", name="test-step", input="test", output=None),
stage="pre",
)


def make_control(
control_id: int = 1,
*,
context: Literal["server", "sdk"] = "server",
matched: bool = False,
raises_error: bool = False,
blocks: bool = False,
decision: Literal["observe", "deny"] = "observe",
) -> DurationControl:
return DurationControl(
id=control_id,
name=f"duration-control-{control_id}",
control=ControlDefinition(
description="Duration test control",
enabled=True,
execution=context,
scope={"stages": ["pre"], "step_types": ["llm"]},
condition={
"selector": {"path": "input"},
"evaluator": {
"name": "test-duration",
"config": {
"matched": matched,
"raises_error": raises_error,
"blocks": blocks,
},
},
},
action={"decision": decision},
),
)


@pytest.mark.asyncio
@pytest.mark.parametrize("context", ["server", "sdk"])
@pytest.mark.parametrize("category", ["matches", "non_matches", "errors"])
@pytest.mark.parametrize("elapsed_seconds", [0.0, 0.0125])
async def test_completed_controls_report_elapsed_milliseconds(
monkeypatch: pytest.MonkeyPatch,
evaluation_request: EvaluationRequest,
context: Literal["server", "sdk"],
category: str,
elapsed_seconds: float,
) -> None:
# Given: a completed control and a controlled clock instead of wall-clock assertions
control = make_control(
context=context,
matched=category == "matches",
raises_error=category == "errors",
)
clock = Mock(side_effect=[10.0, 10.0 + elapsed_seconds])
monkeypatch.setattr("agent_control_engine.core.time.perf_counter", clock)

# When: evaluating via the public engine entrypoint
response = await ControlEngine([control], context=context).process(evaluation_request)

# Then: all completed outcome categories retain their own duration, including zero
results = getattr(response, category)
assert results is not None
assert len(results) == 1
assert results[0].execution_duration_ms == pytest.approx(elapsed_seconds * 1000)
assert results[0].result.matched is (category == "matches")
assert bool(results[0].result.error) is (category == "errors")
assert response.is_safe is True


@pytest.mark.asyncio
async def test_parallel_controls_keep_individual_durations(
monkeypatch: pytest.MonkeyPatch, evaluation_request: EvaluationRequest
) -> None:
# Given: parallel controls start together and complete at different clock readings
clock = Mock(side_effect=[10.0, 10.0, 10.0125, 10.025])
monkeypatch.setattr("agent_control_engine.core.time.perf_counter", clock)
controls = [make_control(1), make_control(2)]

# When: evaluating both controls in the same engine request
response = await ControlEngine(controls).process(evaluation_request)

# Then: each control reports its own elapsed time rather than the batch duration
assert response.non_matches is not None
durations = {result.control_id: result.execution_duration_ms for result in response.non_matches}
assert durations == pytest.approx({1: 12.5, 2: 25.0})


@pytest.mark.asyncio
async def test_condition_traversal_error_retains_elapsed_duration(
monkeypatch: pytest.MonkeyPatch, evaluation_request: EvaluationRequest
) -> None:
# Given: an internal traversal failure forced to exercise the outer error handler
engine = ControlEngine([make_control(decision="deny")])
monkeypatch.setattr(
engine, "_evaluate_condition", AsyncMock(side_effect=ValueError("condition failed"))
)
clock = Mock(side_effect=[10.0, 10.007])
monkeypatch.setattr("agent_control_engine.core.time.perf_counter", clock)

# When: evaluating via the public engine entrypoint
response = await engine.process(evaluation_request)

# Then: the completed error is timed and still fails closed
assert response.is_safe is False
assert response.errors is not None
assert len(response.errors) == 1
assert response.errors[0].execution_duration_ms == pytest.approx(7.0)
assert response.errors[0].result.error == "ValueError: condition failed"


@pytest.mark.asyncio
async def test_timed_out_evaluation_retains_elapsed_duration(
monkeypatch: pytest.MonkeyPatch, evaluation_request: EvaluationRequest
) -> None:
# Given: a blocking evaluator with a bounded timeout and a controlled elapsed clock
monkeypatch.setattr(DurationEvaluator, "get_timeout_seconds", lambda self: 0.001)
clock = Mock(side_effect=[10.0, 10.001])
monkeypatch.setattr("agent_control_engine.core.time.perf_counter", clock)

# When: the evaluator times out without producing a normal result
response = await ControlEngine([make_control(blocks=True, decision="deny")]).process(
evaluation_request
)

# Then: the timeout error is a completed evaluation with an elapsed duration
assert response.is_safe is False
assert response.errors is not None
assert len(response.errors) == 1
assert response.errors[0].execution_duration_ms == pytest.approx(1.0)
assert response.errors[0].result.error is not None
assert response.errors[0].result.error.startswith("TimeoutError:")


@pytest.mark.asyncio
async def test_cancelled_controls_do_not_report_partial_durations(
monkeypatch: pytest.MonkeyPatch, evaluation_request: EvaluationRequest
) -> None:
# Given: a blocking control that is cancelled by a completed deny control
clock = Mock(side_effect=[10.0, 10.0, 10.020])
monkeypatch.setattr("agent_control_engine.core.time.perf_counter", clock)
controls = [make_control(1, blocks=True), make_control(2, matched=True, decision="deny")]

# When: a deny match cancels the unfinished evaluation
response = await ControlEngine(controls).process(evaluation_request)

# Then: only the completed deny is returned, with no fabricated cancelled result
assert response.is_safe is False
assert response.matches is not None
assert len(response.matches) == 1
assert response.matches[0].control_id == 2
assert response.matches[0].execution_duration_ms == pytest.approx(20.0)
assert response.non_matches is None
assert response.errors is None
assert clock.call_count == 3


@pytest.mark.asyncio
async def test_skipped_control_does_not_start_duration_measurement(
monkeypatch: pytest.MonkeyPatch, evaluation_request: EvaluationRequest
) -> None:
# Given: a disabled control that must not evaluate
control = make_control()
control.control.enabled = False
clock = Mock(side_effect=AssertionError("skipped control must not be timed"))
monkeypatch.setattr("agent_control_engine.core.time.perf_counter", clock)

# When: processing a request without applicable controls
response = await ControlEngine([control]).process(evaluation_request)

# Then: there are no results or clock reads for the skipped control
assert response.matches is None
assert response.non_matches is None
assert response.errors is None
clock.assert_not_called()


@pytest.mark.asyncio
async def test_precheck_error_has_no_execution_duration(
monkeypatch: pytest.MonkeyPatch, evaluation_request: EvaluationRequest
) -> None:
# Given: validation is bypassed to exercise the defensive invalid-regex precheck
control = make_control()
payload = control.control.model_dump()
payload["scope"]["step_name_regex"] = "[invalid(regex"
control.control = ControlDefinition.model_validate(
payload, context={"allow_invalid_step_name_regex": True}
)
clock = Mock(side_effect=AssertionError("precheck failure must not be timed"))
monkeypatch.setattr("agent_control_engine.core.time.perf_counter", clock)

# When: processing a control that fails before evaluation
response = await ControlEngine([control]).process(evaluation_request)

# Then: the error retains an unknown duration rather than zero
assert response.errors is not None
assert len(response.errors) == 1
assert response.errors[0].execution_duration_ms is None
clock.assert_not_called()
14 changes: 14 additions & 0 deletions models/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,20 @@ agent = Agent(agent_name="support-bot", agent_description="Support agent")
step = Step(type="llm", name="chat", input="hello")
```

## Control execution duration

Evaluation responses include an optional top-level `execution_duration_ms` on each
`ControlMatch` in `matches`, `non_matches`, and `errors`. It measures that control's
condition evaluation in wall-clock milliseconds, including the visited condition
leaves, evaluator work, and time waiting for an evaluator concurrency slot.
Control loading and request/response overhead are outside this measurement.

Completed evaluations include a nonnegative value, including evaluator failures.
Precheck failures have no duration, and canceled evaluations produce no result.
Older responses without the field remain valid. The Python SDK copies the value
to `ControlExecutionEvent.execution_duration_ms`; the TypeScript SDK exposes it as
`executionDurationMs`.

## Tests

```bash
Expand Down
5 changes: 5 additions & 0 deletions models/src/agent_control_models/controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -957,6 +957,11 @@ class ControlMatch(BaseModel):
result: EvaluatorResult = Field(
..., description="Evaluator result (confidence, message, metadata)"
)
execution_duration_ms: float | None = Field(
default=None,
ge=0,
description="Control evaluation wall-clock duration in milliseconds, if executed",
)
steering_context: SteeringContext | None = Field(
None,
description="Steering context for steer actions if configured"
Expand Down
Loading
Loading