Skip to content

Commit a378e06

Browse files
committed
Merge branch 'main' into feature/SAO-16858-add-evaluator-for-LLM
2 parents b0d5561 + 86fbbfe commit a378e06

5 files changed

Lines changed: 130 additions & 6 deletions

File tree

‎models/src/agent_control_models/agent.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,11 @@ class Step(BaseModel):
168168
ground_truth: JSONValue | None = Field(
169169
None, description="Optional expected or reference output for this step"
170170
)
171+
children: list[Step] | None = Field(
172+
None,
173+
exclude_if=lambda value: value is None,
174+
description="Optional child steps associated with this invocation",
175+
)
171176

172177
@field_validator("type")
173178
@classmethod
@@ -182,3 +187,6 @@ def validate_builtin_types(self) -> Step:
182187
if not isinstance(self.input, dict):
183188
raise ValueError("tool steps require object input")
184189
return self
190+
191+
192+
Step.model_rebuild()

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,7 @@ async def evaluate_controls(
523523
context: dict[str, Any] | None = None,
524524
tools: list[dict[str, JSONValue]] | None = None,
525525
ground_truth: JSONValue | None = None,
526+
children: list[Step] | None = None,
526527
step_type: str = "llm",
527528
stage: Literal["pre", "post"] = "pre",
528529
agent_name: str,
@@ -561,6 +562,8 @@ async def evaluate_controls(
561562
step_dict["tools"] = tools
562563
if ground_truth is not None:
563564
step_dict["ground_truth"] = ground_truth
565+
if children is not None:
566+
step_dict["children"] = children
564567

565568
step_obj = Step(**step_dict) # type: ignore[arg-type]
566569
resolved_controls = state.server_controls or []

‎sdks/python/tests/test_evaluation.py‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import pytest
77
from agent_control import evaluation
88
from agent_control.evaluation import EvaluationResult
9+
from agent_control_models import Step
910
from pydantic import ValidationError
1011

1112

@@ -228,6 +229,85 @@ async def test_evaluate_controls_preserves_explicit_tools_and_ground_truth(monke
228229
assert step.ground_truth == {"answer": "expected"}
229230

230231

232+
def test_step_legacy_serialization_is_unchanged_without_children():
233+
"""A Step without children keeps the existing serialized payload."""
234+
step = Step(type="llm", name="chat", input="hello")
235+
236+
assert step.model_dump(mode="json") == {
237+
"type": "llm",
238+
"name": "chat",
239+
"input": "hello",
240+
"output": None,
241+
"context": None,
242+
"tools": None,
243+
"ground_truth": None,
244+
}
245+
assert Step(type="llm", name="chat", input="hello", children=None).children is None
246+
247+
248+
def test_step_accepts_and_preserves_recursive_children():
249+
"""Nested child steps retain all provider-neutral runtime fields."""
250+
child_payload = {
251+
"type": "llm",
252+
"name": "child",
253+
"input": {"prompt": "question"},
254+
"output": "answer",
255+
"context": {"conversation": ["earlier"]},
256+
"tools": [{"name": "lookup", "input_schema": {"type": "object"}}],
257+
"ground_truth": {"answer": "expected"},
258+
"children": [
259+
{"type": "retriever", "name": "search", "input": {"query": "q"}}
260+
],
261+
}
262+
parent = Step(type="llm", name="parent", input="hello", children=[child_payload])
263+
264+
assert parent.children is not None
265+
child = parent.children[0]
266+
assert child.type == child_payload["type"]
267+
assert child.name == child_payload["name"]
268+
assert child.input == child_payload["input"]
269+
assert child.output == child_payload["output"]
270+
assert child.context == child_payload["context"]
271+
assert child.tools == child_payload["tools"]
272+
assert child.ground_truth == child_payload["ground_truth"]
273+
assert child.children is not None
274+
assert child.children[0].input == {"query": "q"}
275+
276+
277+
def test_step_rejects_invalid_child_values_clearly():
278+
with pytest.raises(ValidationError, match="children"):
279+
Step(type="llm", name="parent", input="hello", children=["not a step"])
280+
281+
282+
@pytest.mark.asyncio
283+
async def test_evaluate_controls_passes_children_without_recursively_evaluating(monkeypatch):
284+
"""Children are data on the current Step and are not evaluated separately."""
285+
mock_check = AsyncMock(return_value=EvaluationResult(is_safe=True, confidence=1.0))
286+
monkeypatch.setattr(evaluation, "check_evaluation_with_local", mock_check)
287+
children = [
288+
Step(
289+
type="tool",
290+
name="search",
291+
input={"query": "nested"},
292+
children=[Step(type="llm", name="nested", input="prompt")],
293+
)
294+
]
295+
296+
with patch("agent_control.state.server_url", "http://localhost:8000"):
297+
await evaluation.evaluate_controls(
298+
step_name="parent",
299+
input="hello",
300+
children=children,
301+
stage="pre",
302+
agent_name="test-bot",
303+
)
304+
305+
mock_check.assert_awaited_once()
306+
passed_step = mock_check.call_args.kwargs["step"]
307+
assert passed_step.children == children
308+
assert passed_step.children[0].children == children[0].children
309+
310+
231311
@pytest.mark.asyncio
232312
async def test_evaluate_controls_uses_session_api_key_header(monkeypatch):
233313
"""evaluate_controls should pass init's API-key header into the client."""

‎sdks/python/tests/test_local_evaluation.py‎

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,18 @@
1313

1414
import httpx
1515
import pytest
16+
from agent_control.client import AgentControlClient
17+
from agent_control.evaluation import (
18+
_merge_results,
19+
check_evaluation_with_local,
20+
)
1621
from agent_control_models import (
1722
ControlMatch,
1823
EvaluationResponse,
1924
EvaluatorResult,
2025
Step,
2126
)
2227

23-
from agent_control.client import AgentControlClient
24-
from agent_control.evaluation import (
25-
_merge_results,
26-
check_evaluation_with_local,
27-
)
28-
2928
# =============================================================================
3029
# Test Fixtures
3130
# =============================================================================
@@ -1316,3 +1315,29 @@ async def test_local_evaluation_includes_steering_context(self, agent_name, llm_
13161315
from agent_control_models.controls import SteeringContext as SteeringContextModel
13171316
assert isinstance(match.steering_context, SteeringContextModel)
13181317
assert match.steering_context.message == "Please rephrase your input"
1318+
1319+
1320+
@pytest.mark.asyncio
1321+
async def test_child_content_does_not_change_current_step_control_matching(
1322+
agent_name: str,
1323+
) -> None:
1324+
"""Local controls still select against the current step, not its children."""
1325+
parent = Step(
1326+
type="llm",
1327+
name="parent",
1328+
input="safe parent input",
1329+
children=[Step(type="llm", name="child", input="test secret")],
1330+
)
1331+
client = _HttpOnlyDuckClient()
1332+
1333+
result = await check_evaluation_with_local(
1334+
client=client, # Given: local execution with a matching pattern only in a child
1335+
agent_name=agent_name,
1336+
step=parent,
1337+
stage="pre",
1338+
controls=[make_control_dict(1, "parent-match", execution="sdk", pattern="secret")],
1339+
)
1340+
1341+
assert result.is_safe is True
1342+
assert result.matches is None
1343+
client.http_client.post.assert_not_awaited()

‎sdks/typescript/src/generated/models/step.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ import { remap as remap$ } from "../lib/primitives.js";
99
* Runtime payload for an agent step invocation.
1010
*/
1111
export type Step = {
12+
/**
13+
* Optional child steps associated with this invocation
14+
*/
15+
children?: Array<Step> | null | undefined;
1216
/**
1317
* Optional context (conversation history, metadata, etc.)
1418
*/
@@ -41,6 +45,7 @@ export type Step = {
4145

4246
/** @internal */
4347
export type Step$Outbound = {
48+
children?: Array<Step$Outbound> | null | undefined;
4449
context?: { [k: string]: any } | null | undefined;
4550
ground_truth?: any | null | undefined;
4651
input: any;
@@ -53,6 +58,9 @@ export type Step$Outbound = {
5358
/** @internal */
5459
export const Step$outboundSchema: z.ZodMiniType<Step$Outbound, Step> = z.pipe(
5560
z.object({
61+
children: z.optional(
62+
z.nullable(z.array(z.lazy(() => Step$outboundSchema))),
63+
),
5664
context: z.optional(z.nullable(z.record(z.string(), z.any()))),
5765
groundTruth: z.optional(z.nullable(z.any())),
5866
input: z.any(),

0 commit comments

Comments
 (0)