Skip to content

Commit a418507

Browse files
committed
feat: add SDK-local control timing example
1 parent 3690373 commit a418507

4 files changed

Lines changed: 280 additions & 0 deletions

File tree

‎examples/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ This directory contains runnable examples for Agent Control. Each example has it
1414
| Customer Support Agent | Enterprise scenario with PII protection, prompt-injection defense, and multiple tools. | https://docs.agentcontrol.dev/examples/customer-support |
1515
| DeepEval | Build a custom evaluator using DeepEval GEval metrics. | https://docs.agentcontrol.dev/examples/deepeval |
1616
| Galileo Luna Direct | Direct Luna scorer invoke evaluation (`scorer_id` required; `scorer_label`/`scorer_version_id` optional) with a composite Agent Control condition. | `examples/galileo_luna/` |
17+
| SDK-local timing | Compare per-control duration with whole SDK call time using synthetic local controls and no network. | `examples/sdk_local_timing/` |
1718
| LangChain SQL Agent | Protect a SQL agent from dangerous queries with server-side controls. | https://docs.agentcontrol.dev/examples/langchain-sql |
1819
| Steer Action Demo | Banking transfer agent showcasing observe, deny, and steer actions. | https://docs.agentcontrol.dev/examples/steer-action-demo |
1920
| Target Context | Bind controls to opaque external targets (e.g. `env=prod`) and let the SDK pin one target per session. | https://docs.agentcontrol.dev/examples/target-context |
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# SDK-local control timing example
2+
3+
This example runs five synthetic controls through the Python SDK's real
4+
`check_evaluation_with_local` path. It covers list allow/block, regex steer,
5+
SQL block, and JSON validation. Each case uses one control, so its result and
6+
duration have a clear owner.
7+
8+
PR #283 measures the full control condition evaluation. It includes evaluator
9+
work and any wait for an evaluator slot. It excludes control loading and SDK
10+
call overhead. The separate `sdk_call` distribution includes those costs.
11+
These values are not isolated evaluator-call timings.
12+
13+
From the repository root:
14+
15+
```bash
16+
make sync
17+
uv run --package agent-control-sdk python examples/sdk_local_timing/demo.py --iterations 100 --warmup 5
18+
```
19+
20+
The default output is a small per-control p50/p95 table. Pass `--json` for
21+
counts, min, p50, p95 and max values in a machine-readable report. The
22+
`control` number comes from `ControlMatch.execution_duration_ms`; `sdk_call`
23+
is measured around the SDK call by this example.
24+
25+
The example disables SDK observability for its run and restores the previous
26+
settings afterward. An HTTP mock rejects all outbound requests. No account,
27+
API key, server, Luna scorer, or GPU is needed. The warmup runs are excluded
28+
from the reported nearest-rank p50 and p95 values.
29+
30+
This is a sequential local demonstration. It does not establish concurrent
31+
SDK capacity or server/Luna3 throughput. Those require a separate load test.

‎examples/sdk_local_timing/demo.py‎

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
"""Compare SDK-local per-control time with the full SDK call, without network I/O."""
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import asyncio
7+
import json
8+
import math
9+
import time
10+
from dataclasses import dataclass
11+
from typing import Any, Literal
12+
13+
import httpx
14+
from agent_control import AgentControlClient
15+
from agent_control.evaluation import check_evaluation_with_local
16+
from agent_control.settings import configure_settings, get_settings
17+
from agent_control_models import Step
18+
19+
20+
@dataclass(frozen=True)
21+
class Scenario:
22+
name: str
23+
evaluator: str
24+
config: dict[str, Any]
25+
selector: str
26+
decision: Literal["deny", "steer", "observe"]
27+
step_input: dict[str, Any]
28+
expected_bucket: Literal["matches", "non_matches"]
29+
30+
31+
LIST_CONFIG = {
32+
"values": ["delete_customer"],
33+
"logic": "any",
34+
"match_on": "match",
35+
"match_mode": "exact",
36+
"case_sensitive": False,
37+
}
38+
39+
SCENARIOS = (
40+
Scenario(
41+
"list-allow",
42+
"list",
43+
LIST_CONFIG,
44+
"input.action",
45+
"deny",
46+
{"action": "read_customer"},
47+
"non_matches",
48+
),
49+
Scenario(
50+
"list-block",
51+
"list",
52+
LIST_CONFIG,
53+
"input.action",
54+
"deny",
55+
{"action": "delete_customer"},
56+
"matches",
57+
),
58+
Scenario(
59+
"regex-steer",
60+
"regex",
61+
{"pattern": "transfer"},
62+
"input.action",
63+
"steer",
64+
{"action": "transfer"},
65+
"matches",
66+
),
67+
Scenario(
68+
"sql-block",
69+
"sql",
70+
{"blocked_operations": ["DROP"]},
71+
"input.sql",
72+
"deny",
73+
{"sql": "DROP TABLE customers"},
74+
"matches",
75+
),
76+
Scenario(
77+
"json-valid",
78+
"json",
79+
{"required_fields": ["customer_id"]},
80+
"input.payload",
81+
"observe",
82+
{"payload": '{"customer_id":"synthetic-1"}'},
83+
"non_matches",
84+
),
85+
)
86+
87+
88+
def control_payload(scenario: Scenario, control_id: int) -> dict[str, Any]:
89+
return {
90+
"id": control_id,
91+
"name": scenario.name,
92+
"control": {
93+
"enabled": True,
94+
"execution": "sdk",
95+
"scope": {"step_types": ["tool"], "stages": ["pre"], "step_names": ["timing_probe"]},
96+
"condition": {
97+
"selector": {"path": scenario.selector},
98+
"evaluator": {"name": scenario.evaluator, "config": scenario.config},
99+
},
100+
"action": {"decision": scenario.decision},
101+
},
102+
}
103+
104+
105+
def distribution(values: list[float]) -> dict[str, float | int]:
106+
"""Summarize a nonempty sample using nearest-rank percentiles."""
107+
ordered = sorted(values)
108+
109+
def rank(fraction: float) -> float:
110+
return ordered[max(0, math.ceil(fraction * len(ordered)) - 1)]
111+
112+
return {
113+
"count": len(ordered),
114+
"min_ms": ordered[0],
115+
"p50_ms": rank(0.50),
116+
"p95_ms": rank(0.95),
117+
"max_ms": ordered[-1],
118+
}
119+
120+
121+
def reject_network(request: httpx.Request) -> httpx.Response:
122+
raise AssertionError(f"SDK-local demo attempted HTTP to {request.url.host}")
123+
124+
125+
def format_table(report: dict[str, Any]) -> str:
126+
"""Render each local control's timing beside the enclosing SDK call."""
127+
rows = [
128+
f"iterations={report['iterations']} warmup={report['warmup']}",
129+
"scenario outcome control p50/p95 (ms) SDK call p50/p95 (ms)",
130+
]
131+
for name, result in report["scenarios"].items():
132+
control = result["control"]
133+
sdk_call = result["sdk_call"]
134+
rows.append(
135+
f"{name:<13} {result['outcome']:<12} "
136+
f"{control['p50_ms']:>8.3f}/{control['p95_ms']:>8.3f} "
137+
f"{sdk_call['p50_ms']:>8.3f}/{sdk_call['p95_ms']:>8.3f}"
138+
)
139+
return "\n".join(rows)
140+
141+
142+
async def run(*, iterations: int, warmup: int) -> dict[str, Any]:
143+
"""Run each synthetic control locally and validate its measured result."""
144+
if not 1 <= iterations <= 100_000 or not 0 <= warmup <= 10_000:
145+
raise ValueError("iterations must be 1..100000 and warmup must be 0..10000")
146+
147+
previous_settings = get_settings().model_dump()
148+
configure_settings(observability_enabled=False)
149+
try:
150+
report: dict[str, Any] = {"iterations": iterations, "warmup": warmup, "scenarios": {}}
151+
async with AgentControlClient(
152+
base_url="https://unused.invalid", transport=httpx.MockTransport(reject_network)
153+
) as client:
154+
for control_id, scenario in enumerate(SCENARIOS, start=1):
155+
control = control_payload(scenario, control_id)
156+
step = Step(type="tool", name="timing_probe", input=scenario.step_input)
157+
control_times: list[float] = []
158+
sdk_times: list[float] = []
159+
for index in range(warmup + iterations):
160+
started_at = time.perf_counter()
161+
result = await check_evaluation_with_local(
162+
client, "sdk-local-timing-demo", step, "pre", [control]
163+
)
164+
sdk_ms = (time.perf_counter() - started_at) * 1000
165+
bucket = getattr(result, scenario.expected_bucket) or []
166+
other_buckets = (
167+
["non_matches"] if scenario.expected_bucket == "matches" else ["matches"]
168+
) + ["errors"]
169+
if len(bucket) != 1 or any(getattr(result, name) for name in other_buckets):
170+
raise RuntimeError(f"{scenario.name}: unexpected control outcome")
171+
if bucket[0].control_id != control_id:
172+
raise RuntimeError(f"{scenario.name}: wrong control result")
173+
expected_match = scenario.expected_bucket == "matches"
174+
if (
175+
bucket[0].action != scenario.decision
176+
or bucket[0].result.matched != expected_match
177+
or result.is_safe != (not expected_match)
178+
):
179+
raise RuntimeError(f"{scenario.name}: unexpected action or safety result")
180+
duration_ms = bucket[0].execution_duration_ms
181+
if duration_ms is None or not math.isfinite(duration_ms) or duration_ms < 0:
182+
raise RuntimeError(f"{scenario.name}: missing or invalid control duration")
183+
if index >= warmup:
184+
control_times.append(duration_ms)
185+
sdk_times.append(sdk_ms)
186+
report["scenarios"][scenario.name] = {
187+
"evaluator": scenario.evaluator,
188+
"outcome": scenario.expected_bucket,
189+
"control": distribution(control_times),
190+
"sdk_call": distribution(sdk_times),
191+
}
192+
return report
193+
finally:
194+
configure_settings(**previous_settings)
195+
196+
197+
def main() -> None:
198+
parser = argparse.ArgumentParser(description=__doc__)
199+
parser.add_argument("--iterations", type=int, default=100)
200+
parser.add_argument("--warmup", type=int, default=5)
201+
parser.add_argument("--json", action="store_true", help="emit machine-readable samples summary")
202+
args = parser.parse_args()
203+
report = asyncio.run(run(iterations=args.iterations, warmup=args.warmup))
204+
print(json.dumps(report, indent=2) if args.json else format_table(report))
205+
206+
207+
if __name__ == "__main__":
208+
main()
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Behavior checks for the SDK-local timing example."""
2+
3+
import pytest
4+
5+
from examples.sdk_local_timing.demo import distribution, format_table, run
6+
7+
8+
def test_distribution_uses_nearest_rank() -> None:
9+
# Given: a sample with a known upper-tail observation.
10+
values = list(range(1, 21))
11+
12+
# When: the collector summarizes the sample.
13+
summary = distribution(values)
14+
15+
# Then: p95 is the nineteenth ordered value.
16+
assert summary == {"count": 20, "min_ms": 1, "p50_ms": 10, "p95_ms": 19, "max_ms": 20}
17+
18+
19+
@pytest.mark.asyncio
20+
async def test_cases_run_locally_and_have_control_time() -> None:
21+
# Given: an HTTP transport that rejects any outbound request.
22+
23+
# When: each synthetic case runs through the SDK local path after one warmup.
24+
report = await run(iterations=2, warmup=1)
25+
26+
# Then: each case returns exactly one measured control result.
27+
assert set(report["scenarios"]) == {
28+
"list-allow",
29+
"list-block",
30+
"regex-steer",
31+
"sql-block",
32+
"json-valid",
33+
}
34+
for case in report["scenarios"].values():
35+
assert case["control"]["count"] == 2
36+
assert case["control"]["min_ms"] >= 0
37+
assert case["sdk_call"]["min_ms"] >= case["control"]["min_ms"]
38+
table = format_table(report)
39+
assert "control p50/p95 (ms)" in table
40+
assert "regex-steer" in table

0 commit comments

Comments
 (0)