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
4 changes: 2 additions & 2 deletions examples/crewai/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ version = "0.1.0"
description = "CrewAI content moderation example with Agent Control"
requires-python = ">=3.12"
dependencies = [
"agent-control-sdk>=2.1.0,<3.0.0",
"agent-control-sdk>=3.0.0,<4.0.0",
"crewai>=0.80.0",
"crewai-tools>=0.12.0",
"openai>=1.0.0",
Expand All @@ -26,4 +26,4 @@ build-backend = "hatchling.build"
include = [
"*.py",
".env.example",
]
]
126 changes: 123 additions & 3 deletions sdks/python/src/agent_control/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ async def process(input: str) -> str:
import os
from collections.abc import Callable
from datetime import UTC, datetime
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as get_version
from typing import TYPE_CHECKING, Any, Literal, TypeVar
from uuid import UUID

Expand Down Expand Up @@ -177,11 +179,119 @@ def __init__(
_current_agent: Agent | None = None
_control_engine = None
_client: AgentControlClient | None = None
_server_controls: list | None = None
_server_controls: list[dict[str, Any]] | None = None
_server_url: str | None = None
_api_key: str | None = None

F = TypeVar("F", bound=Callable[..., Any])


def get_server_controls() -> list[dict[str, Any]] | None:
"""Get the cached server controls.

Returns the controls that were fetched during init() or the last
refresh_controls() call. Returns None if no controls are cached.

Returns:
List of control dicts or None if not initialized.

Example:
controls = agent_control.get_server_controls()
if controls:
print(f"Loaded {len(controls)} controls")
for c in controls:
print(f" - {c['name']} (execution: {c['control'].get('execution', 'server')})")
"""
return _server_controls


async def refresh_controls_async() -> list[dict[str, Any]] | None:
"""Refresh controls from the server asynchronously.

Fetches the latest controls from the server and updates the cache.
Use this when you've made changes to controls in the UI or API
and want the SDK to pick them up without restarting.

Returns:
List of control dicts or None if fetch failed.

Example:
# After updating a control in the UI
controls = await agent_control.refresh_controls_async()
print(f"Refreshed {len(controls)} controls")
"""
global _server_controls

if _current_agent is None:
raise RuntimeError("Agent not initialized. Call agent_control.init() first.")

if _server_url is None:
raise RuntimeError("Server URL not set. Call agent_control.init() first.")

async with AgentControlClient(base_url=_server_url, api_key=_api_key) as client:
response = await agents.register_agent(
client,
_current_agent,
steps=[]
)
_server_controls = response.get('controls', [])
logger.info("Refreshed %d control(s) from server", len(_server_controls or []))
return _server_controls


def refresh_controls() -> list[dict[str, Any]] | None:
Comment thread
nachiket-galileo marked this conversation as resolved.
"""Refresh controls from the server synchronously.

Fetches the latest controls from the server and updates the cache.
Use this when you've made changes to controls in the UI or API
and want the SDK to pick them up without restarting.

Returns:
List of control dicts or None if fetch failed.

Example:
# After updating a control in the UI
controls = agent_control.refresh_controls()
print(f"Refreshed {len(controls)} controls")
"""
import asyncio

try:
loop = asyncio.get_running_loop()
# We're in an async context - run in thread
import threading

result_container: list[list[dict[str, Any]] | None] = [None]
exception_container: list[Exception | None] = [None]

def run_in_thread() -> None:
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try:
result_container[0] = new_loop.run_until_complete(refresh_controls_async())
except Exception as e:
exception_container[0] = e
finally:
new_loop.close()

thread = threading.Thread(target=run_in_thread)
thread.start()
thread.join(timeout=10)

if exception_container[0]:
raise exception_container[0]
return result_container[0]

except RuntimeError:
# No running event loop - we're in a sync context
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
return loop.run_until_complete(refresh_controls_async())
finally:
loop.close()


# ============================================================================
# Public API Functions
# ============================================================================
Expand Down Expand Up @@ -259,7 +369,7 @@ async def handle(message: str):
Environment Variables:
AGENT_CONTROL_URL: Server URL (default: http://localhost:8000)
"""
global _current_agent, _control_engine, _client, _server_controls
global _current_agent, _control_engine, _client, _server_controls, _server_url, _api_key

if not agent_id:
raise ValueError(
Expand Down Expand Up @@ -295,6 +405,7 @@ async def handle(message: str):

# Get server URL (ensure it's always a string)
_server_url = server_url or os.getenv('AGENT_CONTROL_URL') or 'http://localhost:8000'
_api_key = api_key

# Register with server and fetch controls
server_controls = None
Expand Down Expand Up @@ -907,6 +1018,11 @@ async def main():
"init",
"current_agent",

# Control sync
"get_server_controls",
"refresh_controls",
"refresh_controls_async",

# SDK Logging
"get_logger",

Expand Down Expand Up @@ -982,4 +1098,8 @@ async def main():
"EvaluatorConfig",
]

__version__ = "0.1.0"
try:
__version__ = get_version("agent-control-sdk")
except PackageNotFoundError:
# Package not installed (e.g., running from source without install)
__version__ = "0.0.0.dev"
114 changes: 109 additions & 5 deletions sdks/python/src/agent_control/control_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,8 +186,16 @@ async def _evaluate(
server_url: str,
trace_id: str | None = None,
span_id: str | None = None,
controls: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Call server evaluation endpoint asynchronously."""
"""Call evaluation with support for local (SDK) and server execution.

If controls are provided, uses check_evaluation_with_local() which:
- Evaluates execution="sdk" controls locally in the SDK
- Sends execution="server" controls to the server

If no controls provided, falls back to server-only evaluation.
"""
# Build headers with trace/span IDs for distributed tracing
headers = {}
if trace_id:
Expand All @@ -196,6 +204,80 @@ async def _evaluate(
headers["X-Span-Id"] = span_id

async with AgentControlClient(base_url=server_url) as client:
# If we have controls, use local evaluation which handles both SDK and server controls
if controls is not None:
try:
from uuid import UUID

from agent_control.evaluation import check_evaluation_with_local

# Build Step object for evaluation
try:
from agent_control_models import Step
step_obj = Step(**step)
except ImportError:
step_obj = step # type: ignore

result = await check_evaluation_with_local(
client=client,
agent_uuid=UUID(agent_uuid),
step=step_obj,
stage=stage, # type: ignore
controls=controls,
)

# Convert result to dict format expected by process_result
return {
"is_safe": result.is_safe,
"confidence": result.confidence,
"reason": result.reason,
"matches": [
{
Comment thread
nachiket-galileo marked this conversation as resolved.
"control_id": m.control_id,
"control_name": m.control_name,
"action": m.action,
"result": {
"matched": m.result.matched,
"confidence": m.result.confidence,
"message": m.result.message,
"error": m.result.error,
"metadata": m.result.metadata,
},
"control_execution_id": m.control_execution_id,
}
for m in (result.matches or [])
] if result.matches else None,
"errors": [
{
"control_id": e.control_id,
"control_name": e.control_name,
"action": e.action,
"result": {
"matched": e.result.matched,
"confidence": e.result.confidence,
"message": e.result.message,
"error": e.result.error,
"metadata": e.result.metadata,
},
"control_execution_id": e.control_execution_id,
}
for e in (result.errors or [])
] if result.errors else None,
"non_matches": None, # check_evaluation_with_local doesn't return non_matches
}
except ImportError:
logger.warning(
"Local evaluation not available (missing agent_control_engine). "
"Falling back to server-only evaluation. "
"Controls with execution='sdk' will be skipped."
)
except Exception as e:
logger.warning(
"Local evaluation failed: %s. Falling back to server-only evaluation.",
e,
Comment thread
nachiket-galileo marked this conversation as resolved.
)

# Fallback: server-only evaluation
response = await client.http_client.post(
"/api/v1/evaluation",
json={
Expand All @@ -206,8 +288,8 @@ async def _evaluate(
headers=headers,
)
response.raise_for_status()
result: dict[str, Any] = response.json()
return result
result_dict: dict[str, Any] = response.json()
return result_dict


def _extract_input_from_args(func: Callable, args: tuple, kwargs: dict) -> str:
Expand Down Expand Up @@ -376,6 +458,19 @@ def _log_single_control(
)


def _get_server_controls() -> list[dict[str, Any]] | None:
"""Get the cached server controls from agent_control module."""
try:
import agent_control
return agent_control.get_server_controls()
except Exception as exc:
logger.debug(
"Unable to access cached server controls; proceeding without local cache.",
exc_info=exc,
)
return None
Comment thread
nachiket-galileo marked this conversation as resolved.


async def _execute_with_control(
func: Callable,
args: tuple,
Expand All @@ -389,6 +484,10 @@ async def _execute_with_control(
for async functions, or inside asyncio.run() for sync functions), so it can
always use await _evaluate() directly.

Uses cached controls from init() to support both SDK-side and server-side
evaluation. Controls with execution="sdk" are evaluated locally, while
execution="server" controls are sent to the server.

Args:
func: The wrapped function to execute
args: Positional arguments for the function
Expand All @@ -411,6 +510,9 @@ async def _execute_with_control(
return await func(*args, **kwargs)
return func(*args, **kwargs)

# Get cached controls for local evaluation support
controls = _get_server_controls()

# Get trace context: inherit trace_id if set, always generate new span_id
# This allows multiple @control() calls to share the same trace but have unique spans
existing_trace_id = get_current_trace_id()
Expand Down Expand Up @@ -438,7 +540,8 @@ async def _execute_with_control(
try:
result = await _evaluate(
ctx.agent_uuid, ctx.pre_payload(), "pre",
ctx.server_url, ctx.trace_id, ctx.span_id
ctx.server_url, ctx.trace_id, ctx.span_id,
controls=controls,
)
ctx.process_result(result, "pre")
except ControlViolationError:
Expand All @@ -460,7 +563,8 @@ async def _execute_with_control(
try:
result = await _evaluate(
ctx.agent_uuid, ctx.post_payload(output), "post",
ctx.server_url, ctx.trace_id, ctx.span_id
ctx.server_url, ctx.trace_id, ctx.span_id,
controls=controls,
)
ctx.process_result(result, "post")
except ControlViolationError:
Expand Down
Loading
Loading