Skip to content
Merged
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
11 changes: 2 additions & 9 deletions examples/agent_control_demo/setup_controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,10 @@ async def create_control(
) -> int:
"""Create a control with the given definition."""
try:
# Step 1: Create the control (just the name)
# Create and validate the control atomically.
response = await client.http_client.put(
"/api/v1/controls",
json={"name": name}
json={"name": name, "data": control_definition},
)

if response.status_code == 409:
Expand All @@ -93,13 +93,6 @@ async def create_control(
response.raise_for_status()
control_id = response.json().get("control_id")

# Step 2: Set the control data
response = await client.http_client.put(
f"/api/v1/controls/{control_id}/data",
json={"data": control_definition}
)
response.raise_for_status()

print(f"✓ Created control '{name}' with ID: {control_id}")
return control_id

Expand Down
26 changes: 16 additions & 10 deletions examples/deepeval/setup_controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,32 +193,38 @@ async def setup_demo(quiet: bool = False):
description = control_spec["description"]

try:
# Create control
# Create and validate the control atomically.
resp = await client.put(
"/api/v1/controls",
json={"name": control_name},
json={"name": control_name, "data": definition},
)
if resp.status_code == 409:
control_exists = resp.status_code == 409
if control_exists:
# Control exists, get its ID
resp = await client.get("/api/v1/controls", params={"name": control_name})
resp.raise_for_status()
controls = resp.json().get("controls", [])
controls = [
control
for control in resp.json().get("controls", [])
if control.get("name") == control_name
]
if controls:
control_id = controls[0]["id"]
controls_updated += 1
else:
print(f" ❌ Could not find exact control match for '{control_name}'")
continue
else:
resp.raise_for_status()
control_id = resp.json()["control_id"]
controls_created += 1

# Set control definition
resp = await client.put(
f"/api/v1/controls/{control_id}/data",
json={"data": definition},
)
resp.raise_for_status()
if control_exists:
resp = await client.put(
f"/api/v1/controls/{control_id}/data",
json={"data": definition},
)
resp.raise_for_status()

# Associate control directly with the agent
resp = await client.post(f"/api/v1/agents/{agent_name}/controls/{control_id}")
Expand Down
4 changes: 4 additions & 0 deletions models/src/agent_control_models/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ class CreateControlRequest(BaseModel):
...,
description="Unique control name (letters, numbers, hyphens, underscores)",
)
data: ControlDefinition = Field(
...,
description="Control definition to validate and store during creation",
)


class InitAgentRequest(BaseModel):
Expand Down
22 changes: 20 additions & 2 deletions sdks/python/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ async with agent_control.AgentControlClient() as client:
- `GET /api/v1/controls/{control_id}/rules` - List control rules

**Functions**:
- `async def create_control(client, name)` - Create a new control
- `async def create_control(client, name, data)` - Create a new configured control
- `async def add_rule_to_control(client, control_id, rule_id)` - Associate rule
- `async def remove_rule_from_control(client, control_id, rule_id)` - Dissociate rule
- `async def list_control_rules(client, control_id)` - List all rules in control
Expand All @@ -124,7 +124,25 @@ import agent_control

async with agent_control.AgentControlClient() as client:
# Create control
result = await agent_control.controls.create_control(client, "pii-protection")
result = await agent_control.controls.create_control(
client,
"pii-protection",
{
"description": "PII protection",
"enabled": True,
"execution": "server",
"scope": {"step_types": ["llm"], "stages": ["post"]},
"condition": {
"selector": {"path": "output"},
"evaluator": {
"name": "regex",
"config": {"pattern": "\\\\d{3}-\\\\d{2}-\\\\d{4}", "flags": []},
},
},
"action": {"decision": "deny"},
"tags": ["security"],
},
)
control_id = result["control_id"]

# Add rule to control
Expand Down
14 changes: 4 additions & 10 deletions sdks/python/src/agent_control/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -935,26 +935,23 @@ async def main():

async def create_control(
name: str,
data: dict[str, Any] | ControlDefinition | None = None,
data: dict[str, Any] | ControlDefinition,
server_url: str | None = None,
api_key: str | None = None,
) -> dict[str, Any]:
"""
Create a new control, optionally with configuration.

If `data` is provided, the control is created and configured in one call.
Otherwise, use `agent_control.controls.set_control_data()` to configure it later.
Create a new control with configuration.

Args:
name: Unique name for the control
data: Optional control definition with a condition tree, action, scope, etc.
data: Control definition with a condition tree, action, scope, etc.
server_url: Optional server URL (defaults to AGENT_CONTROL_URL env var)
api_key: Optional API key for authentication (defaults to AGENT_CONTROL_API_KEY env var)

Returns:
Dictionary containing:
- control_id: ID of the created control
- configured: True if data was set, False if only name was created
- configured: Always True because create requires data

Raises:
httpx.HTTPError: If request fails
Expand Down Expand Up @@ -984,9 +981,6 @@ async def main():
)
print(f"Created control {result['control_id']}")

# Or create without config (configure later)
result = await agent_control.create_control(name="my-control")

asyncio.run(main())
"""
_final_server_url = server_url or os.getenv('AGENT_CONTROL_URL') or 'http://localhost:8000'
Expand Down
3 changes: 1 addition & 2 deletions sdks/python/src/agent_control/control_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -793,8 +793,7 @@ async def handle_user_input(user_message: str) -> str:

Server Setup (separate from agent code):
1. Create controls via API:
PUT /api/v1/controls {"name": "block-toxic-inputs"}
PUT /api/v1/controls/{id}/data {"data": {...}}
PUT /api/v1/controls {"name": "block-toxic-inputs", "data": {...}}

2. Create policy and add controls:
PUT /api/v1/policies {"name": "safety-policy"}
Expand Down
34 changes: 12 additions & 22 deletions sdks/python/src/agent_control/controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,26 +118,23 @@ async def get_control(
async def create_control(
client: AgentControlClient,
name: str,
data: dict[str, Any] | ControlDefinition | None = None,
data: dict[str, Any] | ControlDefinition,
) -> dict[str, Any]:
"""
Create a new control with a unique name, optionally with configuration.

If `data` is provided, the control is created and configured in one call.
Otherwise, use `set_control_data()` to configure it later.
Create a new control with a unique name and configuration.

Control names are canonicalized by the API (leading/trailing whitespace
is trimmed); callers may pass trimmed names for consistency.

Args:
client: AgentControlClient instance
name: Unique name for the control
data: Optional control definition (condition tree, action, scope, etc.)
data: Control definition (condition tree, action, scope, etc.)

Returns:
Dictionary containing:
- control_id: ID of the created control
- configured: True if data was set, False if only name was created
- configured: Always True because create requires data

Raises:
httpx.HTTPError: If request fails
Expand All @@ -147,11 +144,6 @@ async def create_control(

Example:
async with AgentControlClient() as client:
# Create without configuration (configure later)
result = await create_control(client, "pii-protection")
control_id = result["control_id"]

# Or create with configuration in one call
result = await create_control(
client,
name="ssn-blocker",
Expand All @@ -170,22 +162,20 @@ async def create_control(
)
print(f"Created and configured control: {result['control_id']}")
"""
# Step 1: Create the control with name
payload: dict[str, Any] = {"name": name}
if isinstance(data, ControlDefinition):
payload["data"] = data.model_dump(mode="json", exclude_none=True)
else:
payload["data"] = cast(dict[str, Any], data)

response = await client.http_client.put(
"/api/v1/controls",
json={"name": name}
json=payload,
)
response.raise_for_status()
result = cast(dict[str, Any], response.json())

# Step 2: If data provided, configure the control
if data is not None:
control_id = result["control_id"]
await set_control_data(client, control_id, data)
result["configured"] = True
else:
result["configured"] = False

result["configured"] = True
return result


Expand Down
17 changes: 16 additions & 1 deletion sdks/python/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,22 @@ async def test_control(
"""
result = await agent_control.controls.create_control(
client,
f"test-control-{unique_name}"
f"test-control-{unique_name}",
{
"description": "SDK integration test control",
"enabled": True,
"execution": "server",
"scope": {"step_types": ["llm"], "stages": ["pre"]},
"condition": {
"selector": {"path": "input"},
"evaluator": {
"name": "regex",
"config": {"pattern": "test", "flags": []},
},
},
"action": {"decision": "deny"},
"tags": ["sdk-test"],
},
)

yield result
Expand Down
6 changes: 3 additions & 3 deletions sdks/typescript/src/generated/funcs/controls-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,13 @@ import { Result } from "../types/fp.js";
* Create a new control
*
* @remarks
* Create a new control with a unique name and empty data.
* Create a new control with a unique name.
*
* Controls define protection logic and can be added to policies.
* Use the PUT /{control_id}/data endpoint to set control configuration.
* Control data is required and is validated before anything is inserted.
*
* Args:
* request: Control creation request with unique name
* request: Control creation request with unique name and data
* db: Database session (injected)
*
* Returns:
Expand Down
16 changes: 16 additions & 0 deletions sdks/typescript/src/generated/models/create-control-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,22 @@
*/

import * as z from "zod/v4-mini";
import {
ControlDefinitionInput,
ControlDefinitionInput$Outbound,
ControlDefinitionInput$outboundSchema,
} from "./control-definition-input.js";

export type CreateControlRequest = {
/**
* A control definition to evaluate agent interactions.
*
* @remarks
*
* This model contains only the logic and configuration.
* Identity fields (id, name) are managed by the database.
*/
data: ControlDefinitionInput;
/**
* Unique control name (letters, numbers, hyphens, underscores)
*/
Expand All @@ -13,6 +27,7 @@ export type CreateControlRequest = {

/** @internal */
export type CreateControlRequest$Outbound = {
data: ControlDefinitionInput$Outbound;
name: string;
};

Expand All @@ -21,6 +36,7 @@ export const CreateControlRequest$outboundSchema: z.ZodMiniType<
CreateControlRequest$Outbound,
CreateControlRequest
> = z.object({
data: ControlDefinitionInput$outboundSchema,
name: z.string(),
});

Expand Down
6 changes: 3 additions & 3 deletions sdks/typescript/src/generated/sdk/controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,13 @@ export class Controls extends ClientSDK {
* Create a new control
*
* @remarks
* Create a new control with a unique name and empty data.
* Create a new control with a unique name.
*
* Controls define protection logic and can be added to policies.
* Use the PUT /{control_id}/data endpoint to set control configuration.
* Control data is required and is validated before anything is inserted.
*
* Args:
* request: Control creation request with unique name
* request: Control creation request with unique name and data
* db: Database session (injected)
*
* Returns:
Expand Down
39 changes: 39 additions & 0 deletions sdks/typescript/tests/client-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,25 @@ describe("AgentControlClient API wiring", () => {

await client.controls.create({
name: "deny-pii",
data: {
action: {
decision: "deny",
},
condition: {
evaluator: {
name: "regex",
config: { pattern: "pii" },
},
selector: {
path: "input",
},
},
execution: "server",
scope: {
stages: ["pre"],
stepTypes: ["llm"],
},
},
});

expect(fetchMock).toHaveBeenCalledTimes(1);
Expand All @@ -87,6 +106,26 @@ describe("AgentControlClient API wiring", () => {
expect(request.headers.get("content-type")).toContain("application/json");
await expect(request.clone().json()).resolves.toEqual({
name: "deny-pii",
data: {
action: {
decision: "deny",
},
condition: {
evaluator: {
name: "regex",
config: { pattern: "pii" },
},
selector: {
path: "input",
},
},
enabled: true,
execution: "server",
scope: {
stages: ["pre"],
step_types: ["llm"],
},
},
});
});

Expand Down
Loading
Loading