Skip to content
Open
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
6 changes: 4 additions & 2 deletions docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@
"group": "Use case examples",
"pages": [
"examples/customer-support",
"examples/steer-action-demo"
"examples/steer-action-demo",
"examples/attribute-based-controls"
]
}
]
Expand Down Expand Up @@ -170,7 +171,8 @@
"group": "Use case examples",
"pages": [
"examples/customer-support",
"examples/steer-action-demo"
"examples/steer-action-demo",
"examples/attribute-based-controls"
]
}
]
Expand Down
247 changes: 247 additions & 0 deletions examples/attribute-based-controls.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
---
title: Attribute-Based Controls
description: Apply rules only for some values of a request attribute, such as the country. Author them as one control per value or as one composite control.
---

Some rules apply only when a request attribute has a given value. The attribute can be the user's country, tenant, user tier or channel. Agent Control expresses this as a condition on the attribute, combined with the rule itself.

This example uses the country (jurisdiction) as the attribute. An assistant must not return a German IBAN to a user in Germany, or a US Social Security number to a user in the US. To use another attribute, replace the jurisdiction field and its values.

## What This Demonstrates

- Attribute-based rules: each rule applies only when the request's jurisdiction matches.
- Two ways to author the same policy:
- One control per attribute value, here one per country.
- One composite control with a branch for each value.
- Baseline controls that apply to every country, next to the country controls.
- Two ways to send the jurisdiction with each check:
- In `step.context`, with `evaluate_controls()` or the HTTP API.
- As a function argument, with the `@control()` decorator.

No LLM or API key is needed. The scripts use fixed text in place of a model answer.

## Quick Start

### Prerequisites

1. Start the Agent Control server: `make server-run` from the repo root.

### Run the Demo

```bash
# From the agent-control repo root
uv run python examples/attribute_controls/attribute_controls_context.py
uv run python examples/attribute_controls/attribute_controls_decorator.py
```

Each script creates its agent and controls, then runs the scenarios. It prints PASS or FAIL for each check, and exits non-zero if any check fails. The scripts are safe to re-run.

## Send the Jurisdiction

The caller must send the jurisdiction with each check. Use an ISO 3166-1 alpha-2 code, such as `DE` or `US`.

If the country code arrives in a request header, such as `x-ai-jurisdiction: DE`, the caller copies it into `step.context.jurisdiction`. Agent Control does not map request headers into the step.

### In `step.context` (recommended)

`context` holds request metadata, separate from the data that the controls check. Controls select the value with the path `context.jurisdiction`.

```python
result = await agent_control.evaluate_controls(
"support-answer",
input="What is on file for me?",
output=answer_text,
context={"jurisdiction": "DE"},
step_type="llm",
stage="post",
agent_name=AGENT_NAME,
)
if not result.is_safe:
... # blocked
```

Gateways and non-Python clients send the same field in the body of `POST /api/v1/evaluation`:

```json
{
"agent_name": "my-agent",
"stage": "post",
"step": {
"type": "llm",
"name": "support-answer",
"input": "What is on file for me?",
"output": "Your refund goes to DE89 3704 0044 0532 0130 00.",
"context": {"jurisdiction": "DE"}
}
}
```

### As a decorator argument

`@control()` builds the step from the function's arguments, and it cannot set `step.context`. To use the decorator, pass the jurisdiction as a function argument and select it with `input.jurisdiction`.

```python
@control(step_name="support-answer", step_type="tool")
async def support_answer(*, text: str, jurisdiction: str) -> str:
return text
```

<Note>
This works only for `tool` steps and custom step types, where `input` is the dict of all arguments. For an `llm` step, `input` is a single string, so the path `input.jurisdiction` does not resolve.
</Note>

<Warning>
The controls below use the `context` path. For the decorator path, make two changes in each control, or the controls never match and every request is allowed:

- Set `"step_types"` to `["tool"]`.
- Set the jurisdiction selector `path` to `"input.jurisdiction"`.
</Warning>

## Author the Controls

Each country rule is an `and` of two conditions:

- The jurisdiction is in a list, checked with the `list` evaluator.
- The rule matches, here a `regex` on the output.

### Option 1: One control per country

```python
de_iban = {
"execution": "server",
"scope": {"step_types": ["llm"], "stages": ["post"]},
"condition": {
"and": [
{
"selector": {"path": "context.jurisdiction"},
"evaluator": {"name": "list", "config": {"values": ["DE"]}},
},
{
"selector": {"path": "output"},
"evaluator": {
"name": "regex",
"config": {"pattern": r"\bDE\d{2}(?: ?\d{4}){4} ?\d{2}\b"},
},
},
]
},
"action": {"decision": "deny"},
}
```

Write a similar control for each country, for example `US` with a Social Security number pattern.

### Option 2: One composite control

Put each country's `and` branch inside one `or`:

```python
countries = {
"execution": "server",
"scope": {"step_types": ["llm"], "stages": ["post"]},
"condition": {
"or": [
{"and": [jurisdiction_in(["DE"]), output_matches(DE_IBAN)]},
{"and": [jurisdiction_in(["US"]), output_matches(US_SSN)]},
]
},
"action": {"decision": "deny"},
}
```

`jurisdiction_in()` and `output_matches()` are small helpers in the example scripts. They return the leaf conditions shown in Option 1.

### Which option to use

| | One control per country | One composite control |
|---|---|---|
| Number of controls | One per country | One |
| Match result | Names the country's control | Names the composite control |
| Change one country | Edit or disable only that control | Edit the shared condition |

## Baseline and Country Controls

Most policies combine two kinds of rules:

- **Baseline controls** apply to every country. They have no jurisdiction condition.
- **Country controls** apply only to some countries. They include the jurisdiction condition.

Keep them as separate controls. A baseline control is a plain control:

```python
baseline_email = {
"execution": "server",
"scope": {"step_types": ["llm"], "stages": ["post"]},
"condition": {
"selector": {"path": "output"},
"evaluator": {
"name": "regex",
"config": {"pattern": r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b"},
},
},
"action": {"decision": "deny"},
}
```

The baseline and the country controls run together on each check. A baseline control also applies when the request has no jurisdiction.

<Note>
Agent Control evaluates the controls in parallel and stops the remaining evaluations after the first `deny` match. If a baseline control and a country control both match, `result.matches` usually names only one of them.
</Note>

To tell the two kinds apart, use a naming convention or `tags`, for example `["baseline"]` and `["country", "DE"]`.

## Scenarios

Both authoring options give the same decisions. The example runs them side by side, with the baseline control on both:

| Scenario | Expected | Matched by |
|---|---|---|
| DE + IBAN | Blocked | DE country rule |
| US + IBAN (no US rule) | Allowed | - |
| US + SSN | Blocked | US country rule |
| DE + SSN (no DE rule) | Allowed | - |
| US + clean text | Allowed | - |
| DE + email | Blocked | Baseline |
| No jurisdiction + IBAN | Allowed | - (country rules are skipped) |
| No jurisdiction + email | Blocked | Baseline |

## Missing Jurisdiction

<Warning>
If a request has no jurisdiction, no country rule matches. Only baseline controls apply, so the request is allowed unless a baseline control matches. Agent Control has no built-in default jurisdiction.
</Warning>

If you need a strict default, add a control that matches when the jurisdiction is missing or not covered:

```python
strict_default = {
"execution": "server",
"scope": {"step_types": ["llm"], "stages": ["post"]},
"condition": {
"and": [
{"not": jurisdiction_in(["DE", "US"])},
{"or": [output_matches(DE_IBAN), output_matches(US_SSN)]},
]
},
"action": {"decision": "deny"},
}
```

Keep this control in sync with the country controls:

- The `not` list must name every country that has a rule.
- The `or` must repeat every country rule.

For example, if you add an `FR` control but do not add its rule to the `or`, a request with no jurisdiction that matches the `FR` rule is allowed.

## Files

- `attribute_controls_context.py` - Sends the jurisdiction in `step.context` with `evaluate_controls()`
- `attribute_controls_decorator.py` - Sends the jurisdiction as a `@control()` function argument

## Source Code

View the complete example with all scripts and setup instructions:

[`Attribute-Based Controls Example`](https://github.com/agentcontrol/agent-control/tree/main/examples/attribute_controls)
4 changes: 4 additions & 0 deletions examples/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,9 @@ Browse working examples that show how to add Agent Control to different framewor
<Card title="Customer Support Agent" icon="headset" href="/examples/customer-support">
Realistic enterprise scenario with PII protection, prompt-injection defense, and multiple tool types.
</Card>

<Card title="Attribute-Based Controls" icon="globe" href="/examples/attribute-based-controls">
Apply rules only for some attribute values, such as the country, as one control per value or as one composite control.
</Card>

</CardGroup>
10 changes: 9 additions & 1 deletion sdk/python-sdk.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,13 @@ When background refresh is enabled, the SDK refreshes control snapshots via `GET
#### `@control()`

```python
def control(policy: str | None = None, step_name: str | None = None):
def control(
policy: str | None = None,
step_name: str | None = None,
step_type: str | None = None,
*,
tools=None,
):
```

Decorator to protect a function with server-defined controls.
Expand All @@ -191,6 +197,8 @@ Decorator to protect a function with server-defined controls.

- `policy`: Optional policy label for code readability when multiple policies exist
- `step_name`: Optional step name override for the registered step
- `step_type`: Optional step type override, for example `"tool"`. If not set, tool-like functions are `tool` steps and other functions are `llm` steps
- `tools`: Optional tool definitions for `llm` steps, as a static list or a provider called at each check

**Example:**

Expand Down
Loading