Skip to content

Commit 5d29b8a

Browse files
committed
feat(evaluators): add starts_with mode to list evaluator
1 parent 90265ba commit 5d29b8a

5 files changed

Lines changed: 108 additions & 7 deletions

File tree

‎evaluators/builtin/src/agent_control_evaluators/list/config.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,12 @@ class ListEvaluatorConfig(EvaluatorConfig):
1919
match_on: Literal["match", "no_match"] = Field(
2020
"match", description="Trigger rule on match or no match"
2121
)
22-
match_mode: Literal["exact", "contains"] = Field(
22+
match_mode: Literal["exact", "contains", "starts_with"] = Field(
2323
"exact",
24-
description="'exact' for full string match, 'contains' for keyword/substring match",
24+
description=(
25+
"'exact' for full string match, 'contains' for keyword matching, "
26+
"'starts_with' for prefix matching"
27+
),
2528
)
2629
case_sensitive: bool = Field(False, description="Whether matching is case sensitive")
2730

‎evaluators/builtin/src/agent_control_evaluators/list/evaluator.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ class ListEvaluator(Evaluator[ListEvaluatorConfig]):
1818
Checks if data matches values in a list. Supports:
1919
- any/all logic (match any value vs match all values)
2020
- match/no_match trigger (trigger on match or no match)
21-
- exact/contains mode (full match vs substring/keyword)
21+
- exact/contains/starts_with mode (full match vs keyword vs prefix)
2222
- case sensitivity toggle
2323
2424
Example configs:
@@ -50,6 +50,9 @@ def _build_regex(self) -> Any:
5050
if self.config.match_mode == "contains":
5151
# Word boundary matching for substring/keyword detection
5252
pattern = f"\\b({'|'.join(escaped)})\\b"
53+
elif self.config.match_mode == "starts_with":
54+
# Prefix matching using anchors
55+
pattern = f"^({'|'.join(escaped)})"
5356
else:
5457
# Exact match using anchors
5558
pattern = f"^({'|'.join(escaped)})$"

‎evaluators/builtin/tests/list/test_list.py‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,100 @@ def test_whitespace_only_value_rejected(self) -> None:
3333
class TestListEvaluator:
3434
"""Tests for list evaluator runtime behavior."""
3535

36+
@pytest.mark.asyncio
37+
async def test_starts_with_matches_prefix(self) -> None:
38+
"""Test that starts_with mode triggers on prefix matches."""
39+
# Given: a starts_with evaluator config
40+
evaluator = ListEvaluator(
41+
ListEvaluatorConfig(
42+
values=["/home/lev/agent-control", "/tmp/cache"],
43+
logic="any",
44+
match_on="match",
45+
match_mode="starts_with",
46+
case_sensitive=True,
47+
)
48+
)
49+
50+
# When: evaluating a path under an allowed prefix
51+
result = await evaluator.evaluate("/home/lev/agent-control/server/src/app.py")
52+
53+
# Then: the prefix match triggers
54+
assert result.matched is True
55+
assert result.metadata["matches"] == ["/home/lev/agent-control/server/src/app.py"]
56+
57+
@pytest.mark.asyncio
58+
async def test_starts_with_no_match_when_prefix_absent(self) -> None:
59+
"""Test that starts_with mode does not trigger when no prefix matches."""
60+
# Given: a starts_with evaluator config
61+
evaluator = ListEvaluator(
62+
ListEvaluatorConfig(
63+
values=["/home/lev/agent-control", "/tmp/cache"],
64+
logic="any",
65+
match_on="match",
66+
match_mode="starts_with",
67+
case_sensitive=True,
68+
)
69+
)
70+
71+
# When: evaluating a path with no configured prefix
72+
result = await evaluator.evaluate("/var/log/system.log")
73+
74+
# Then: the evaluator does not trigger
75+
assert result.matched is False
76+
77+
@pytest.mark.asyncio
78+
async def test_starts_with_honors_case_sensitivity(self) -> None:
79+
"""Test that starts_with mode respects case sensitivity settings."""
80+
# Given: two starts_with evaluators that differ only by case sensitivity
81+
insensitive = ListEvaluator(
82+
ListEvaluatorConfig(
83+
values=["/HOME/LEV/AGENT-CONTROL"],
84+
logic="any",
85+
match_on="match",
86+
match_mode="starts_with",
87+
case_sensitive=False,
88+
)
89+
)
90+
sensitive = ListEvaluator(
91+
ListEvaluatorConfig(
92+
values=["/HOME/LEV/AGENT-CONTROL"],
93+
logic="any",
94+
match_on="match",
95+
match_mode="starts_with",
96+
case_sensitive=True,
97+
)
98+
)
99+
100+
# When: evaluating the same lower-case path against both
101+
insensitive_result = await insensitive.evaluate("/home/lev/agent-control/server")
102+
sensitive_result = await sensitive.evaluate("/home/lev/agent-control/server")
103+
104+
# Then: only the case-insensitive evaluator matches
105+
assert insensitive_result.matched is True
106+
assert sensitive_result.matched is False
107+
108+
@pytest.mark.asyncio
109+
async def test_starts_with_supports_no_match_allowlists(self) -> None:
110+
"""Test that starts_with works with no_match for allowlist-style controls."""
111+
# Given: a starts_with evaluator configured as an allowlist
112+
evaluator = ListEvaluator(
113+
ListEvaluatorConfig(
114+
values=["/home/lev/agent-control", "/tmp/cache"],
115+
logic="any",
116+
match_on="no_match",
117+
match_mode="starts_with",
118+
case_sensitive=True,
119+
)
120+
)
121+
122+
# When: evaluating one allowed and one disallowed path
123+
allowed_result = await evaluator.evaluate("/home/lev/agent-control/server")
124+
denied_result = await evaluator.evaluate("/var/log/system.log")
125+
126+
# Then: only the disallowed path triggers the control
127+
assert allowed_result.matched is False
128+
assert denied_result.matched is True
129+
36130
@pytest.mark.asyncio
37131
async def test_legacy_empty_string_value_is_ignored_defensively(self) -> None:
38132
"""Test that legacy invalid configs do not compile into a match-all regex."""

‎ui/src/core/evaluators/list/form.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,14 @@ export const ListForm = ({ form }: EvaluatorFormProps<ListFormValues>) => {
7575
label={
7676
<LabelWithTooltip
7777
label="Match mode"
78-
tooltip="Exact string match or substring (contains)"
78+
tooltip="Exact string match, keyword contains, or string prefix matching"
7979
/>
8080
}
8181
labelProps={labelPropsInline}
8282
data={[
8383
{ value: 'exact', label: 'Exact (full string match)' },
84-
{ value: 'contains', label: 'Contains (substring match)' },
84+
{ value: 'contains', label: 'Contains (keyword match)' },
85+
{ value: 'starts_with', label: 'Starts with (prefix match)' },
8586
]}
8687
size="sm"
8788
{...form.getInputProps('match_mode')}

‎ui/src/core/evaluators/list/types.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ export type ListFormValues = {
99
logic: 'any' | 'all';
1010
/** When to trigger: on match or no match */
1111
match_on: 'match' | 'no_match';
12-
/** Match mode: exact or contains */
13-
match_mode: 'exact' | 'contains';
12+
/** Match mode: exact, contains, or starts_with */
13+
match_mode: 'exact' | 'contains' | 'starts_with';
1414
/** Whether matching is case sensitive */
1515
case_sensitive: boolean;
1616
};

0 commit comments

Comments
 (0)