feat(server)!: implement recursive control condition trees#115
feat(server)!: implement recursive control condition trees#115
Conversation
…9-condition-trees # Conflicts: # ui/src/core/page-components/agent-detail/modals/edit-control/edit-control-content.tsx
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
flowchart TD
C["condition (root)"] --> A["and"]
C --> O["or"]
C --> N["not"]
A --> L1["leaf: selector + evaluator"]
A --> L2["leaf: selector + evaluator"]
O --> L3["leaf: selector + evaluator"]
O --> A2["and"]
A2 --> L4["leaf: selector + evaluator"]
A2 --> L5["leaf: selector + evaluator"]
N --> L6["leaf: selector + evaluator"]
|
| conditionEditingMessage: string | null; | ||
| }; | ||
|
|
||
| function getLeafConditionDetails( |
There was a problem hiding this comment.
Problem: This logic treats a condition as editable only when it is a top-level leaf (selector + evaluator), so any composite tree is considered unsupported by the editor.
Proposed change: Replace leaf-only detection with recursive tree-aware state handling (ConditionNode), so composite nodes are editable, not downgraded to read-only behavior.
There was a problem hiding this comment.
import type { ConditionNode, ControlDefinition } from '@/core/api/types';
import type { AnyEvaluatorDefinition } from '@/core/evaluators';
import { getEvaluator } from '@/core/evaluators';
export type NodeKind = 'leaf' | 'and' | 'or' | 'not';
export type LeafConditionDetails = {
selectorPath: string;
evaluatorName: string;
evaluatorConfig: Record<string, unknown>;
};
export type ControlConditionState = {
leafCondition: LeafConditionDetails | null;
evaluatorId: string;
evaluator: AnyEvaluatorDefinition | undefined;
canEditLeafCondition: boolean;
conditionEditingMessage: string | null;
};
export function getNodeKind(node: ConditionNode): NodeKind {
if (node.selector && node.evaluator) return 'leaf';
if (node.and) return 'and';
if (node.or) return 'or';
return 'not';
}
export function isLeafNode(node: ConditionNode): boolean {
return getNodeKind(node) === 'leaf';
}
export function getChildren(node: ConditionNode): ConditionNode[] {
if (node.and) return node.and;
if (node.or) return node.or;
if (node.not) return [node.not];
return [];
}
function findFirstLeaf(node: ConditionNode): ConditionNode | null {
if (isLeafNode(node)) return node;
for (const child of getChildren(node)) {
const found = findFirstLeaf(child);
if (found) return found;
}
return null;
}
/**
* Recursive/tree-aware replacement for old top-level leaf detection.
* Old: only editable if condition.selector + condition.evaluator existed at root.
* New: supports composite trees by finding leaf context recursively.
*/
function getLeafConditionDetails(condition: ConditionNode): LeafConditionDetails | null {
const leaf = findFirstLeaf(condition);
if (!leaf?.selector || !leaf.evaluator) return null;
return {
selectorPath: leaf.selector.path ?? '*',
evaluatorName: leaf.evaluator.name,
evaluatorConfig: leaf.evaluator.config,
};
}
export function getControlConditionState(
definition: ControlDefinition,
conditionOverride?: ConditionNode
): ControlConditionState {
const condition = conditionOverride ?? definition.condition;
const leafCondition = getLeafConditionDetails(condition);
const evaluatorId = leafCondition?.evaluatorName ?? '';
const evaluator = getEvaluator(evaluatorId);
return {
leafCondition,
evaluatorId,
evaluator,
canEditLeafCondition: Boolean(leafCondition && evaluator),
conditionEditingMessage: null,
};
}
This removes top-level leaf-only gating and makes condition parsing tree-aware, which is required to enable composite condition editing.
There was a problem hiding this comment.
Intentional for this PR. The UI work here is limited to keeping the existing single-condition/leaf editor working against the new condition contract. Composite trees are preserved on save, but not editable in the modal in this PR. I just updated the PR description to call that out explicitly.
| selectorPath: string, | ||
| finalConfig: Record<string, unknown> | ||
| ): ControlDefinition['condition'] { | ||
| if (!leafCondition) { |
There was a problem hiding this comment.
Problem: When no leaf is detected, save behavior just returns the original definition.condition, which preserves composite trees but prevents any UI-driven updates to them.
Proposed change: Use editable tree state as the source of truth for condition and apply explicit tree actions (add/remove/convert/reorder). Keep the existing evaluator form only as a specialized editor when the current node is a leaf.
There was a problem hiding this comment.
import type { ConditionNode, ControlDefinition } from '@/core/api/types';
import type { AnyEvaluatorDefinition } from '@/core/evaluators';
import { getEvaluator } from '@/core/evaluators';
export type NodeKind = 'leaf' | 'and' | 'or' | 'not';
export type NodePath = number[];
export type LeafConditionDetails = {
selectorPath: string;
evaluatorName: string;
evaluatorConfig: Record<string, unknown>;
path: NodePath;
};
export type ControlConditionState = {
leafCondition: LeafConditionDetails | null;
evaluatorId: string;
evaluator: AnyEvaluatorDefinition | undefined;
canEditLeafCondition: boolean;
conditionEditingMessage: string | null;
};
const DEFAULT_LEAF: ConditionNode = {
selector: { path: '*' },
evaluator: { name: 'regex', config: { pattern: '.+' } },
};
export function getNodeKind(node: ConditionNode): NodeKind {
if (node.selector && node.evaluator) return 'leaf';
if (node.and) return 'and';
if (node.or) return 'or';
return 'not';
}
export function isLeafNode(node: ConditionNode): boolean {
return getNodeKind(node) === 'leaf';
}
export function getChildren(node: ConditionNode): ConditionNode[] {
if (node.and) return node.and;
if (node.or) return node.or;
if (node.not) return [node.not];
return [];
}
function withChildren(node: ConditionNode, children: ConditionNode[]): ConditionNode {
const kind = getNodeKind(node);
if (kind === 'and') return { and: children };
if (kind === 'or') return { or: children };
if (kind === 'not') return { not: children[0] ?? DEFAULT_LEAF };
return node;
}
export function getNodeAtPath(root: ConditionNode, path: NodePath): ConditionNode {
let current = root;
for (const idx of path) {
current = getChildren(current)[idx];
}
return current;
}
export function updateNodeAtPath(
root: ConditionNode,
path: NodePath,
updater: (node: ConditionNode) => ConditionNode
): ConditionNode {
if (path.length === 0) return updater(root);
const [head, ...rest] = path;
const children = getChildren(root);
return withChildren(
root,
children.map((child, i) =>
i === head ? updateNodeAtPath(child, rest, updater) : child
)
);
}
function findFirstLeaf(
node: ConditionNode,
path: NodePath = []
): { node: ConditionNode; path: NodePath } | null {
if (isLeafNode(node)) return { node, path };
const children = getChildren(node);
for (let i = 0; i < children.length; i += 1) {
const found = findFirstLeaf(children[i], [...path, i]);
if (found) return found;
}
return null;
}
export function addChild(root: ConditionNode, path: NodePath, index?: number): ConditionNode {
return updateNodeAtPath(root, path, (node) => {
const kind = getNodeKind(node);
if (kind === 'leaf') return node;
if (kind === 'not') return { not: DEFAULT_LEAF };
const children = [...getChildren(node)];
const insertAt = typeof index === 'number' ? index : children.length;
children.splice(insertAt, 0, DEFAULT_LEAF);
return withChildren(node, children);
});
}
export function removeNode(root: ConditionNode, path: NodePath): ConditionNode {
if (path.length === 0) return DEFAULT_LEAF;
const parentPath = path.slice(0, -1);
const index = path[path.length - 1];
return updateNodeAtPath(root, parentPath, (parent) => {
const kind = getNodeKind(parent);
if (kind === 'not') return DEFAULT_LEAF;
const next = getChildren(parent).filter((_, i) => i !== index);
if (next.length === 0) return DEFAULT_LEAF;
if (next.length === 1 && (kind === 'and' || kind === 'or')) return next[0];
return withChildren(parent, next);
});
}
export function convertNodeType(node: ConditionNode, target: NodeKind): ConditionNode {
const current = getNodeKind(node);
if (current === target) return node;
if (target === 'leaf') {
const found = findFirstLeaf(node);
return found?.node ?? DEFAULT_LEAF;
}
if (target === 'not') {
const children = getChildren(node);
return { not: children[0] ?? DEFAULT_LEAF };
}
if (target === 'and') {
const children = getChildren(node);
return { and: children.length > 0 ? children : [DEFAULT_LEAF] };
}
const children = getChildren(node);
return { or: children.length > 0 ? children : [DEFAULT_LEAF] };
}
export function reorderChild(
root: ConditionNode,
parentPath: NodePath,
from: number,
to: number
): ConditionNode {
return updateNodeAtPath(root, parentPath, (parent) => {
const kind = getNodeKind(parent);
if (kind !== 'and' && kind !== 'or') return parent;
const children = [...getChildren(parent)];
const [moved] = children.splice(from, 1);
if (!moved) return parent;
children.splice(to, 0, moved);
return withChildren(parent, children);
});
}
function getLeafConditionDetails(condition: ConditionNode): LeafConditionDetails | null {
const found = findFirstLeaf(condition);
if (!found?.node.selector || !found.node.evaluator) return null;
return {
selectorPath: found.node.selector.path ?? '*',
evaluatorName: found.node.evaluator.name,
evaluatorConfig: found.node.evaluator.config,
path: found.path,
};
}
export function getControlConditionState(
definition: ControlDefinition,
conditionOverride?: ConditionNode
): ControlConditionState {
const condition = conditionOverride ?? definition.condition;
const leafCondition = getLeafConditionDetails(condition);
const evaluatorId = leafCondition?.evaluatorName ?? '';
const evaluator = getEvaluator(evaluatorId);
return {
leafCondition,
evaluatorId,
evaluator,
canEditLeafCondition: Boolean(leafCondition && evaluator),
conditionEditingMessage: null,
};
}
export function buildEditableCondition(
conditionTree: ConditionNode,
leafCondition: LeafConditionDetails | null,
selectorPath: string,
finalConfig: Record<string, unknown>
): ControlDefinition['condition'] {
if (!leafCondition) return conditionTree;
// Source of truth is the editable tree; only patch the selected leaf details.
return updateNodeAtPath(conditionTree, leafCondition.path, (node) => ({
...node,
selector: { path: selectorPath },
evaluator: {
name: leafCondition.evaluatorName,
config: finalConfig,
},
}));
}
There was a problem hiding this comment.
Agreed on what full support would require. We are intentionally not taking tree-state editing in this PR; the modal stays leaf-oriented and preserves composite conditions on save. I updated the PR description so that limitation is explicit.
|
|
||
| export type ControlDefinitionFormProps = { | ||
| form: UseFormReturnType<ControlDefinitionFormValues>; | ||
| disableSelectorPath?: boolean; |
There was a problem hiding this comment.
This is for line 537
Problem: The UI shows “Condition editing unavailable” for non-leaf conditions, blocking users from creating/updating composite logic in the modal.
Proposed change: Replace this alert-only path with a recursive ConditionTreeEditor component that supports full tree editing (and/or/not/leaf), while preserving current single-leaf UX via EvaluatorConfigSection for leaf nodes.
import { ConditionTreeEditor } from './condition-tree-editor';
import { isLeafNode } from './control-condition';
// ...
const [conditionTree, setConditionTree] =
useState<ControlDefinition['condition']>(control.control.condition);
useEffect(() => {
setConditionTree(control.control.condition);
}, [control.id, control.control.condition]);
// ...
<Grid.Col span={8}>
{isLeafNode(conditionTree) && canEditLeafCondition ? (
<EvaluatorConfigSection
config={evaluatorConfig}
evaluatorForm={evaluatorForm}
formComponent={formComponent}
height={EVALUATOR_CONFIG_HEIGHT}
onConfigChange={syncJsonToForm}
onValidateConfig={validateEvaluatorConfig}
/>
) : (
<ConditionTreeEditor
value={conditionTree}
onChange={setConditionTree}
onAddChild={(path) =>
setConditionTree((prev) => addChild(prev, path))
}
onRemoveNode={(path) =>
setConditionTree((prev) => removeNode(prev, path))
}
onConvertNode={(path, kind) =>
setConditionTree((prev) =>
updateNodeAtPath(prev, path, (node) => convertNodeType(node, kind))
)
}
onReorderChild={(parentPath, from, to) =>
setConditionTree((prev) =>
reorderChild(prev, parentPath, from, to)
)
}
/>
)}
</Grid.Col>
And in save path, conditionTree becomes the source of truth:
condition: buildEditableCondition(
conditionTree,
leafCondition,
values.selector_path.trim(),
finalConfig
),
So leaf controls still use EvaluatorConfigSection, while composite controls render full recursive tree editing.
There was a problem hiding this comment.
Same scope note here: we are not introducing a recursive ConditionTreeEditor in this PR. The goal for this UI pass is to keep the existing leaf editor working and make the composite-tree limitation explicit rather than pretending the modal can edit it.
| _trace_warning_logged = False | ||
|
|
||
|
|
||
| def _primary_leaf_details( |
There was a problem hiding this comment.
Problem: _primary_leaf_details() returns (None, None) for composite conditions.
Proposed solution: replace with tree-wide identity helper matching server behavior.
def _control_observability_identity(
control_def: ControlDefinition,
) -> tuple[str | None, str | None, int, list[str], list[str]]:
all_evaluators: list[str] = []
all_selector_paths: list[str] = []
seen_evaluators: set[str] = set()
seen_selectors: set[str] = set()
leaf_count = 0
for selector, evaluator in control_def.iter_condition_leaf_parts():
leaf_count += 1
selector_path = selector.path or "*"
if evaluator.name not in seen_evaluators:
seen_evaluators.add(evaluator.name)
all_evaluators.append(evaluator.name)
if selector_path not in seen_selectors:
seen_selectors.add(selector_path)
all_selector_paths.append(selector_path)
primary_evaluator = all_evaluators[0] if all_evaluators else None
primary_selector_path = all_selector_paths[0] if all_selector_paths else None
return primary_selector_path, primary_evaluator, leaf_count, all_evaluators, all_selector_paths
There was a problem hiding this comment.
Good catch. This one is not an intentional scope cut like the UI tree editor. I updated the control observability identity to derive a deterministic representative from condition traversal order, so composite controls now populate top-level selector/evaluator dimensions instead of dropping them to null.
| return | ||
| for match in matches: | ||
| ctrl = control_lookup.get(match.control_id) | ||
| selector_path, evaluator_name = ( |
There was a problem hiding this comment.
Problem: event emission only sets selector/evaluator for single-leaf controls.
Proposed solution: use helper output for all controls and enrich metadata.
selector_path, evaluator_name, leaf_count, all_evaluators, all_selector_paths = (
_control_observability_identity(ctrl.control) if ctrl else (None, None, 0, [], [])
)
event_metadata = dict(match.result.metadata or {})
event_metadata.update(
{
"primary_evaluator": evaluator_name,
"primary_selector_path": selector_path,
"leaf_count": leaf_count,
"all_evaluators": all_evaluators,
"all_selector_paths": all_selector_paths,
}
)
add_event(
ControlExecutionEvent(
...
evaluator_name=evaluator_name,
selector_path=selector_path,
metadata=event_metadata,
)
)
There was a problem hiding this comment.
Agreed. I updated event emission to keep the top-level selector/evaluator fields populated for composite controls and to enrich metadata with primary_*, leaf_count, all_evaluators, and all_selector_paths. I also added coverage for the composite case.
| referencing_controls: list[tuple[str, str]] = [] # (control_name, evaluator) | ||
|
|
||
| for ctrl in controls: | ||
| evaluator_ref = ctrl.control.evaluator.name |
There was a problem hiding this comment.
Problem: Appending per-leaf references creates duplicates for same control/evaluator pair.
Proposed Solution: Use a set for unique references while scanning leaves.
# before loop
referencing_control_set: set[tuple[str, str]] = set()
for ctrl in controls:
for _, evaluator_spec in ctrl.control.iter_condition_leaf_parts():
evaluator_ref = evaluator_spec.name
if ":" not in evaluator_ref:
continue
ref_agent, ref_eval = evaluator_ref.split(":", 1)
if ref_agent == agent.name and ref_eval in remove_evaluator_set:
referencing_control_set.add((ctrl.name, ref_eval))
There was a problem hiding this comment.
Agreed. I changed the dependency scan to dedupe control/evaluator references before building the conflict response so composite controls with multiple matching leaves do not emit duplicate in_use errors.
| # Check if this control references an evaluator we're removing | ||
| # AND it's scoped to this agent (by name match) | ||
| if ref_agent == agent.name and ref_eval in remove_evaluator_set: | ||
| referencing_controls.append((ctrl.name, ref_eval)) |
There was a problem hiding this comment.
Problem: referencing_controls.append(...) duplicates entries.
Proposed Solution: Replace append with set insert and sort once before error response.
# remove append line and do:
referencing_control_set.add((ctrl.name, ref_eval))
# then before ConflictError:
referencing_controls = sorted(referencing_control_set, key=lambda x: (x[0], x[1]))
There was a problem hiding this comment.
Fixed in the same change: the conflict payload is now built from a sorted unique set of (control, evaluator) pairs for deterministic output without duplicates.
| detail="Cannot remove evaluators: active controls reference them", | ||
| resource="Evaluator", | ||
| hint="Remove or update the controls that reference these evaluators first.", | ||
| errors=[ |
There was a problem hiding this comment.
Problem: Error list is built from duplicated tuples.
Proposed Solution: Build errors from sorted unique list.
errors=[
ValidationErrorItem(
resource="Control",
field="evaluator.name",
code="in_use",
message=f"Control '{ctrl}' uses evaluator '{ev}'",
)
for ctrl, ev in referencing_controls # already deduped + sorted
]
There was a problem hiding this comment.
Also addressed in the same fix. The validation errors now come from the deduped, sorted control/evaluator list instead of the per-leaf append path.
| tags: list[str] = Field(default_factory=list, description="Tags for categorization") | ||
|
|
||
| @classmethod | ||
| def canonicalize_payload(cls, data: Any) -> Any: |
There was a problem hiding this comment.
Problem: canonicalize_payload enables runtime legacy flat payload acceptance.
Proposed Solution (strict major wire break): Remove legacy canonicalization path and fail validation if selector/evaluator provided at top level.
@classmethod
def canonicalize_payload(cls, data: Any) -> Any:
if not isinstance(data, dict):
return data
has_condition = "condition" in data
has_selector = "selector" in data
has_evaluator = "evaluator" in data
if has_selector or has_evaluator:
raise ValueError(
"Legacy selector/evaluator fields are no longer supported. "
"Use top-level 'condition' only."
)
if not has_condition:
raise ValueError("Control definition must include top-level 'condition'.")
return data
There was a problem hiding this comment.
We are intentionally keeping the canonicalization path during rollout. The current implementation and tests rely on that compatibility for legacy flat payloads, so I do not want to turn this PR into strict runtime rejection. I updated the PR description to make the rollout compatibility explicit.
|
|
||
| @model_validator(mode="before") | ||
| @classmethod | ||
| def canonicalize_legacy_condition_shape(cls, data: Any) -> Any: |
There was a problem hiding this comment.
"""
Compatibility mode: accepts legacy flat selector/evaluator payloads during rollout.
Remove in next major once migration window closes.
"""
There was a problem hiding this comment.
And add matching note in PR scope/README/changelog:
Runtime compatibility note: server/models still canonicalize legacy flat control payloads
(selector + evaluator) into condition during rollout; strict rejection is deferred.
There was a problem hiding this comment.
Agreed. I updated the wording in the PR description to explicitly call this compatibility mode out instead of implying strict runtime rejection.
There was a problem hiding this comment.
Agreed. I updated the PR scope text to note that legacy flat payloads are still canonicalized during rollout and that strict rejection is deferred.
|
One suggestion from Observability perspective: |
|
Following up on the broader observability note: I aligned server and SDK event emission so composite conditions use a deterministic representative leaf for top-level |
corresponding docs update: agentcontrol/docs-agent-control#28
Summary
conditiontrees across the shared models, engine, server, SDKs, and UI contractscondition_tracemetadata for composite evaluationScope
ControlDefinitionnow uses a top-levelconditiontree instead of top-levelselector/evaluatorand/or/notcomposition with a hard API depth limit of6/api/v1stays the same, but this is a major-version wire break for the canonical contract and old SDK majors are unsupported against the new servermake server-migrate-control-conditions MIGRATE_ARGS=--dry-runandmake server-migrate-control-conditions MIGRATE_ARGS=--applymetadata.condition_traceselector+evaluator) intoconditionduring rolloutRisk and Rollout
Testing
make checkmake checkpassed, which covered:make server-testmake engine-testmake sdk-testmake evaluators-testNotes:
sqlglot[rs]deprecation, Strands resource/deprecation warnings), but the full target passed cleanlyChecklist