From 4f147f627eb07c6a60869d4d5642ab166977a25c Mon Sep 17 00:00:00 2001 From: Joao Moura Date: Wed, 11 Mar 2026 01:21:40 -0700 Subject: [PATCH 1/5] feat(examples): expand CrewAI examples with multi-agent crew and Flow integration Add 4 new CrewAI example directories demonstrating the full range of Agent Control capabilities: - steering_financial_agent: All 3 action types (deny/steer/warn) in a wire-transfer scenario with 5 tested scenarios - evaluator_showcase: All 4 built-in evaluators (SQL, LIST, REGEX, JSON) in a data-analyst scenario with 12 tested scenarios - secure_research_crew: 3-agent sequential crew (Researcher, Analyst, Writer) with per-role policies and 5 tested scenarios - content_publishing_flow: Full CrewAI Flow with @start/@listen/@router, conditional routing (low_risk/high_risk/escalation), embedded crews, client-side steering, and human-in-the-loop with 6 tested scenarios Update the top-level CrewAI README with comprehensive feature coverage matrix, architecture diagrams, and scenario tables for all 5 examples. Co-Authored-By: Claude Opus 4.6 --- examples/crewai/README.md | 277 +++-- .../content_publishing_flow/.env.example | 5 + .../crewai/content_publishing_flow/README.md | 150 +++ .../publishing_flow.py | 945 ++++++++++++++++++ .../content_publishing_flow/pyproject.toml | 19 + .../content_publishing_flow/setup_controls.py | 428 ++++++++ .../crewai/evaluator_showcase/.env.example | 5 + examples/crewai/evaluator_showcase/README.md | 109 ++ .../crewai/evaluator_showcase/data_analyst.py | 551 ++++++++++ .../crewai/evaluator_showcase/pyproject.toml | 25 + .../evaluator_showcase/setup_controls.py | 287 ++++++ .../crewai/secure_research_crew/.env.example | 5 + .../crewai/secure_research_crew/README.md | 125 +++ .../secure_research_crew/pyproject.toml | 19 + .../secure_research_crew/research_crew.py | 628 ++++++++++++ .../secure_research_crew/setup_controls.py | 363 +++++++ .../steering_financial_agent/.env.example | 5 + .../crewai/steering_financial_agent/README.md | 83 ++ .../financial_agent.py | 403 ++++++++ .../steering_financial_agent/pyproject.toml | 25 + .../setup_controls.py | 337 +++++++ 21 files changed, 4727 insertions(+), 67 deletions(-) create mode 100644 examples/crewai/content_publishing_flow/.env.example create mode 100644 examples/crewai/content_publishing_flow/README.md create mode 100644 examples/crewai/content_publishing_flow/publishing_flow.py create mode 100644 examples/crewai/content_publishing_flow/pyproject.toml create mode 100644 examples/crewai/content_publishing_flow/setup_controls.py create mode 100644 examples/crewai/evaluator_showcase/.env.example create mode 100644 examples/crewai/evaluator_showcase/README.md create mode 100644 examples/crewai/evaluator_showcase/data_analyst.py create mode 100644 examples/crewai/evaluator_showcase/pyproject.toml create mode 100644 examples/crewai/evaluator_showcase/setup_controls.py create mode 100644 examples/crewai/secure_research_crew/.env.example create mode 100644 examples/crewai/secure_research_crew/README.md create mode 100644 examples/crewai/secure_research_crew/pyproject.toml create mode 100644 examples/crewai/secure_research_crew/research_crew.py create mode 100644 examples/crewai/secure_research_crew/setup_controls.py create mode 100644 examples/crewai/steering_financial_agent/.env.example create mode 100644 examples/crewai/steering_financial_agent/README.md create mode 100644 examples/crewai/steering_financial_agent/financial_agent.py create mode 100644 examples/crewai/steering_financial_agent/pyproject.toml create mode 100644 examples/crewai/steering_financial_agent/setup_controls.py diff --git a/examples/crewai/README.md b/examples/crewai/README.md index a1777ca6..cd43942a 100644 --- a/examples/crewai/README.md +++ b/examples/crewai/README.md @@ -1,122 +1,265 @@ -# CrewAI Customer Support with Agent Control + Guardrails +# CrewAI + Agent Control Examples -Combines Agent Control (security/compliance) with CrewAI Guardrails (quality retries) for production customer support. +Combines **Agent Control** (runtime security & compliance) with **CrewAI** (agent orchestration) for production-grade AI agents. -## What It Does -Agent Control (Security): PRE/POST/FINAL blocks unauthorized access and PII. -CrewAI Guardrails (Quality): validates length/structure/tone with up to 3 retries. +**Agent Control** enforces guardrails at tool boundaries -- blocking unauthorized access, PII leakage, SQL injection, and more -- while **CrewAI Guardrails** handle quality retries. Both coexist without code changes to CrewAI. -## Prerequisites +## How It Works + +``` +User Request + | + v +CrewAI Agent (planning & orchestration) + | + v +@control() decorator (PRE check) <- LAYER 1: Agent Control validates input + | + v +Tool executes (LLM call, DB query, etc.) + | + v +@control() decorator (POST check) <- LAYER 2: Agent Control validates output + | + v +CrewAI Guardrails (quality retries) <- LAYER 3: CrewAI validates quality + | + v +Return to user (or block / steer / warn) +``` + +**Agent Control** blocks or steers immediately (security). **CrewAI Guardrails** retry with feedback (quality). Controls are defined on the **server** -- update rules without redeploying agents. -Before running this example, ensure you have: +## Prerequisites - **Python 3.12+** -- **uv** — Fast Python package manager (`curl -LsSf https://astral.sh/uv/install.sh | sh`) -- **Docker** — For running PostgreSQL (required by Agent Control server) +- **uv** -- Fast Python package manager (`curl -LsSf https://astral.sh/uv/install.sh | sh`) +- **Docker** -- For PostgreSQL (`docker compose -f docker-compose.dev.yml up -d`) +- **OpenAI API key** -- `export OPENAI_API_KEY="your-key"` (only needed for full LLM crew runs; simulated scenarios work without it) -## Installation +## Quick Start -### 1. Install Monorepo Dependencies +### 1. Install dependencies -From the monorepo root, install all workspace packages: +From the monorepo root: ```bash cd /path/to/agent-control make sync ``` -This installs the Agent Control SDK and all workspace packages in editable mode. - -### 2. Install CrewAI Example Dependencies +### 2. Start the Agent Control server -Navigate to the CrewAI example and install its specific dependencies: +In a separate terminal: ```bash -cd examples/crewai -uv pip install -e . --upgrade +make server-run ``` +Verify it's running: -### 3. Set OpenAI API Key +```bash +curl http://localhost:8000/health +# {"status":"healthy","version":"0.1.0"} +``` -Create a `.env` file or export the environment variable: +### 3. Pick an example and run it + +Each example has a `setup_controls.py` (one-time, idempotent) and a main script: ```bash -export OPENAI_API_KEY="your-key-here" +cd examples/crewai/secure_research_crew +uv run python setup_controls.py +uv run python research_crew.py ``` -### 4. Start the Agent Control Server +--- + +## Examples + +### 1. [Content Agent Protection](./content_agent_protection.py) -- PII & Access Control + +The foundational example. A single-agent customer support crew with multi-layer protection: -In a separate terminal, start the server from the monorepo root: +- **PRE-execution**: Block unauthorized data access (regex evaluator) +- **POST-execution**: Block PII leakage in tool output (regex evaluator) +- **Final validation**: Catch PII that bypasses tool-level controls +- **CrewAI Guardrails**: Quality retries for length, tone, and structure + +| Scenario | Layer | Result | +|----------|-------|--------| +| Unauthorized access | Agent Control PRE | Blocked | +| PII in tool output | Agent Control POST | Blocked | +| Short/low-quality response | CrewAI Guardrails | Retry then pass | +| Agent bypass attempt | Agent Control FINAL | Blocked | ```bash -cd /path/to/agent-control -make server-run +cd examples/crewai +uv run python setup_content_controls.py +uv run python content_agent_protection.py ``` -**Verify server is running:** +### 2. [Steering Financial Agent](./steering_financial_agent/) -- Deny, Steer & Warn + +Demonstrates all three Agent Control action types in a wire-transfer scenario: + +| Action | Behavior | Example | +|--------|----------|---------| +| **DENY** | Hard block, no recovery | Sanctioned country, fraud detected | +| **STEER** | Pause, guide agent to correct, retry | 2FA required, manager approval needed | +| **WARN** | Log for audit, no blocking | New recipient, unusual activity | + ```bash -curl http://localhost:8000/health +cd examples/crewai/steering_financial_agent +uv run python setup_controls.py +uv run python financial_agent.py ``` -### 5. Setup Content Controls (One-Time) +### 3. [Evaluator Showcase](./evaluator_showcase/) -- All 4 Built-in Evaluators + +Demonstrates every built-in evaluator in a data-analyst scenario: -From the `examples/crewai` directory, run the setup script: +| Evaluator | Stage | Purpose | +|-----------|-------|---------| +| **SQL** | PRE | Block DROP/DELETE, enforce LIMIT, prevent injection | +| **LIST** | PRE | Restrict access to sensitive tables | +| **REGEX** | POST | Catch SSN, email, credit cards in query results | +| **JSON** | PRE | Validate required fields, enforce constraints, steer for missing data | ```bash -uv run python setup_content_controls.py +cd examples/crewai/evaluator_showcase +uv run python setup_controls.py +uv run python data_analyst.py ``` +### 4. [Secure Research Crew](./secure_research_crew/) -- Multi-Agent Crew with Per-Role Policies + +A production-quality **3-agent sequential crew** (Researcher, Analyst, Writer) where each agent has its own policy with distinct controls. This is the pattern you'd use in real multi-agent systems. -## Running the Example +``` ++--------------+ +--------------+ +---------------+ +| Researcher | --> | Analyst | --> | Writer | +| | | | | | +| query_database| | validate_data| | write_report | ++--------------+ +--------------+ +---------------+ + | | | + data-access-policy analysis-validation content-safety + - SQL safety [deny] - Required fields [deny] - PII blocker [deny] + - Restricted tables - Methodology [steer] + [deny] +``` -Make sure you're in the `examples/crewai` directory and run: +**5 scenarios** -- all run without LLM calls (direct tool testing): + +| # | Scenario | Agent | Evaluator | Action | Result | +|---|----------|-------|-----------|--------|--------| +| 1 | Happy path | All 3 | All | allow | Report generated with sources | +| 2 | SQL injection | Researcher | SQL | deny | "Multiple SQL statements not allowed" | +| 3 | Restricted table | Researcher | LIST | deny | salary_data access blocked | +| 4 | Missing methodology | Analyst | JSON Schema | steer | Auto-corrected, passes on retry | +| 5 | PII in report | Writer | REGEX | deny | SSN/email/phone blocked | ```bash -uv run python content_agent_protection.py +cd examples/crewai/secure_research_crew +uv run python setup_controls.py +uv run python research_crew.py ``` -### Expected Behavior +### 5. [Content Publishing Flow](./content_publishing_flow/) -- CrewAI Flow with Routing & Human-in-the-Loop -| Scenario | Layer | Result | -|----------|-------|--------| -| Unauthorized access | Agent Control PRE | Blocked | -| PII in tool output | Agent Control POST | Blocked | -| Short/low-quality response | Guardrails | Retry then pass | -| Agent bypass attempt | Agent Control FINAL | Blocked | +A complete **CrewAI Flow** using `@start`, `@listen`, and `@router` decorators with Agent Control guardrails at every pipeline stage. Content is routed through different paths based on risk level, with embedded crews and steering for human approval. -### Output Legend -PRE checks input before the LLM. -POST checks tool output for PII. -FINAL checks the crew’s final response. -Agent Control blocks immediately (no retries), violations are logged. -Guardrails retry with feedback (quality-only). +``` +@start: intake_request (JSON validation) + | +@listen: research (Researcher + Fact-Checker) + | +@listen: draft_content (PII + banned topic checks) + | +@router: quality_gate + | + +-- "low_risk" (blog_post) --> auto_publish (final PII scan) + +-- "high_risk" (press_release) --> compliance_review (legal + editor steering) + +-- "escalation" (internal_memo)--> human_review (STEER: manager approval) +``` -## Agent Control + CrewAI Integration +**6 scenarios** covering all three routing paths plus control blocking: -Agent Control works seamlessly **with** CrewAI's agent orchestration: +| # | Scenario | Flow Path | Result | +|---|----------|-----------|--------| +| 1 | Blog post | low_risk -> auto-publish | Published | +| 2 | Press release | high_risk -> compliance review | Steered (exec summary), then published | +| 3 | Internal memo | escalation -> human review | Steered: pending manager approval | +| 4 | Invalid request | intake blocked | JSON evaluator: missing fields | +| 5 | Banned topic | draft blocked | LIST evaluator: "insider trading" detected | +| 6 | PII in draft | draft blocked | REGEX evaluator: email/SSN/phone detected | -1. **CrewAI Agent Layer**: Plans tasks, selects tools, manages conversation flow -2. **Agent Control Layer**: Enforces controls and business rules at tool boundaries +```bash +cd examples/crewai/content_publishing_flow +uv run python setup_controls.py +uv run python publishing_flow.py +``` + +--- + +## Feature Coverage + +| Feature | Ex 1 | Ex 2 | Ex 3 | Ex 4 | Ex 5 | +|---------|:----:|:----:|:----:|:----:|:----:| +| `@control()` decorator | Yes | Yes | Yes | Yes | Yes | +| PRE-execution checks | Yes | Yes | Yes | Yes | Yes | +| POST-execution checks | Yes | Yes | Yes | Yes | Yes | +| **deny** action | Yes | Yes | Yes | Yes | Yes | +| **steer** action | | Yes | Yes | Yes | Yes | +| **warn** action | | Yes | | | | +| Regex evaluator | Yes | | Yes | Yes | Yes | +| List evaluator | | Yes | Yes | Yes | Yes | +| JSON evaluator | | Yes | Yes | Yes | Yes | +| SQL evaluator | | | Yes | Yes | | +| Steering context + retry loop | | Yes | Yes | Yes | Yes | +| ControlViolationError handling | Yes | Yes | Yes | Yes | Yes | +| ControlSteerError handling | | Yes | Yes | Yes | Yes | +| **Multi-agent crew** | | | | **Yes** | | +| **Per-agent policies** | | | | **Yes** | | +| **CrewAI Flow (@start/@listen/@router)** | | | | | **Yes** | +| **Conditional routing** | | | | | **Yes** | +| **Human-in-the-loop (steer)** | | | | | **Yes** | +| **Pydantic state management** | | | | | **Yes** | +| CrewAI Guardrails coexistence | Yes | | | | | + +## Architecture Deep Dive + +### Multi-Agent Crew (Example 4) + +The SDK supports one `agent_control.init()` per process. All three CrewAI agents share a single Agent Control identity, but each tool's `step_name` routes it to the right policy: ``` -User Request - ↓ -CrewAI Agent (planning & orchestration) - ↓ -Decides to call tool - ↓ -@control() decorator (PRE-execution) ← LAYER 1: Validates input - ↓ -Tool executes (LLM generation) - ↓ -@control() decorator (POST-execution) ← LAYER 2: Validates tool output - ↓ -If blocked, agent may generate own response - ↓ -Final Output Validation ← LAYER 3: Validates crew output (catches bypass) - ↓ -Return to user (or block if control violated) + Single agent_control.init() + | + +------------------------------------------------------------+ + | CrewAI Sequential Crew | + | | + | +--------------+ +--------------+ +----------------+ | + | | Researcher |-->| Analyst |-->| Writer | | + | | query_database| | validate_data| | write_report | | + | +--------------+ +--------------+ +----------------+ | + +------------------------------------------------------------+ + | | | + data-access-policy analysis-validation content-safety + (SQL + LIST deny) (JSON deny + steer) (REGEX deny) ``` +### CrewAI Flow (Example 5) +``` +@start: intake_request -----> @listen: research -----> @listen: draft_content + (JSON validation) (LIST + REGEX checks) (REGEX + LIST checks) + | + @router: quality_gate + / | \ + / | \ + "low_risk" "high_risk" "escalation" + | | | + auto_publish compliance human_review + (REGEX PII) (JSON + STEER) (STEER) +``` diff --git a/examples/crewai/content_publishing_flow/.env.example b/examples/crewai/content_publishing_flow/.env.example new file mode 100644 index 00000000..a3e138cd --- /dev/null +++ b/examples/crewai/content_publishing_flow/.env.example @@ -0,0 +1,5 @@ +# OpenAI API key for CrewAI agents +OPENAI_API_KEY=your-openai-api-key-here + +# Agent Control server URL (default: http://localhost:8000) +AGENT_CONTROL_URL=http://localhost:8000 diff --git a/examples/crewai/content_publishing_flow/README.md b/examples/crewai/content_publishing_flow/README.md new file mode 100644 index 00000000..371b2ead --- /dev/null +++ b/examples/crewai/content_publishing_flow/README.md @@ -0,0 +1,150 @@ +# Content Publishing Flow - CrewAI Flow + Agent Control + +A complete CrewAI Flow example demonstrating routing (`@router`), embedded crews, and human-in-the-loop via steering, with Agent Control guardrails at every pipeline stage. + +## What It Demonstrates + +- **CrewAI Flows** with `@start`, `@listen`, and `@router` decorators +- **Routing logic** that directs content through different pipelines based on type +- **Agent Control integration** at every stage (JSON, LIST, REGEX evaluators + STEER actions) +- **Pydantic state management** across flow stages +- **Steering with retry** for corrective actions (e.g., adding missing Executive Summary) +- **Human-in-the-loop** via STEER action for manager approval + +## Flow Architecture + +``` +@start: intake_request + | JSON evaluator: require topic, audience, content_type + | +@listen(intake_request): research + | Researcher: LIST evaluator (block banned sources) + | Fact-Checker: REGEX evaluator (flag unverified claims) + | +@listen(research): draft_content + | Writer: REGEX (block PII), LIST (block banned topics) + | +@router(draft_content): quality_gate + | + +-- "low_risk" (blog_post) + | | + | @listen: auto_publish + | REGEX: final PII scan, then publish + | + +-- "high_risk" (press_release) + | | + | @listen: compliance_review + | Legal Reviewer: JSON (require disclaimer, legal_reviewed) + | Editor: REGEX (PII), client-side steer (Executive Summary) + | Then: publish + | + +-- "escalation" (internal_memo) + | + @listen: human_review + STEER: pause for manager approval +``` + +## Scenarios + +| # | Scenario | Input | Path | Expected Outcome | +|---|----------|-------|------|------------------| +| 1 | Blog post | topic + audience + "blog_post" | low_risk | Intake -> Research -> Draft -> Auto-publish | +| 2 | Press release | topic + audience + "press_release" | high_risk | Intake -> Research -> Draft -> Compliance review -> Publish | +| 3 | Internal memo | topic + audience + "internal_memo" | escalation | Intake -> Research -> Draft -> Human review (STEER) | +| 4 | Invalid request | missing fields | blocked | JSON evaluator blocks at intake | +| 5 | Banned topic | draft contains "insider trading" | blocked | LIST evaluator blocks at draft | +| 6 | PII in draft | draft contains email/phone/SSN | blocked | REGEX evaluator blocks at draft | + +## Prerequisites + +- **Python 3.12+** +- **uv** package manager +- **Docker** for the Agent Control server's PostgreSQL +- **OpenAI API key** (for full CrewAI agent execution, not required for simulated scenarios) + +## Running + +### 1. Start the Agent Control Server + +From the monorepo root: + +```bash +make server-run +``` + +Verify: + +```bash +curl http://localhost:8000/health +``` + +### 2. Install Dependencies + +```bash +cd examples/crewai/content_publishing_flow +uv pip install -e . --upgrade +``` + +### 3. Set Environment Variables + +```bash +export OPENAI_API_KEY="your-key-here" +export AGENT_CONTROL_URL="http://localhost:8000" # optional, this is the default +``` + +### 4. Setup Controls (One-Time) + +```bash +uv run python setup_controls.py +``` + +This creates 9 controls covering all pipeline stages and associates them with the `content-publishing-flow` agent. + +### 5. Run the Flow + +```bash +uv run python publishing_flow.py +``` + +## Controls Reference + +| Control Name | Evaluator | Stage | Step | Action | +|---|---|---|---|---| +| flow-intake-validation | JSON (required_fields) | pre | validate_request | deny | +| flow-research-banned-sources | LIST (unreliable sources) | post | research_topic | deny | +| flow-factcheck-unverified | REGEX (unverified markers) | post | fact_check | deny | +| flow-draft-pii-block | REGEX (SSN/email/phone) | post | write_draft | deny | +| flow-draft-banned-topics | LIST (restricted topics) | post | write_draft | deny | +| flow-compliance-legal-review | JSON (disclaimer, legal_reviewed) | post | legal_review | deny | +| flow-editor-pii-block | REGEX (SSN/email/phone) | post | edit_content | deny | +| flow-human-review-steer | LIST (internal_memo) | pre | request_human_review | steer | +| flow-publish-pii-scan | REGEX (SSN/email/phone) | pre | publish_content | deny | + +## How Agent Control Integrates with CrewAI Flows + +Agent Control works at the **tool boundary** within each flow stage: + +1. Each flow stage calls a **controlled function** (wrapped with `@control()`) +2. The SDK sends the function's input/output to the Agent Control server +3. The server evaluates controls matching the step name and stage (pre/post) +4. On **deny**: `ControlViolationError` is raised, the stage handles it +5. On **steer**: `ControlSteerError` provides corrective guidance for retry +6. On **allow**: execution proceeds normally + +This pattern means controls are defined on the **server** (not in code), so you can update rules, add new controls, or change actions without redeploying the agent. + +``` +Flow Stage (e.g., draft_content) + | + v +controlled_fn(topic=..., audience=...) + | + +-- PRE check: server evaluates input against matching controls + | + +-- Function executes (simulated or LLM-based) + | + +-- POST check: server evaluates output against matching controls + | + v +Result returned to flow (or error raised) +``` diff --git a/examples/crewai/content_publishing_flow/publishing_flow.py b/examples/crewai/content_publishing_flow/publishing_flow.py new file mode 100644 index 00000000..67068735 --- /dev/null +++ b/examples/crewai/content_publishing_flow/publishing_flow.py @@ -0,0 +1,945 @@ +""" +Content Publishing Flow - CrewAI Flow with Agent Control Guardrails. + +Demonstrates CrewAI Flows (routing, embedded crews, state management) +integrated with Agent Control at every pipeline stage. + +Flow Architecture: + @start: intake_request + -> Validates and classifies the content request + -> Control: JSON evaluator (require topic, audience, content_type) + + @listen(intake_request): research + -> 2-agent crew: Researcher + Fact-Checker + -> Controls: LIST (banned sources), REGEX (unverified claims) + + @listen(research): draft_content + -> Single agent writes content + -> Controls: REGEX (block PII), LIST (block banned topics) + + @router(draft_content): quality_gate + -> Routes based on content_type: + "blog_post" -> "low_risk" (auto-publish) + "press_release" -> "high_risk" (compliance review) + "internal_memo" -> "escalation" (human review) + + @listen("low_risk"): auto_publish -> Final PII scan, then publish + @listen("high_risk"): compliance_review -> Legal + Editor crew + @listen("escalation"): human_review -> STEER for manager approval + +PREREQUISITE: + Run setup_controls.py first: + $ uv run python setup_controls.py + + Then run this flow: + $ uv run python publishing_flow.py +""" + +import asyncio +import json +import os +import sys +import time + +from crewai.flow.flow import Flow, listen, router, start +from pydantic import BaseModel + +import agent_control +from agent_control import ControlSteerError, ControlViolationError, control + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +AGENT_NAME = "content-publishing-flow" +SERVER_URL = os.getenv("AGENT_CONTROL_URL", "http://localhost:8000") + + +# --------------------------------------------------------------------------- +# Flow State +# --------------------------------------------------------------------------- + +class PublishingState(BaseModel): + """Tracks data through the entire publishing pipeline.""" + + topic: str = "" + audience: str = "" + content_type: str = "" # "blog_post", "press_release", "internal_memo" + research: str = "" + draft: str = "" + final_content: str = "" + status: str = "pending" + error: str = "" + + +# --------------------------------------------------------------------------- +# Controlled tool wrappers +# +# Each wrapper follows the same pattern: +# 1. Define an async inner function with named parameters (not **kwargs). +# 2. Set .name and .tool_name for step detection. +# 3. Apply @control() to get the controlled version. +# 4. The outer CrewAI @tool calls asyncio.run(controlled_fn(...)). +# --------------------------------------------------------------------------- + +# --- Intake: validate_request --- + +async def _validate_request(request: dict) -> str: + """Validate that the content request has required fields.""" + # The JSON evaluator on the server checks for topic, audience, content_type. + # If they are present the control passes; if missing it denies. + topic = request.get("topic", "") + audience = request.get("audience", "") + content_type = request.get("content_type", "") + return json.dumps({ + "topic": topic, + "audience": audience, + "content_type": content_type, + "valid": bool(topic and audience and content_type), + }) + + +_validate_request.name = "validate_request" # type: ignore[attr-defined] +_validate_request.tool_name = "validate_request" # type: ignore[attr-defined] +_controlled_validate_request = control()(_validate_request) + + +# --- Research: research_topic --- + +async def _research_topic(topic: str, audience: str) -> str: + """Research a topic. Returns simulated research notes.""" + # Simulated output - the LIST control checks for banned sources in output. + return ( + f"Research findings on '{topic}' for {audience}:\n\n" + f"1. Industry reports from McKinsey and Gartner highlight growing adoption.\n" + f"2. Academic papers from MIT and Stanford confirm key trends.\n" + f"3. Government statistics (census.gov, bls.gov) provide baseline data.\n" + f"4. Recent surveys show 73% of professionals consider this a priority.\n\n" + f"Key insight: The intersection of {topic} and modern workflows is driving " + f"measurable ROI for early adopters." + ) + + +_research_topic.name = "research_topic" # type: ignore[attr-defined] +_research_topic.tool_name = "research_topic" # type: ignore[attr-defined] +_controlled_research = control()(_research_topic) + + +# --- Research: fact_check --- + +async def _fact_check(research_text: str) -> str: + """Fact-check research output. Returns simulated verification report.""" + # The REGEX control checks output for unverified-claim markers. + return ( + "Fact-Check Report:\n" + "- Claim 1 (industry reports): VERIFIED - matches published data.\n" + "- Claim 2 (academic papers): VERIFIED - citations confirmed.\n" + "- Claim 3 (government stats): VERIFIED - data matches official sources.\n" + "- Claim 4 (survey data): VERIFIED - survey methodology reviewed.\n\n" + "Overall assessment: All claims verified. No corrections needed." + ) + + +_fact_check.name = "fact_check" # type: ignore[attr-defined] +_fact_check.tool_name = "fact_check" # type: ignore[attr-defined] +_controlled_fact_check = control()(_fact_check) + + +# --- Draft: write_draft --- + +async def _write_draft(topic: str, audience: str, content_type: str, research: str) -> str: + """Write a content draft based on research. Returns simulated draft.""" + # The REGEX control blocks PII; the LIST control blocks banned topics. + if content_type == "blog_post": + return ( + f"# {topic}: What You Need to Know\n\n" + f"*Written for {audience}*\n\n" + f"## Introduction\n" + f"The landscape of {topic} is evolving rapidly. Industry leaders are " + f"taking notice, and for good reason.\n\n" + f"## Key Findings\n" + f"Our research reveals several important trends that professionals " + f"should be aware of. Early adopters are seeing measurable returns.\n\n" + f"## What This Means For You\n" + f"Whether you are a seasoned professional or just getting started, " + f"understanding these trends is essential for staying competitive.\n\n" + f"## Conclusion\n" + f"The data is clear: {topic} represents a significant opportunity. " + f"Now is the time to act.\n" + ) + elif content_type == "press_release": + return ( + f"FOR IMMEDIATE RELEASE\n\n" + f"{topic}\n\n" + f"Company announces major developments in {topic}, " + f"targeting {audience}.\n\n" + f"Key highlights:\n" + f"- New initiatives launched to address industry needs\n" + f"- Strategic partnerships formed with leading organizations\n" + f"- Measurable impact expected within the first quarter\n\n" + f"About the Company:\n" + f"We are committed to innovation and excellence in our field.\n" + ) + else: # internal_memo + return ( + f"INTERNAL MEMO\n\n" + f"Subject: {topic}\n" + f"Audience: {audience}\n\n" + f"Team,\n\n" + f"This memo outlines our strategy regarding {topic}. " + f"Based on recent research, we recommend the following actions:\n\n" + f"1. Allocate resources for pilot program\n" + f"2. Form cross-functional task force\n" + f"3. Establish KPIs and reporting cadence\n\n" + f"Please review and provide feedback by end of week.\n" + ) + + +_write_draft.name = "write_draft" # type: ignore[attr-defined] +_write_draft.tool_name = "write_draft" # type: ignore[attr-defined] +_controlled_write_draft = control()(_write_draft) + + +# --- Compliance: legal_review --- + +async def _legal_review(content: str) -> str: + """Legal review of content. Returns JSON with disclaimer and approval.""" + # The JSON control checks output for required fields: disclaimer, legal_reviewed. + return json.dumps({ + "disclaimer": ( + "This content has been reviewed for legal compliance. " + "Forward-looking statements are subject to change." + ), + "legal_reviewed": True, + "notes": "No legal issues found. Approved for publication.", + "reviewed_content": content, + }) + + +_legal_review.name = "legal_review" # type: ignore[attr-defined] +_legal_review.tool_name = "legal_review" # type: ignore[attr-defined] +_controlled_legal_review = control()(_legal_review) + + +# --- Compliance: edit_content --- + +async def _edit_content(content: str, include_executive_summary: bool = False) -> str: + """Edit content for quality. REGEX blocks PII; STEER requires exec summary.""" + if include_executive_summary: + summary_section = ( + "## Executive Summary\n\n" + "This press release announces key developments that position the company " + "for significant growth. Stakeholders should note the strategic partnerships " + "and projected impact outlined below.\n\n" + ) + # Insert after the first line (FOR IMMEDIATE RELEASE) + lines = content.split("\n", 2) + if len(lines) >= 3: + return lines[0] + "\n" + summary_section + lines[2] + return summary_section + content + return content + + +_edit_content.name = "edit_content" # type: ignore[attr-defined] +_edit_content.tool_name = "edit_content" # type: ignore[attr-defined] +_controlled_edit_content = control()(_edit_content) + + +# --- Publish: publish_content --- + +async def _publish_content(content: str, content_type: str) -> str: + """Publish content. Final PII scan happens as a pre-check.""" + return json.dumps({ + "status": "published", + "content_type": content_type, + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ"), + "content_preview": content[:200] + "..." if len(content) > 200 else content, + }) + + +_publish_content.name = "publish_content" # type: ignore[attr-defined] +_publish_content.tool_name = "publish_content" # type: ignore[attr-defined] +_controlled_publish = control()(_publish_content) + + +# --- Human Review: request_human_review --- + +async def _request_human_review(content: str, content_type: str) -> str: + """Submit content for human review. STEER control pauses for approval.""" + return json.dumps({ + "status": "pending_review", + "content_type": content_type, + "submitted_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"), + }) + + +_request_human_review.name = "request_human_review" # type: ignore[attr-defined] +_request_human_review.tool_name = "request_human_review" # type: ignore[attr-defined] +_controlled_human_review = control()(_request_human_review) + + +# --------------------------------------------------------------------------- +# Helper: run a controlled function and handle errors +# --------------------------------------------------------------------------- + +async def run_controlled_async(controlled_fn, label: str, **kwargs) -> str: + """Run a controlled async function with error handling.""" + print(f"\n [{label}] Evaluating controls...") + try: + result = await controlled_fn(**kwargs) + print(f" [{label}] Controls passed.") + return result + except ControlViolationError as e: + print(f" [{label}] BLOCKED by control '{e.control_name}': {e.message}") + raise + except ControlSteerError as e: + print(f" [{label}] STEERED by control '{e.control_name}': {e.message}") + raise + except RuntimeError as e: + print(f" [{label}] Control check failed: {e}") + raise + + +def run_controlled_sync(controlled_fn, label: str, **kwargs) -> str: + """Run a controlled async function synchronously (for use outside event loops).""" + print(f"\n [{label}] Evaluating controls...") + try: + result = asyncio.run(controlled_fn(**kwargs)) + print(f" [{label}] Controls passed.") + return result + except ControlViolationError as e: + print(f" [{label}] BLOCKED by control '{e.control_name}': {e.message}") + raise + except ControlSteerError as e: + print(f" [{label}] STEERED by control '{e.control_name}': {e.message}") + raise + except RuntimeError as e: + print(f" [{label}] Control check failed: {e}") + raise + + +# --------------------------------------------------------------------------- +# CrewAI Flow +# --------------------------------------------------------------------------- + +class ContentPublishingFlow(Flow[PublishingState]): + """ + Content publishing pipeline as a CrewAI Flow. + + Stages: intake -> research -> draft -> quality_gate + -> auto_publish | compliance_review | human_review + """ + + # ----------------------------------------------------------------------- + # @start: intake_request + # ----------------------------------------------------------------------- + @start() + async def intake_request(self): + """Validate and classify the incoming content request.""" + print("\n" + "=" * 60) + print("STAGE: intake_request") + print("=" * 60) + + request = { + "topic": self.state.topic, + "audience": self.state.audience, + "content_type": self.state.content_type, + } + print(f" Request: {json.dumps(request, indent=2)}") + + try: + result = await run_controlled_async( + _controlled_validate_request, + "Intake Validation", + request=request, + ) + parsed = json.loads(result) + if not parsed.get("valid"): + self.state.status = "failed" + self.state.error = "Request missing required fields" + print(f" Result: INVALID - {self.state.error}") + return "invalid" + + print(f" Result: VALID - proceeding to research") + return "valid" + + except ControlViolationError as e: + self.state.status = "blocked" + self.state.error = f"Intake blocked: {e.message}" + print(f" Result: BLOCKED at intake") + return "blocked" + + # ----------------------------------------------------------------------- + # @listen(intake_request): research + # ----------------------------------------------------------------------- + @listen(intake_request) + async def research(self): + """Run research and fact-checking (simulated 2-agent crew).""" + print("\n" + "=" * 60) + print("STAGE: research (Researcher + Fact-Checker)") + print("=" * 60) + + if self.state.status in ("blocked", "failed"): + print(" Skipping research - intake failed.") + return + + # Step 1: Research + try: + research_output = await run_controlled_async( + _controlled_research, + "Research", + topic=self.state.topic, + audience=self.state.audience, + ) + except (ControlViolationError, RuntimeError) as e: + self.state.status = "blocked" + self.state.error = f"Research blocked: {e}" + return + + # Step 2: Fact-check + try: + fact_check_output = await run_controlled_async( + _controlled_fact_check, + "Fact-Check", + research_text=research_output, + ) + except (ControlViolationError, RuntimeError) as e: + self.state.status = "blocked" + self.state.error = f"Fact-check blocked: {e}" + return + + self.state.research = research_output + print(f"\n Research complete ({len(research_output)} chars)") + print(f" Fact-check: PASSED") + + # ----------------------------------------------------------------------- + # @listen(research): draft_content + # ----------------------------------------------------------------------- + @listen(research) + async def draft_content(self): + """Write the content draft.""" + print("\n" + "=" * 60) + print("STAGE: draft_content") + print("=" * 60) + + if self.state.status in ("blocked", "failed"): + print(" Skipping draft - pipeline already failed.") + return + + try: + draft = await run_controlled_async( + _controlled_write_draft, + "Draft Writer", + topic=self.state.topic, + audience=self.state.audience, + content_type=self.state.content_type, + research=self.state.research, + ) + self.state.draft = draft + print(f" Draft written ({len(draft)} chars)") + print(f" Content type: {self.state.content_type}") + + except ControlViolationError as e: + self.state.status = "blocked" + self.state.error = f"Draft blocked: {e.message}" + print(f" Draft BLOCKED: {e.message}") + + # ----------------------------------------------------------------------- + # @router(draft_content): quality_gate + # ----------------------------------------------------------------------- + @router(draft_content) + async def quality_gate(self): + """Route based on content_type and pipeline status.""" + print("\n" + "=" * 60) + print("STAGE: quality_gate (router)") + print("=" * 60) + + if self.state.status in ("blocked", "failed"): + print(f" Routing: pipeline_end (status={self.state.status})") + return "pipeline_end" + + content_type = self.state.content_type + if content_type == "blog_post": + print(f" Routing: low_risk (blog_post -> auto-publish)") + return "low_risk" + elif content_type == "press_release": + print(f" Routing: high_risk (press_release -> compliance review)") + return "high_risk" + else: + print(f" Routing: escalation (internal_memo -> human review)") + return "escalation" + + # ----------------------------------------------------------------------- + # @listen("low_risk"): auto_publish + # ----------------------------------------------------------------------- + @listen("low_risk") + async def auto_publish(self): + """Auto-publish with a final PII scan.""" + print("\n" + "=" * 60) + print("STAGE: auto_publish (low_risk path)") + print("=" * 60) + + try: + result = await run_controlled_async( + _controlled_publish, + "Publish (PII Scan)", + content=self.state.draft, + content_type=self.state.content_type, + ) + self.state.final_content = self.state.draft + self.state.status = "published" + parsed = json.loads(result) + print(f" Published at: {parsed.get('timestamp')}") + print(f" Status: {parsed.get('status')}") + + except ControlViolationError as e: + self.state.status = "blocked" + self.state.error = f"Publish blocked (PII detected): {e.message}" + print(f" Publish BLOCKED: {e.message}") + + # ----------------------------------------------------------------------- + # @listen("high_risk"): compliance_review + # ----------------------------------------------------------------------- + @listen("high_risk") + async def compliance_review(self): + """Compliance review with Legal Reviewer + Editor (simulated crew).""" + print("\n" + "=" * 60) + print("STAGE: compliance_review (high_risk path)") + print("=" * 60) + print(" Running compliance crew: Legal Reviewer + Editor") + + # Step 1: Legal review + try: + legal_result = await run_controlled_async( + _controlled_legal_review, + "Legal Review", + content=self.state.draft, + ) + legal_data = json.loads(legal_result) + print(f" Legal reviewed: {legal_data.get('legal_reviewed')}") + print(f" Disclaimer: {legal_data.get('disclaimer', '')[:60]}...") + except ControlViolationError as e: + self.state.status = "blocked" + self.state.error = f"Legal review blocked: {e.message}" + print(f" Legal review BLOCKED: {e.message}") + return + + # Step 2: Edit content + # Client-side check: press releases require an Executive Summary. + # First pass without it, detect the absence, then retry with it. + include_exec_summary = False + max_attempts = 2 + + for attempt in range(1, max_attempts + 1): + try: + edited = await run_controlled_async( + _controlled_edit_content, + f"Editor (attempt {attempt})", + content=self.state.draft, + include_executive_summary=include_exec_summary, + ) + + # Client-side steering: check for Executive Summary in press releases + if ( + self.state.content_type == "press_release" + and "Executive Summary" not in edited + and not include_exec_summary + ): + print(f" [Editor] STEERED (client-side): missing Executive Summary") + print(f" Correction: will add Executive Summary on next attempt") + include_exec_summary = True + continue + + # Publish + try: + result = await run_controlled_async( + _controlled_publish, + "Publish (after compliance)", + content=edited, + content_type=self.state.content_type, + ) + self.state.final_content = edited + self.state.status = "published" + parsed = json.loads(result) + print(f"\n Published at: {parsed.get('timestamp')}") + print(f" Status: {parsed.get('status')}") + return + + except ControlViolationError as e: + self.state.status = "blocked" + self.state.error = f"Final publish blocked: {e.message}" + return + + except ControlViolationError as e: + self.state.status = "blocked" + self.state.error = f"Editor blocked: {e.message}" + return + + self.state.status = "failed" + self.state.error = "Editor failed after max retries" + print(f" Editor: exhausted {max_attempts} attempts") + + # ----------------------------------------------------------------------- + # @listen("escalation"): human_review + # ----------------------------------------------------------------------- + @listen("escalation") + async def human_review(self): + """Submit for human review. STEER control pauses for manager approval.""" + print("\n" + "=" * 60) + print("STAGE: human_review (escalation path)") + print("=" * 60) + + max_attempts = 3 + for attempt in range(1, max_attempts + 1): + try: + result = await run_controlled_async( + _controlled_human_review, + f"Human Review (attempt {attempt})", + content=self.state.draft, + content_type=self.state.content_type, + ) + # If we get here without steer, it was approved + self.state.final_content = self.state.draft + self.state.status = "pending_review" + print(f" Submitted for review successfully") + return + + except ControlSteerError as e: + print(f" Flow paused - manager approval required") + try: + guidance = json.loads(e.steering_context) + print(f" Reason: {guidance.get('reason', 'Approval required')}") + print(f" Required: {guidance.get('required_actions', [])}") + except (json.JSONDecodeError, AttributeError): + print(f" Steering context: {e.steering_context}") + + # In a real system, this would pause and wait for async approval. + # For demo purposes, we show the steering and mark as pending. + self.state.status = "pending_approval" + self.state.error = ( + f"Awaiting manager approval. " + f"Steering context: {e.steering_context}" + ) + return + + except ControlViolationError as e: + self.state.status = "blocked" + self.state.error = f"Human review blocked: {e.message}" + return + + # ----------------------------------------------------------------------- + # @listen("pipeline_end"): handle_pipeline_end + # ----------------------------------------------------------------------- + @listen("pipeline_end") + def handle_pipeline_end(self): + """Handle cases where the pipeline was blocked or failed before routing.""" + print("\n" + "=" * 60) + print("STAGE: pipeline_end") + print("=" * 60) + print(f" Status: {self.state.status}") + print(f" Error: {self.state.error}") + + + +# --------------------------------------------------------------------------- +# Scenario runner +# --------------------------------------------------------------------------- + +def print_banner(title: str): + """Print a scenario banner.""" + width = 70 + print("\n" + "#" * width) + print(f"# {title}") + print("#" * width) + + +def print_result(state: PublishingState): + """Print the final state of a flow run.""" + print("\n --- Flow Result ---") + print(f" Status: {state.status}") + if state.error: + print(f" Error: {state.error}") + if state.final_content: + preview = state.final_content[:150].replace("\n", " ") + print(f" Content: {preview}...") + print(f" Content Type: {state.content_type}") + print() + + +def run_flow_scenario( + title: str, + topic: str, + audience: str, + content_type: str, +) -> PublishingState: + """Run the full flow with given inputs and return the final state.""" + print_banner(title) + + flow = ContentPublishingFlow() + flow._state = PublishingState( + topic=topic, + audience=audience, + content_type=content_type, + ) + + # kickoff() runs the flow synchronously + flow.kickoff() + + print_result(flow.state) + return flow.state + + +def run_direct_tool_scenario( + title: str, + tool_label: str, + controlled_fn, + kwargs: dict, +) -> str | None: + """Run a single controlled tool call to demonstrate blocking.""" + print_banner(title) + + try: + result = run_controlled_sync(controlled_fn, tool_label, **kwargs) + print(f"\n --- Result ---") + print(f" Status: PASSED") + preview = str(result)[:200].replace("\n", " ") + print(f" Output: {preview}...") + print() + return result + except ControlViolationError as e: + print(f"\n --- Result ---") + print(f" Status: BLOCKED") + print(f" Control: {e.control_name}") + print(f" Reason: {e.message}") + print() + return None + except ControlSteerError as e: + print(f"\n --- Result ---") + print(f" Status: STEERED") + print(f" Control: {e.control_name}") + print(f" Steering: {e.steering_context}") + print() + return None + except RuntimeError as e: + print(f"\n --- Result ---") + print(f" Status: ERROR") + print(f" Error: {e}") + print() + return None + + +# --------------------------------------------------------------------------- +# Verification +# --------------------------------------------------------------------------- + +def verify_setup() -> bool: + """Verify the Agent Control server is running and controls are configured.""" + import httpx + + try: + print("Verifying Agent Control server...") + response = httpx.get(f"{SERVER_URL}/api/v1/controls", timeout=5.0) + response.raise_for_status() + + data = response.json() + all_controls = [c["name"] for c in data.get("controls", [])] + + required = [ + "flow-intake-validation", + "flow-research-banned-sources", + "flow-draft-pii-block", + "flow-draft-banned-topics", + "flow-publish-pii-scan", + ] + + missing = [c for c in required if c not in all_controls] + if missing: + print(f" Missing controls: {missing}") + print(" Run: uv run python setup_controls.py") + return False + + print(f" Server: {SERVER_URL}") + print(f" Controls found: {len(all_controls)}") + return True + + except httpx.ConnectError: + print(f" Cannot connect to server at {SERVER_URL}") + print(" Start the server: make server-run") + return False + except Exception as e: + print(f" Error: {e}") + return False + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main(): + print("=" * 70) + print("Content Publishing Flow - CrewAI Flow + Agent Control") + print("=" * 70) + print() + print("This demo runs a CrewAI Flow with @start, @listen, and @router") + print("decorators, with Agent Control guardrails at every stage.") + print() + + # Verify setup + if not verify_setup(): + print("\nSetup verification failed. Fix issues above and retry.") + sys.exit(1) + + # Initialize Agent Control (ONE init per process) + agent_control.init( + agent_name=AGENT_NAME, + server_url=SERVER_URL, + ) + + print("\n Agent Control initialized.") + controls_loaded = agent_control.get_server_controls() + print(f" Controls loaded: {len(controls_loaded) if controls_loaded else 0}") + + # ================================================================== + # SCENARIO 1: Blog Post (low_risk path) - Happy Path + # ================================================================== + run_flow_scenario( + title="SCENARIO 1: Blog Post (low_risk -> auto-publish)", + topic="AI in Healthcare", + audience="technology professionals", + content_type="blog_post", + ) + + # ================================================================== + # SCENARIO 2: Press Release (high_risk path) - Compliance Review + # ================================================================== + run_flow_scenario( + title="SCENARIO 2: Press Release (high_risk -> compliance review)", + topic="Q4 Earnings Announcement", + audience="investors and media", + content_type="press_release", + ) + + # ================================================================== + # SCENARIO 3: Internal Memo (escalation path) - Human Review + # ================================================================== + run_flow_scenario( + title="SCENARIO 3: Internal Memo (escalation -> human review STEER)", + topic="Restructuring Plan", + audience="executive leadership", + content_type="internal_memo", + ) + + # ================================================================== + # SCENARIO 4: Invalid Request - Missing Required Fields + # ================================================================== + # Use direct tool call to demonstrate JSON evaluator blocking + run_direct_tool_scenario( + title="SCENARIO 4: Invalid Request (missing fields -> JSON block)", + tool_label="Intake Validation", + controlled_fn=_controlled_validate_request, + kwargs={"request": {"topic": "Something"}}, + # Missing audience and content_type + ) + + # ================================================================== + # SCENARIO 5: Banned Topic - LIST evaluator blocks draft + # ================================================================== + # Direct tool call with content that contains a banned topic + async def _write_draft_banned( + topic: str, audience: str, content_type: str, research: str + ) -> str: + """Write a draft that contains banned topic content.""" + return ( + f"# Investment Strategies\n\n" + f"One controversial but effective approach involves insider trading " + f"techniques that some executives have used to gain market advantage. " + f"This strategy leverages non-public information for maximum returns." + ) + + _write_draft_banned.name = "write_draft" # type: ignore[attr-defined] + _write_draft_banned.tool_name = "write_draft" # type: ignore[attr-defined] + controlled_draft_banned = control()(_write_draft_banned) + + run_direct_tool_scenario( + title="SCENARIO 5: Banned Topic (draft contains 'insider trading' -> LIST block)", + tool_label="Draft Writer (banned content)", + controlled_fn=controlled_draft_banned, + kwargs={ + "topic": "Investment Strategies", + "audience": "financial advisors", + "content_type": "blog_post", + "research": "Financial market research", + }, + ) + + # ================================================================== + # SCENARIO 6: PII in Draft - REGEX evaluator blocks + # ================================================================== + async def _write_draft_pii( + topic: str, audience: str, content_type: str, research: str + ) -> str: + """Write a draft that accidentally includes PII.""" + return ( + f"# {topic}\n\n" + f"For more information, contact our lead researcher at " + f"sarah.jones@company.com or call 555-867-5309.\n\n" + f"Her SSN is 123-45-6789 (included for verification).\n" + ) + + _write_draft_pii.name = "write_draft" # type: ignore[attr-defined] + _write_draft_pii.tool_name = "write_draft" # type: ignore[attr-defined] + controlled_draft_pii = control()(_write_draft_pii) + + run_direct_tool_scenario( + title="SCENARIO 6: PII in Draft (email/phone/SSN -> REGEX block)", + tool_label="Draft Writer (PII leak)", + controlled_fn=controlled_draft_pii, + kwargs={ + "topic": "Research Update", + "audience": "internal team", + "content_type": "blog_post", + "research": "Research data", + }, + ) + + # ================================================================== + # Summary + # ================================================================== + print("\n" + "=" * 70) + print("DEMO COMPLETE") + print("=" * 70) + print(""" +Flow Architecture: + + @start: intake_request + | + @listen: research (Researcher + Fact-Checker) + | + @listen: draft_content + | + @router: quality_gate + | + +-- "low_risk" --> auto_publish + +-- "high_risk" --> compliance_review + +-- "escalation" --> human_review + +Controls Applied: + Intake: JSON evaluator (required fields) + Research: LIST (banned sources), REGEX (unverified claims) + Draft: REGEX (PII), LIST (banned topics) + Compliance: JSON (legal fields), REGEX (PII), STEER (exec summary) + Publish: REGEX (final PII scan) + Human: STEER (manager approval) + +Scenarios Demonstrated: + 1. Blog post -> low_risk -> auto-publish (happy path) + 2. Press release -> high_risk -> compliance review + steering + 3. Internal memo -> escalation -> human review (STEER) + 4. Missing fields -> JSON evaluator blocks at intake + 5. Banned topic -> LIST evaluator blocks at draft + 6. PII in draft -> REGEX evaluator blocks at draft +""") + + +if __name__ == "__main__": + main() diff --git a/examples/crewai/content_publishing_flow/pyproject.toml b/examples/crewai/content_publishing_flow/pyproject.toml new file mode 100644 index 00000000..3458001a --- /dev/null +++ b/examples/crewai/content_publishing_flow/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "agent-control-crewai-publishing-flow" +version = "0.1.0" +description = "CrewAI Flow with routing, embedded crews, and Agent Control guardrails" +requires-python = ">=3.12" +dependencies = [ + "agent-control-sdk>=6.3.0", + "crewai>=0.80.0", + "crewai-tools>=0.12.0", + "openai>=1.0.0", + "python-dotenv>=1.0.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +include = ["*.py"] diff --git a/examples/crewai/content_publishing_flow/setup_controls.py b/examples/crewai/content_publishing_flow/setup_controls.py new file mode 100644 index 00000000..5685c8d8 --- /dev/null +++ b/examples/crewai/content_publishing_flow/setup_controls.py @@ -0,0 +1,428 @@ +""" +Setup script for Content Publishing Flow controls. + +Creates Agent Control controls for each stage of the CrewAI Flow pipeline: +- Intake: JSON evaluator to validate required fields +- Research: LIST evaluator to block banned sources +- Fact-Check: REGEX evaluator to flag unverified claims +- Draft: REGEX evaluator for PII, LIST evaluator for banned topics +- Compliance: JSON evaluator for legal review fields +- Editor: REGEX for PII cleanup (executive summary check is client-side) +- Human Review: STEER action for manager approval on internal memos + +Run once before running publishing_flow.py: + uv run python setup_controls.py +""" + +import asyncio +import os + +from agent_control import Agent, AgentControlClient, agents, controls + +AGENT_NAME = "content-publishing-flow" +SERVER_URL = os.getenv("AGENT_CONTROL_URL", "http://localhost:8000") + + +async def create_control_safe( + client: AgentControlClient, + name: str, + data: dict, +) -> int: + """Create a control, handling 409 conflicts gracefully. Returns control_id.""" + try: + result = await controls.create_control(client, name=name, data=data) + control_id = result["control_id"] + print(f" + Created control: {name} (ID: {control_id})") + return control_id + except Exception as e: + if "409" in str(e): + print(f" ~ Control '{name}' already exists, looking it up...") + controls_list = await controls.list_controls(client, name=name, limit=1) + if controls_list["controls"]: + control_id = controls_list["controls"][0]["id"] + print(f" ~ Using existing control (ID: {control_id})") + return control_id + else: + print(f" ! Could not find existing control '{name}'") + raise SystemExit(1) + else: + print(f" ! Error creating control '{name}': {e}") + raise + + +async def add_control_to_agent_safe( + client: AgentControlClient, + agent_name: str, + control_id: int, + control_name: str, +) -> None: + """Associate a control with an agent, handling duplicates gracefully.""" + try: + await agents.add_agent_control(client, agent_name, control_id) + print(f" + Associated '{control_name}' with agent") + except Exception as e: + if "409" in str(e) or "already" in str(e).lower(): + print(f" ~ '{control_name}' already associated with agent (OK)") + else: + print(f" ! Failed to associate '{control_name}': {e}") + raise + + +async def setup_publishing_controls(): + """Create all controls for the content publishing flow pipeline.""" + async with AgentControlClient(base_url=SERVER_URL) as client: + + # ===================================================================== + # 1. Register the Agent + # ===================================================================== + print("Registering agent...") + agent = Agent( + agent_name=AGENT_NAME, + agent_description=( + "Content publishing flow with routing, embedded crews, " + "and Agent Control guardrails at each pipeline stage" + ), + ) + + try: + await agents.register_agent(client, agent, steps=[]) + print(f" + Agent registered: {AGENT_NAME}") + except Exception as e: + print(f" ~ Agent may already exist: {e}") + + # ===================================================================== + # 2. Create Controls + # ===================================================================== + print("\nCreating controls...") + control_ids: list[tuple[int, str]] = [] + + # ------------------------------------------------------------------ + # INTAKE STAGE: JSON evaluator - require topic, audience, content_type + # ------------------------------------------------------------------ + intake_validation = { + "description": "Validate content request has required fields (topic, audience, content_type)", + "enabled": True, + "execution": "server", + "scope": { + "step_types": ["tool"], + "step_names": ["validate_request"], + "stages": ["pre"], + }, + "selector": { + "path": "input.request", + }, + "evaluator": { + "name": "json", + "config": { + "required_fields": ["topic", "audience", "content_type"], + }, + }, + "action": {"decision": "deny"}, + } + cid = await create_control_safe(client, "flow-intake-validation", intake_validation) + control_ids.append((cid, "flow-intake-validation")) + + # ------------------------------------------------------------------ + # RESEARCH STAGE: LIST evaluator - block banned sources + # ------------------------------------------------------------------ + banned_sources = { + "description": "Block research that references banned or unreliable sources", + "enabled": True, + "execution": "server", + "scope": { + "step_types": ["tool"], + "step_names": ["research_topic"], + "stages": ["post"], + }, + "selector": { + "path": "output", + }, + "evaluator": { + "name": "list", + "config": { + "values": [ + "infowars.com", + "naturalcures.com", + "conspiracydaily.net", + "fakenews.org", + "unverifiedsource.com", + ], + "logic": "any", + "match_mode": "contains", + "case_sensitive": False, + }, + }, + "action": {"decision": "deny"}, + } + cid = await create_control_safe(client, "flow-research-banned-sources", banned_sources) + control_ids.append((cid, "flow-research-banned-sources")) + + # ------------------------------------------------------------------ + # FACT-CHECK STAGE: REGEX evaluator - flag unverified claims/URLs + # ------------------------------------------------------------------ + unverified_claims = { + "description": "Flag fact-check results that contain unverified claim markers", + "enabled": True, + "execution": "server", + "scope": { + "step_types": ["tool"], + "step_names": ["fact_check"], + "stages": ["post"], + }, + "selector": { + "path": "output", + }, + "evaluator": { + "name": "regex", + "config": { + "pattern": ( + r"(?i)" + r"(?:UNVERIFIED|UNCONFIRMED|DEBUNKED|RETRACTED|FABRICATED)" + r"|(?:no\s+credible\s+source)" + r"|(?:cannot\s+be\s+verified)" + ), + }, + }, + "action": {"decision": "deny"}, + } + cid = await create_control_safe(client, "flow-factcheck-unverified", unverified_claims) + control_ids.append((cid, "flow-factcheck-unverified")) + + # ------------------------------------------------------------------ + # DRAFT STAGE: REGEX evaluator - block PII in draft content + # ------------------------------------------------------------------ + draft_pii = { + "description": "Block drafts containing PII (SSN, email, phone)", + "enabled": True, + "execution": "server", + "scope": { + "step_types": ["tool"], + "step_names": ["write_draft"], + "stages": ["post"], + }, + "selector": { + "path": "output", + }, + "evaluator": { + "name": "regex", + "config": { + "pattern": ( + r"(?:" + r"\b\d{3}-\d{2}-\d{4}\b" # SSN + r"|[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}" # Email + r"|\b\d{3}[-.]?\d{3}[-.]?\d{4}\b" # Phone + r")" + ), + }, + }, + "action": {"decision": "deny"}, + } + cid = await create_control_safe(client, "flow-draft-pii-block", draft_pii) + control_ids.append((cid, "flow-draft-pii-block")) + + # ------------------------------------------------------------------ + # DRAFT STAGE: LIST evaluator - block banned topics + # ------------------------------------------------------------------ + banned_topics = { + "description": "Block drafts that contain banned or restricted topics", + "enabled": True, + "execution": "server", + "scope": { + "step_types": ["tool"], + "step_names": ["write_draft"], + "stages": ["post"], + }, + "selector": { + "path": "output", + }, + "evaluator": { + "name": "list", + "config": { + "values": [ + "insider trading", + "market manipulation", + "ponzi scheme", + "money laundering", + "classified information", + ], + "logic": "any", + "match_mode": "contains", + "case_sensitive": False, + }, + }, + "action": {"decision": "deny"}, + } + cid = await create_control_safe(client, "flow-draft-banned-topics", banned_topics) + control_ids.append((cid, "flow-draft-banned-topics")) + + # ------------------------------------------------------------------ + # COMPLIANCE STAGE: JSON evaluator - require disclaimer + legal_reviewed + # ------------------------------------------------------------------ + legal_review = { + "description": "Require disclaimer and legal_reviewed=true in compliance output", + "enabled": True, + "execution": "server", + "scope": { + "step_types": ["tool"], + "step_names": ["legal_review"], + "stages": ["post"], + }, + "selector": { + "path": "output", + }, + "evaluator": { + "name": "json", + "config": { + "required_fields": ["disclaimer", "legal_reviewed"], + }, + }, + "action": {"decision": "deny"}, + } + cid = await create_control_safe(client, "flow-compliance-legal-review", legal_review) + control_ids.append((cid, "flow-compliance-legal-review")) + + # ------------------------------------------------------------------ + # EDITOR STAGE: REGEX evaluator - clean PII from edited content + # ------------------------------------------------------------------ + editor_pii = { + "description": "Block edited content that still contains PII", + "enabled": True, + "execution": "server", + "scope": { + "step_types": ["tool"], + "step_names": ["edit_content"], + "stages": ["post"], + }, + "selector": { + "path": "output", + }, + "evaluator": { + "name": "regex", + "config": { + "pattern": ( + r"(?:" + r"\b\d{3}-\d{2}-\d{4}\b" + r"|[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}" + r"|\b\d{3}[-.]?\d{3}[-.]?\d{4}\b" + r")" + ), + }, + }, + "action": {"decision": "deny"}, + } + cid = await create_control_safe(client, "flow-editor-pii-block", editor_pii) + control_ids.append((cid, "flow-editor-pii-block")) + + # NOTE: Executive summary check for press releases is handled + # client-side in the flow code (compliance_review stage), because + # detecting the ABSENCE of text requires negative lookahead which + # the regex evaluator does not support. + + # ------------------------------------------------------------------ + # HUMAN REVIEW STAGE: STEER - pause for manager approval + # ------------------------------------------------------------------ + human_review_steer = { + "description": "Steer flow to pause for manager approval on internal memos", + "enabled": True, + "execution": "server", + "scope": { + "step_types": ["tool"], + "step_names": ["request_human_review"], + "stages": ["pre"], + }, + "selector": { + "path": "input.content_type", + }, + "evaluator": { + "name": "list", + "config": { + "values": ["internal_memo"], + "logic": "any", + "match_mode": "exact", + "case_sensitive": False, + }, + }, + "action": { + "decision": "steer", + "steering_context": { + "message": '{"required_actions": ["get_manager_approval"], "reason": "Internal memos require manager approval before distribution.", "retry_with": {"approved": true}}', + }, + }, + } + cid = await create_control_safe(client, "flow-human-review-steer", human_review_steer) + control_ids.append((cid, "flow-human-review-steer")) + + # ------------------------------------------------------------------ + # AUTO-PUBLISH STAGE: REGEX - final PII scan before publishing + # ------------------------------------------------------------------ + publish_pii = { + "description": "Final PII scan before auto-publishing content", + "enabled": True, + "execution": "server", + "scope": { + "step_types": ["tool"], + "step_names": ["publish_content"], + "stages": ["pre"], + }, + "selector": { + "path": "input.content", + }, + "evaluator": { + "name": "regex", + "config": { + "pattern": ( + r"(?:" + r"\b\d{3}-\d{2}-\d{4}\b" + r"|[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}" + r"|\b\d{3}[-.]?\d{3}[-.]?\d{4}\b" + r")" + ), + }, + }, + "action": {"decision": "deny"}, + } + cid = await create_control_safe(client, "flow-publish-pii-scan", publish_pii) + control_ids.append((cid, "flow-publish-pii-scan")) + + # ===================================================================== + # 3. Associate All Controls with Agent + # ===================================================================== + print("\nAssociating controls with agent...") + for control_id, control_name in control_ids: + await add_control_to_agent_safe(client, AGENT_NAME, control_id, control_name) + + # ===================================================================== + # Summary + # ===================================================================== + print("\n" + "=" * 60) + print("Setup complete!") + print("=" * 60) + print(f"\nAgent: {AGENT_NAME}") + print(f"Server: {SERVER_URL}") + print(f"Controls created: {len(control_ids)}") + print("\nControls by pipeline stage:") + print(" INTAKE:") + print(" - flow-intake-validation (JSON: require topic, audience, content_type)") + print(" RESEARCH:") + print(" - flow-research-banned-sources (LIST: block unreliable sources)") + print(" - flow-factcheck-unverified (REGEX: flag unverified claims)") + print(" DRAFT:") + print(" - flow-draft-pii-block (REGEX: block PII)") + print(" - flow-draft-banned-topics (LIST: block restricted topics)") + print(" COMPLIANCE (high_risk path):") + print(" - flow-compliance-legal-review (JSON: require disclaimer + legal_reviewed)") + print(" - flow-editor-pii-block (REGEX: block PII in edits)") + print(" - Executive summary check (client-side in flow code)") + print(" HUMAN REVIEW (escalation path):") + print(" - flow-human-review-steer (STEER: manager approval)") + print(" AUTO-PUBLISH (low_risk path):") + print(" - flow-publish-pii-scan (REGEX: final PII scan)") + print("\nRun the flow: uv run python publishing_flow.py") + + +if __name__ == "__main__": + print("=" * 60) + print("Content Publishing Flow - Control Setup") + print("=" * 60) + print() + asyncio.run(setup_publishing_controls()) diff --git a/examples/crewai/evaluator_showcase/.env.example b/examples/crewai/evaluator_showcase/.env.example new file mode 100644 index 00000000..a3e138cd --- /dev/null +++ b/examples/crewai/evaluator_showcase/.env.example @@ -0,0 +1,5 @@ +# OpenAI API key for CrewAI agents +OPENAI_API_KEY=your-openai-api-key-here + +# Agent Control server URL (default: http://localhost:8000) +AGENT_CONTROL_URL=http://localhost:8000 diff --git a/examples/crewai/evaluator_showcase/README.md b/examples/crewai/evaluator_showcase/README.md new file mode 100644 index 00000000..5f5b028f --- /dev/null +++ b/examples/crewai/evaluator_showcase/README.md @@ -0,0 +1,109 @@ +# CrewAI Data Analyst - Evaluator Showcase + +Demonstrates all four built-in Agent Control evaluators in a realistic data-analyst scenario using CrewAI. + +## Evaluators + +| Evaluator | Stage | Purpose | Example | +|-----------|-------|---------|---------| +| **SQL** | PRE | Validate query structure and safety | Block DROP, enforce LIMIT | +| **LIST** | PRE | Access control via allowlists/blocklists | Restrict sensitive tables | +| **REGEX** | POST | Pattern detection in free-text output | Catch SSN, email, credit cards | +| **JSON** | PRE | Schema validation with constraints | Require fields, enforce ranges | + +## Scenarios + +### SQL Evaluator +| # | Query | Outcome | +|---|-------|---------| +| 1a | `SELECT ... FROM orders LIMIT 10` | ALLOWED | +| 1b | `DROP TABLE orders; SELECT ...` | DENIED (blocked operation + multi-statement) | +| 1c | `SELECT * FROM orders` | DENIED (missing required LIMIT) | +| 1d | `DELETE FROM orders WHERE ...` | DENIED (blocked operation) | + +### LIST Evaluator +| # | Table | Outcome | +|---|-------|---------| +| 2a | `orders` | ALLOWED (not restricted) | +| 2b | `salary_data` | DENIED (restricted table) | +| 2c | `audit_log` | DENIED (restricted table) | + +### REGEX Evaluator +| # | Results Contain | Outcome | +|---|-----------------|---------| +| 3a | Order data (no PII) | ALLOWED | +| 3b | SSN `123-45-6789` + email | DENIED (PII detected post-execution) | + +### JSON Evaluator +| # | Request | Outcome | +|---|---------|---------| +| 4a | All fields valid | ALLOWED | +| 4b | Missing `date_range` | DENIED (required field) | +| 4c | `max_rows: 50000` | DENIED (exceeds max of 10000) | +| 4d | Missing `purpose` | STEERED (auto-filled, then allowed) | + +## Controls Created + +- `sql-safety-check` — SQL evaluator: block destructive ops, enforce LIMIT +- `restrict-sensitive-tables` — LIST evaluator: block salary_data, audit_log, etc. +- `pii-in-query-results` — REGEX evaluator: detect SSN/email/credit cards in output +- `validate-analysis-request` — JSON evaluator: require dataset + date_range, constrain max_rows +- `steer-require-purpose` — JSON evaluator with STEER: collect analysis purpose for audit + +## Prerequisites + +- Python 3.12+ +- Agent Control server running (`make server-run` from repo root) +- OpenAI API key (only needed for JSON and CrewAI crew scenarios) + +## Running + +```bash +# From repo root — install dependencies +make sync + +# Navigate to example +cd examples/crewai/evaluator_showcase + +# Install example dependencies +uv pip install -e . --upgrade + +# Set your OpenAI key (optional for SQL/LIST/REGEX scenarios) +export OPENAI_API_KEY="your-key" + +# Set up controls (one-time) +uv run python setup_controls.py + +# Run the demo +uv run python data_analyst.py +``` + +## Key Insight + +Each evaluator serves a different purpose at a different stage: + +``` + Request arrives + | + v + ┌─────────────────────┐ + │ SQL Evaluator (PRE) │ Is this query structurally safe? + └──────────┬──────────┘ + v + ┌──────────────────────┐ + │ LIST Evaluator (PRE) │ Is the target table allowed? + └──────────┬───────────┘ + v + ┌──────────────────────┐ + │ JSON Evaluator (PRE) │ Are required fields present and valid? + └──────────┬───────────┘ + v + Query Executes + | + v + ┌───────────────────────┐ + │ REGEX Evaluator (POST) │ Do results contain PII patterns? + └───────────┬───────────┘ + v + Return Results +``` diff --git a/examples/crewai/evaluator_showcase/data_analyst.py b/examples/crewai/evaluator_showcase/data_analyst.py new file mode 100644 index 00000000..0e038662 --- /dev/null +++ b/examples/crewai/evaluator_showcase/data_analyst.py @@ -0,0 +1,551 @@ +""" +CrewAI Data Analyst with All Four Built-in Evaluators. + +Demonstrates every built-in Agent Control evaluator in a realistic +data-analyst scenario where a CrewAI crew queries databases and +generates reports: + + SQL - Validates queries before execution (block DROP, enforce LIMIT) + LIST - Restricts access to sensitive tables (audit_log, salary_data) + REGEX - Catches PII leaking through query results (SSN, emails) + JSON - Validates analysis requests (required fields, constraints) + +PREREQUISITE: + Run setup_controls.py first: + + $ uv run python setup_controls.py + + Then run this example: + + $ uv run python data_analyst.py + +Scenarios: + 1. Safe SELECT query -> SQL evaluator ALLOWS + 2. DROP TABLE injection -> SQL evaluator DENIES + 3. Query without LIMIT -> SQL evaluator DENIES + 4. Query sensitive table -> LIST evaluator DENIES + 5. Query returns PII -> REGEX evaluator DENIES (post-execution) + 6. Valid analysis request -> JSON evaluator ALLOWS + 7. Missing required fields -> JSON evaluator DENIES + 8. Missing purpose field -> JSON evaluator STEERS (then allowed) +""" + +import asyncio +import json +import os + +import agent_control +from agent_control import ControlSteerError, ControlViolationError, control +from crewai import Agent, Crew, LLM, Task +from crewai.tools import tool + +# ── Configuration ─────────────────────────────────────────────────────── +AGENT_NAME = "crewai-data-analyst" +SERVER_URL = os.getenv("AGENT_CONTROL_URL", "http://localhost:8000") + +agent_control.init( + agent_name=AGENT_NAME, + agent_description="CrewAI data analyst with all evaluator types", + server_url=SERVER_URL, +) + + +# ── Simulated Database ────────────────────────────────────────────────── +# In a real app these would hit an actual database. The simulated responses +# let us demonstrate how Agent Control inspects both input AND output. + +SIMULATED_RESULTS = { + "safe": ( + "| order_id | product | total |\n" + "|----------|-----------|--------|\n" + "| 1001 | Widget A | 29.99 |\n" + "| 1002 | Widget B | 49.99 |\n" + "| 1003 | Gadget X | 149.99 |\n" + "\n3 rows returned." + ), + "pii": ( + "| customer_id | name | ssn | email |\n" + "|-------------|------------|-------------|--------------------| \n" + "| C-101 | John Smith | 123-45-6789 | john@example.com |\n" + "| C-102 | Jane Doe | 987-65-4321 | jane@example.com |\n" + "\n2 rows returned." + ), +} + + +# ── Tool 1: SQL Query Runner ──────────────────────────────────────────── + +def create_sql_tool(): + """Build the SQL query tool with @control protection.""" + + async def _run_sql_query(query: str) -> str: + """Execute a SQL query against the database (protected).""" + # Simulate: if query touches customers_full, return PII results + if "customers_full" in query.lower(): + return SIMULATED_RESULTS["pii"] + return SIMULATED_RESULTS["safe"] + + _run_sql_query.name = "run_sql_query" # type: ignore[attr-defined] + _run_sql_query.tool_name = "run_sql_query" # type: ignore[attr-defined] + controlled_fn = control()(_run_sql_query) + + @tool("run_sql_query") + def run_sql_query_tool(query: str) -> str: + """Run a SQL query against the company database. + + Args: + query: The SQL query to execute + """ + if isinstance(query, dict): + query = query.get("query", str(query)) + + print(f"\n [SQL TOOL] Query: {query[:80]}...") + + try: + result = asyncio.run(controlled_fn(query=query)) + print(f" [SQL TOOL] Query executed successfully") + return result + + except ControlViolationError as e: + print(f" [SQL TOOL] BLOCKED by {e.control_name}: {e.message[:100]}") + return f"QUERY BLOCKED: {e.message}" + + except Exception as e: + print(f" [SQL TOOL] Error: {e}") + return f"Query error: {e}" + + return run_sql_query_tool + + +# ── Tool 2: Data Analyzer ─────────────────────────────────────────────── + +def create_analysis_tool(): + """Build the analysis tool with JSON validation and steering.""" + + llm = LLM(model="gpt-4o-mini", temperature=0.3) + + async def _analyze_data(request: dict) -> str: + """Run data analysis (protected by JSON validation controls). + + Takes a single dict param so the @control() decorator sends it + as input.request — and the JSON evaluator can check which fields + are present or absent. + """ + dataset = request.get("dataset", "") + date_range = request.get("date_range", "") + max_rows = request.get("max_rows", 1000) + purpose = request.get("purpose", "") + + prompt = f"""Summarize this data analysis in 2-3 sentences: +- Dataset: {dataset} +- Date range: {date_range} +- Max rows: {max_rows} +- Purpose: {purpose} + +Provide a brief, professional analysis summary.""" + + return llm.call([{"role": "user", "content": prompt}]) + + _analyze_data.name = "analyze_data" # type: ignore[attr-defined] + _analyze_data.tool_name = "analyze_data" # type: ignore[attr-defined] + controlled_fn = control()(_analyze_data) + + @tool("analyze_data") + def analyze_data_tool(request: str) -> str: + """Analyze a dataset with validation controls. + + Args: + request: JSON string with fields: dataset (required), date_range (required), + max_rows (optional, 1-10000), purpose (recommended for audit compliance) + """ + if isinstance(request, dict): + params = request + else: + try: + params = json.loads(request) + except (json.JSONDecodeError, TypeError): + return f"Invalid request format. Expected JSON, got: {request!r}" + + # Build the request dict — only include fields that have values. + # The JSON evaluator checks which fields are PRESENT in this dict, + # so omitting a field triggers the "required_fields" check. + request_dict: dict = {} + if params.get("dataset"): + request_dict["dataset"] = params["dataset"] + if params.get("date_range"): + request_dict["date_range"] = params["date_range"] + if params.get("max_rows") is not None: + request_dict["max_rows"] = int(params["max_rows"]) + if params.get("purpose"): + request_dict["purpose"] = params["purpose"] + + print(f"\n [ANALYSIS TOOL] Request: {request_dict}") + + # Steering retry loop + max_attempts = 3 + for attempt in range(1, max_attempts + 1): + try: + result = asyncio.run(controlled_fn(request=request_dict)) + print(f" [ANALYSIS TOOL] Analysis complete") + return result + + except ControlViolationError as e: + print(f" [ANALYSIS TOOL] BLOCKED by {e.control_name}: {e.message[:100]}") + return f"ANALYSIS BLOCKED: {e.message}" + + except ControlSteerError as e: + print(f" [ANALYSIS TOOL] STEERED by {e.control_name}") + try: + guidance = json.loads(e.steering_context) + except (json.JSONDecodeError, TypeError): + guidance = {} + + reason = guidance.get("reason", "Correction needed") + actions = guidance.get("required_actions", []) + print(f" Reason: {reason}") + print(f" Actions: {actions}") + + if "collect_purpose" in actions: + auto_purpose = f"Quarterly {request_dict.get('dataset', 'data')} analysis for business reporting" + request_dict["purpose"] = auto_purpose + print(f" Auto-filled purpose: {auto_purpose}") + + continue + + return "ANALYSIS FAILED: Could not satisfy all controls." + + return analyze_data_tool + + +# ── CrewAI Crew ───────────────────────────────────────────────────────── + +def create_analyst_crew(sql_tool, analysis_tool): + analyst = Agent( + role="Data Analyst", + goal="Execute data queries and analysis while respecting all data governance controls", + backstory=( + "You are a data analyst at a company with strict data governance policies. " + "You use run_sql_query to query the database and analyze_data for analysis. " + "You always comply with security controls and never attempt to bypass them." + ), + tools=[sql_tool, analysis_tool], + verbose=True, + ) + + task = Task( + description=( + "Execute this data request: {request}\n\n" + "Use the appropriate tool and report the outcome." + ), + expected_output="Query results or analysis, or an explanation if blocked by controls", + agent=analyst, + ) + + return Crew(agents=[analyst], tasks=[task], verbose=True) + + +# ── Scenario Runner ───────────────────────────────────────────────────── + +def verify_server(): + import httpx + + try: + r = httpx.get(f"{SERVER_URL}/api/v1/controls", timeout=5.0) + r.raise_for_status() + names = [c["name"] for c in r.json().get("controls", [])] + required = [ + "sql-safety-check", + "restrict-sensitive-tables", + "pii-in-query-results", + "validate-analysis-request", + "steer-require-purpose", + ] + missing = [n for n in required if n not in names] + if missing: + print(f"Missing controls: {missing}") + print("Run: uv run python setup_controls.py") + return False + print(f"Server OK - {len(names)} controls active") + return True + except Exception as e: + print(f"Cannot reach server at {SERVER_URL}: {e}") + return False + + +def run_direct_test(title, evaluator, tool_fn, input_data, expected): + """Run a test by calling the tool function directly (bypasses CrewAI LLM).""" + print(f"\n{'=' * 60}") + print(f" {title}") + print(f" Evaluator: {evaluator}") + print(f" Expected: {expected}") + print(f"{'=' * 60}") + + result = tool_fn.run(**input_data) if hasattr(tool_fn, 'run') else tool_fn( + **input_data + ) + print(f"\n Result: {str(result)[:200]}") + return result + + +def main(): + print("=" * 60) + print(" CrewAI Data Analyst - Evaluator Showcase") + print(" All 4 Built-in Evaluators: SQL, LIST, REGEX, JSON") + print("=" * 60) + print() + + if not verify_server(): + return + + if not os.getenv("OPENAI_API_KEY"): + print("\nSet OPENAI_API_KEY to run the JSON/analysis scenarios.") + print("SQL, LIST, and REGEX scenarios work without it.\n") + + sql_tool = create_sql_tool() + analysis_tool = create_analysis_tool() + + # ════════════════════════════════════════════════════════════════ + # SQL EVALUATOR SCENARIOS + # ════════════════════════════════════════════════════════════════ + print("\n" + "#" * 60) + print(" PART 1: SQL EVALUATOR") + print(" Validates queries before they reach the database") + print("#" * 60) + + # 1a. Safe query + run_direct_test( + "1a. Safe SELECT with LIMIT", + "SQL", + sql_tool, + {"query": "SELECT order_id, product, total FROM orders LIMIT 10"}, + "ALLOWED - safe read-only query with LIMIT", + ) + + # 1b. DROP TABLE injection + run_direct_test( + "1b. DROP TABLE Injection", + "SQL", + sql_tool, + {"query": "DROP TABLE orders; SELECT * FROM users LIMIT 5"}, + "DENIED - DROP is a blocked operation + multi-statement", + ) + + # 1c. Missing LIMIT clause + run_direct_test( + "1c. SELECT Without LIMIT", + "SQL", + sql_tool, + {"query": "SELECT * FROM orders"}, + "DENIED - require_limit is enforced", + ) + + # 1d. DELETE attempt + run_direct_test( + "1d. DELETE Attempt", + "SQL", + sql_tool, + {"query": "DELETE FROM orders WHERE status = 'cancelled'"}, + "DENIED - DELETE is a blocked operation", + ) + + # ════════════════════════════════════════════════════════════════ + # LIST EVALUATOR SCENARIOS + # ════════════════════════════════════════════════════════════════ + print("\n" + "#" * 60) + print(" PART 2: LIST EVALUATOR") + print(" Restricts access to sensitive tables") + print("#" * 60) + + # 2a. Query allowed table + run_direct_test( + "2a. Query Public Table (orders)", + "LIST", + sql_tool, + {"query": "SELECT * FROM orders LIMIT 10"}, + "ALLOWED - 'orders' is not in the restricted list", + ) + + # 2b. Query restricted table + run_direct_test( + "2b. Query Restricted Table (salary_data)", + "LIST", + sql_tool, + {"query": "SELECT * FROM salary_data LIMIT 10"}, + "DENIED - 'salary_data' is in the restricted table list", + ) + + # 2c. Query another restricted table + run_direct_test( + "2c. Query Restricted Table (audit_log)", + "LIST", + sql_tool, + {"query": "SELECT * FROM audit_log LIMIT 5"}, + "DENIED - 'audit_log' is in the restricted table list", + ) + + # ════════════════════════════════════════════════════════════════ + # REGEX EVALUATOR SCENARIOS + # ════════════════════════════════════════════════════════════════ + print("\n" + "#" * 60) + print(" PART 3: REGEX EVALUATOR") + print(" Scans query results for PII patterns (post-execution)") + print("#" * 60) + + # 3a. Clean results + run_direct_test( + "3a. Query With Clean Results", + "REGEX (POST)", + sql_tool, + {"query": "SELECT order_id, product, total FROM orders LIMIT 10"}, + "ALLOWED - results contain no PII patterns", + ) + + # 3b. Results contain PII (SSN, email) + run_direct_test( + "3b. Query Returns PII (SSN + Email)", + "REGEX (POST)", + sql_tool, + {"query": "SELECT * FROM customers_full LIMIT 10"}, + "DENIED - SSN (123-45-6789) and email detected in results", + ) + + # ════════════════════════════════════════════════════════════════ + # JSON EVALUATOR SCENARIOS + # ════════════════════════════════════════════════════════════════ + if os.getenv("OPENAI_API_KEY"): + print("\n" + "#" * 60) + print(" PART 4: JSON EVALUATOR") + print(" Validates analysis request structure and constraints") + print("#" * 60) + + # 4a. Valid request + run_direct_test( + "4a. Valid Analysis Request (all fields present)", + "JSON", + analysis_tool, + { + "request": json.dumps( + { + "dataset": "sales_q4", + "date_range": "2024-10-01 to 2024-12-31", + "max_rows": 5000, + "purpose": "Quarterly revenue analysis for board meeting", + } + ) + }, + "ALLOWED - all required fields present with valid constraints", + ) + + # 4b. Missing required field (date_range) + run_direct_test( + "4b. Missing Required Field (date_range)", + "JSON", + analysis_tool, + { + "request": json.dumps( + { + "dataset": "sales_q4", + "max_rows": 500, + "purpose": "Quick check", + } + ) + }, + "DENIED - 'date_range' is a required field", + ) + + # 4c. max_rows exceeds constraint + run_direct_test( + "4c. Field Constraint Violation (max_rows > 10000)", + "JSON", + analysis_tool, + { + "request": json.dumps( + { + "dataset": "full_export", + "date_range": "2024-01-01 to 2024-12-31", + "max_rows": 50000, + "purpose": "Full year export", + } + ) + }, + "DENIED - max_rows exceeds maximum of 10000", + ) + + # 4d. Missing purpose → STEER (then auto-fill and retry) + run_direct_test( + "4d. Missing Purpose -> STEER (auto-fill and retry)", + "JSON (STEER)", + analysis_tool, + { + "request": json.dumps( + { + "dataset": "inventory", + "date_range": "2024-11-01 to 2024-11-30", + "max_rows": 1000, + } + ) + }, + "STEERED to collect purpose, then ALLOWED after auto-fill", + ) + + # ── Full CrewAI Crew Demo ─────────────────────────────────────── + if os.getenv("OPENAI_API_KEY"): + print("\n" + "#" * 60) + print(" PART 5: FULL CREWAI CREW") + print(" Agent autonomously handles a multi-step data request") + print("#" * 60) + + crew = create_analyst_crew(sql_tool, analysis_tool) + + print("\n Running crew with a safe data request...") + result = crew.kickoff( + inputs={ + "request": ( + "Query the orders table for the top 5 orders by total amount, " + "then analyze the sales_q4 dataset for the date range " + "2024-10-01 to 2024-12-31 with max 2000 rows. " + "The purpose is quarterly sales performance review." + ) + } + ) + print(f"\n Crew Result: {str(result)[:300]}") + + # ── Summary ───────────────────────────────────────────────────── + print("\n" + "=" * 60) + print(" Demo Complete!") + print("=" * 60) + print(""" + Evaluators Demonstrated: + + SQL EVALUATOR (input validation): + - Blocked DROP TABLE injection (destructive operation) + - Blocked SELECT without LIMIT (require_limit enforced) + - Blocked DELETE statement (blocked operation) + - Allowed safe SELECT with LIMIT (passed all checks) + + LIST EVALUATOR (access control): + - Blocked query to salary_data (restricted table) + - Blocked query to audit_log (restricted table) + - Allowed query to orders (not restricted) + + REGEX EVALUATOR (output scanning): + - Blocked results with SSN + email (PII detected post-execution) + - Allowed clean results (no PII patterns found) + + JSON EVALUATOR (request validation): + - Blocked missing required field (date_range absent) + - Blocked constraint violation (max_rows > 10000) + - Steered to collect missing purpose (STEER action + retry) + - Allowed valid complete request (all fields valid) + + Key Insight: + Each evaluator serves a different purpose: + SQL -> Structural query safety (BEFORE execution) + LIST -> Access control / allowlists / blocklists + REGEX -> Pattern detection in free-text (AFTER execution) + JSON -> Schema validation with constraints +""") + + +if __name__ == "__main__": + main() diff --git a/examples/crewai/evaluator_showcase/pyproject.toml b/examples/crewai/evaluator_showcase/pyproject.toml new file mode 100644 index 00000000..2142c2c3 --- /dev/null +++ b/examples/crewai/evaluator_showcase/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "agent-control-crewai-evaluator-showcase" +version = "0.1.0" +description = "CrewAI agent demonstrating all built-in evaluators: regex, list, json, and sql" +requires-python = ">=3.12" +dependencies = [ + "agent-control-sdk>=6.3.0", + "crewai>=0.80.0", + "crewai-tools>=0.12.0", + "openai>=1.0.0", + "python-dotenv>=1.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +include = ["*.py"] diff --git a/examples/crewai/evaluator_showcase/setup_controls.py b/examples/crewai/evaluator_showcase/setup_controls.py new file mode 100644 index 00000000..3865cb16 --- /dev/null +++ b/examples/crewai/evaluator_showcase/setup_controls.py @@ -0,0 +1,287 @@ +""" +Setup script for the CrewAI Evaluator Showcase. + +Creates controls using ALL four built-in evaluators in a realistic +data-analyst agent scenario: + + REGEX - Block PII patterns (SSN, credit cards) in query results + LIST - Restrict access to sensitive database tables + JSON - Validate query parameters have required fields and constraints + SQL - Prevent dangerous SQL operations (DROP, DELETE, multi-statement) + +Run once before running the demo: + uv run python setup_controls.py +""" + +import asyncio +import os + +from agent_control import Agent, AgentControlClient, agents, controls + +AGENT_NAME = "crewai-data-analyst" +SERVER_URL = os.getenv("AGENT_CONTROL_URL", "http://localhost:8000") + + +async def setup(): + async with AgentControlClient(base_url=SERVER_URL) as client: + # ── Register Agent ────────────────────────────────────────────── + agent = Agent( + agent_name=AGENT_NAME, + agent_description=( + "CrewAI data analyst agent that queries databases with " + "controls on SQL safety, PII leakage, table access, and data validation" + ), + ) + + try: + await agents.register_agent(client, agent, steps=[]) + print(f" Agent registered: {AGENT_NAME}") + except Exception as e: + if "already exists" in str(e).lower() or "409" in str(e): + print(f" Agent already registered: {AGENT_NAME}") + else: + raise + + # ── Control Definitions ───────────────────────────────────────── + control_defs = [ + # ┌─────────────────────────────────────────────────────────┐ + # │ SQL EVALUATOR │ + # │ Validates SQL queries for safety before execution. │ + # │ Blocks DROP/DELETE/TRUNCATE, enforces LIMIT, │ + # │ prevents multi-statement injection. │ + # └─────────────────────────────────────────────────────────┘ + ( + "sql-safety-check", + { + "description": ( + "Validate SQL queries: block destructive operations, " + "enforce LIMIT, prevent multi-statement injection" + ), + "enabled": True, + "execution": "server", + "scope": { + "step_types": ["tool"], + "step_names": ["run_sql_query"], + "stages": ["pre"], + }, + "selector": {"path": "input.query"}, + "evaluator": { + "name": "sql", + "config": { + "blocked_operations": [ + "DROP", + "DELETE", + "TRUNCATE", + "ALTER", + "GRANT", + "INSERT", + "UPDATE", + ], + "allow_multi_statements": False, + "require_limit": True, + "max_limit": 100, + }, + }, + "action": {"decision": "deny"}, + }, + ), + # ┌─────────────────────────────────────────────────────────┐ + # │ LIST EVALUATOR │ + # │ Restricts which tables the agent can query. │ + # │ Contains-match against the SQL query text to catch │ + # │ references to sensitive tables. │ + # └─────────────────────────────────────────────────────────┘ + ( + "restrict-sensitive-tables", + { + "description": ( + "Block queries that reference sensitive tables: " + "audit_log, admin_users, payment_methods, salary_data" + ), + "enabled": True, + "execution": "server", + "scope": { + "step_types": ["tool"], + "step_names": ["run_sql_query"], + "stages": ["pre"], + }, + "selector": {"path": "input.query"}, + "evaluator": { + "name": "list", + "config": { + "values": [ + "audit_log", + "admin_users", + "payment_methods", + "salary_data", + ], + "logic": "any", + "match_mode": "contains", + "case_sensitive": False, + }, + }, + "action": {"decision": "deny"}, + }, + ), + # ┌─────────────────────────────────────────────────────────┐ + # │ REGEX EVALUATOR │ + # │ Scans query RESULTS for PII patterns after execution. │ + # │ Catches SSNs, credit card numbers, and email │ + # │ addresses that might leak through query results. │ + # └─────────────────────────────────────────────────────────┘ + ( + "pii-in-query-results", + { + "description": ( + "Block query results containing PII: " + "SSN, credit card numbers, or email addresses" + ), + "enabled": True, + "execution": "server", + "scope": { + "step_types": ["tool"], + "step_names": ["run_sql_query"], + "stages": ["post"], + }, + "selector": {"path": "output"}, + "evaluator": { + "name": "regex", + "config": { + "pattern": ( + r"(?:" + r"\b\d{3}-\d{2}-\d{4}\b" # SSN + r"|\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b" # Credit card + r"|[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}" # Email + r")" + ) + }, + }, + "action": {"decision": "deny"}, + }, + ), + # ┌─────────────────────────────────────────────────────────┐ + # │ JSON EVALUATOR │ + # │ Validates the analysis request structure before the │ + # │ agent starts working. Ensures required fields exist │ + # │ and constraints are met (date range, row limit). │ + # └─────────────────────────────────────────────────────────┘ + ( + "validate-analysis-request", + { + "description": ( + "Validate analysis request: require dataset, date_range, " + "and max_rows fields with proper constraints" + ), + "enabled": True, + "execution": "server", + "scope": { + "step_types": ["tool"], + "step_names": ["analyze_data"], + "stages": ["pre"], + }, + "selector": {"path": "input.request"}, + "evaluator": { + "name": "json", + "config": { + "required_fields": ["dataset", "date_range"], + "field_constraints": { + "max_rows": { + "type": "number", + "min": 1, + "max": 10000, + } + }, + }, + }, + "action": {"decision": "deny"}, + }, + ), + # ┌─────────────────────────────────────────────────────────┐ + # │ JSON EVALUATOR (STEER) │ + # │ When analysis request is missing the optional │ + # │ "purpose" field, steer the agent to collect it. │ + # │ This demonstrates json + steer together. │ + # └─────────────────────────────────────────────────────────┘ + ( + "steer-require-purpose", + { + "description": ( + "Steer agent to collect analysis purpose when not provided" + ), + "enabled": True, + "execution": "server", + "scope": { + "step_types": ["tool"], + "step_names": ["analyze_data"], + "stages": ["pre"], + }, + "selector": {"path": "input.request"}, + "evaluator": { + "name": "json", + "config": { + "required_fields": ["purpose"], + }, + }, + "action": { + "decision": "steer", + "steering_context": { + "message": ( + '{"required_actions": ["collect_purpose"],' + ' "reason": "Every data analysis must include a stated purpose' + ' for audit compliance. Please add a purpose field.",' + ' "retry_with": {"purpose": ""}}' + ) + }, + }, + }, + ), + ] + + # ── Create Controls & Associate with Agent ────────────────────── + control_ids = [] + for name, data in control_defs: + evaluator = data["evaluator"]["name"].upper() + decision = data["action"]["decision"].upper() + try: + result = await controls.create_control(client, name=name, data=data) + cid = result["control_id"] + print(f" [{evaluator:5s}|{decision:5s}] Created: {name} (ID: {cid})") + control_ids.append(cid) + except Exception as e: + if "409" in str(e): + clist = await controls.list_controls(client, name=name, limit=1) + if clist["controls"]: + cid = clist["controls"][0]["id"] + print(f" [{evaluator:5s}|EXIST] Already exists: {name} (ID: {cid})") + control_ids.append(cid) + else: + raise + + print() + for cid in control_ids: + try: + await agents.add_agent_control(client, AGENT_NAME, cid) + except Exception: + pass # Already associated + + print(f" All {len(control_ids)} controls associated with agent") + + # ── Summary ───────────────────────────────────────────────────── + print() + print("Setup complete! Evaluators configured:") + print() + print(" SQL - Block destructive ops, enforce LIMIT, prevent injection") + print(" LIST - Restrict access to sensitive tables") + print(" REGEX - Detect PII (SSN, credit cards, emails) in results") + print(" JSON - Validate request structure and field constraints") + print(" JSON - Steer agent to provide analysis purpose") + print() + print("Run the demo: uv run python data_analyst.py") + + +if __name__ == "__main__": + print("=" * 60) + print(" CrewAI Data Analyst - Evaluator Showcase Setup") + print("=" * 60) + print() + asyncio.run(setup()) diff --git a/examples/crewai/secure_research_crew/.env.example b/examples/crewai/secure_research_crew/.env.example new file mode 100644 index 00000000..a3e138cd --- /dev/null +++ b/examples/crewai/secure_research_crew/.env.example @@ -0,0 +1,5 @@ +# OpenAI API key for CrewAI agents +OPENAI_API_KEY=your-openai-api-key-here + +# Agent Control server URL (default: http://localhost:8000) +AGENT_CONTROL_URL=http://localhost:8000 diff --git a/examples/crewai/secure_research_crew/README.md b/examples/crewai/secure_research_crew/README.md new file mode 100644 index 00000000..a3dc20df --- /dev/null +++ b/examples/crewai/secure_research_crew/README.md @@ -0,0 +1,125 @@ +# Secure Research Crew -- CrewAI Multi-Agent with Per-Agent Policies + +A production-quality example of a 3-agent CrewAI crew where each agent has its own Agent Control policy with distinct controls. + +## What It Demonstrates + +- **Per-agent policies**: Different controls for different agent roles, all assigned to a single runtime agent and differentiated by `step_names` in control scopes. +- **Multiple evaluator types**: SQL, LIST, JSON, JSON Schema, and REGEX evaluators working together. +- **Deny and steer actions**: Hard blocks for security violations, corrective steering for recoverable issues. +- **Idempotent setup**: The setup script handles 409 conflicts gracefully and can be run repeatedly. + +## Architecture + +``` + Agent Control Server + +---------------------------------+ + | data-access-policy | + | researcher-sql-safety [deny] | + | researcher-restricted [deny] | + | | + | analysis-validation-policy | + | analyst-required-fields [deny] | + | analyst-methodology [steer]| + | | + | content-safety-policy | + | writer-pii-blocker [deny] | + +---------------------------------+ + | + @control() decorator + | + +------------------------------------------------------------+ + | CrewAI Sequential Crew | + | | + | +--------------+ +--------------+ +---------------+ | + | | Researcher |-->| Analyst |-->| Writer | | + | | | | | | | | + | | query_database| | validate_data| | write_report | | + | | (step_name) | | (step_name) | | (step_name) | | + | +--------------+ +--------------+ +---------------+ | + +------------------------------------------------------------+ +``` + +Each tool's `step_name` matches the `step_names` in its corresponding control scope, so the SQL evaluator only fires for `query_database`, the JSON evaluator only fires for `validate_data`, etc. + +## Scenarios + +| # | Scenario | Agent | Control | Evaluator | Action | Expected | +|---|----------|-------|---------|-----------|--------|----------| +| 1 | Happy path | All | All | All | allow | Report generated | +| 2 | SQL injection | Researcher | researcher-sql-safety | SQL | deny | Query blocked | +| 3 | Restricted table | Researcher | researcher-restricted-tables | LIST | deny | Query blocked | +| 4 | Missing methodology | Analyst | analyst-methodology-check | JSON Schema | steer | Auto-corrected, then passes | +| 5 | PII in report | Writer | writer-pii-blocker | REGEX | deny | Report blocked | + +## Prerequisites + +- **Python 3.12+** +- **uv** (`curl -LsSf https://astral.sh/uv/install.sh | sh`) +- **Docker** for PostgreSQL (required by Agent Control server) +- **Agent Control server** running (`make server-run` from monorepo root) + +## Running + +### 1. Install dependencies + +From the monorepo root: + +```bash +make sync +``` + +Then from this directory: + +```bash +cd examples/crewai/secure_research_crew +uv pip install -e . --upgrade +``` + +### 2. Start the Agent Control server + +In a separate terminal from the monorepo root: + +```bash +make server-run +``` + +### 3. Create controls and policies (one-time, idempotent) + +```bash +uv run python setup_controls.py +``` + +### 4. Run the demo + +```bash +uv run python research_crew.py +``` + +Scenarios 1-5 run with direct tool calls (no LLM needed). The optional full crew run at the end requires `OPENAI_API_KEY`. + +## How It Works + +### Single Agent, Multiple Policies + +The SDK only supports one `agent_control.init()` call per process, so all three CrewAI agents share a single Agent Control agent identity (`secure-research-crew`). Each policy's controls target specific `step_names`: + +- `query_database` -- matched by controls in `data-access-policy` +- `validate_data` -- matched by controls in `analysis-validation-policy` +- `write_report` -- matched by controls in `content-safety-policy` + +### Steering Retry Pattern + +When a steer control fires (e.g., missing methodology), the tool catches the `ControlSteerError`, parses the structured JSON steering context, applies corrections, and retries up to 3 times: + +```python +except ControlSteerError as e: + guidance = json.loads(e.steering_context) + for key, hint in guidance.get("retry_with", {}).items(): + current_request[key] = "auto-generated value" + continue # retry +``` + +### Direct Tool Testing + +Scenarios 2-5 call tools directly (bypassing CrewAI's LLM orchestration) to demonstrate control behavior without incurring API costs. The full crew run is optional and exercises the same controls through CrewAI's agent loop. diff --git a/examples/crewai/secure_research_crew/pyproject.toml b/examples/crewai/secure_research_crew/pyproject.toml new file mode 100644 index 00000000..a7ff257e --- /dev/null +++ b/examples/crewai/secure_research_crew/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "agent-control-crewai-research-crew" +version = "0.1.0" +description = "CrewAI multi-agent research crew with per-agent policies via Agent Control" +requires-python = ">=3.12" +dependencies = [ + "agent-control-sdk>=6.3.0", + "crewai>=0.80.0", + "crewai-tools>=0.12.0", + "openai>=1.0.0", + "python-dotenv>=1.0.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +include = ["*.py"] diff --git a/examples/crewai/secure_research_crew/research_crew.py b/examples/crewai/secure_research_crew/research_crew.py new file mode 100644 index 00000000..e4c920e1 --- /dev/null +++ b/examples/crewai/secure_research_crew/research_crew.py @@ -0,0 +1,628 @@ +""" +Secure Research Crew -- CrewAI multi-agent demo with per-agent Agent Control policies. + +A 3-agent sequential crew where each agent has different controls: + + 1. Researcher -- queries a simulated database + Controls: SQL evaluator (block DROP/DELETE, enforce LIMIT) + LIST evaluator (block sensitive tables) + + 2. Analyst -- validates and processes research data + Controls: JSON evaluator (require dataset, findings, confidence_score) + JSON schema steer (add methodology if missing) + + 3. Writer -- generates the final report + Controls: REGEX evaluator (block PII in output) + Client-side citation check (steer if missing) + +Scenarios: + 1. Happy path -- all agents pass, report generated + 2. Researcher blocked -- SQL injection attempt (DROP TABLE) + 3. Researcher restricted-- query to salary_data table + 4. Analyst steered -- missing methodology, corrected, then succeeds + 5. Writer blocked -- PII in report output + +PREREQUISITE: + uv run python setup_controls.py + +Usage: + uv run python research_crew.py +""" + +import asyncio +import json +import os +import re + +import agent_control +from agent_control import ControlSteerError, ControlViolationError, control +from crewai import Agent, Crew, Task +from crewai.tools import tool + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +AGENT_NAME = "secure-research-crew" +SERVER_URL = os.getenv("AGENT_CONTROL_URL", "http://localhost:8000") + +agent_control.init( + agent_name=AGENT_NAME, + agent_description="Multi-agent research crew with per-agent policies", + server_url=SERVER_URL, +) + +# --------------------------------------------------------------------------- +# Simulated database (no real DB needed) +# --------------------------------------------------------------------------- + +SIMULATED_DB = { + "employees": [ + {"id": 1, "name": "Alice Johnson", "department": "Engineering", "role": "Senior Engineer"}, + {"id": 2, "name": "Bob Williams", "department": "Marketing", "role": "Marketing Lead"}, + {"id": 3, "name": "Carol Davis", "department": "Engineering", "role": "Staff Engineer"}, + ], + "projects": [ + {"id": 101, "name": "Project Alpha", "status": "active", "budget": 150000}, + {"id": 102, "name": "Project Beta", "status": "completed", "budget": 80000}, + {"id": 103, "name": "Project Gamma", "status": "active", "budget": 220000}, + ], + "quarterly_metrics": [ + {"quarter": "Q1", "revenue": 2400000, "growth": 0.12}, + {"quarter": "Q2", "revenue": 2700000, "growth": 0.15}, + {"quarter": "Q3", "revenue": 3100000, "growth": 0.18}, + ], +} + + +def simulate_query(query: str) -> str: + """Return simulated data based on query content.""" + q = query.lower() + if "employee" in q or "staff" in q: + rows = SIMULATED_DB["employees"] + elif "project" in q: + rows = SIMULATED_DB["projects"] + elif "metric" in q or "revenue" in q or "quarterly" in q: + rows = SIMULATED_DB["quarterly_metrics"] + else: + rows = SIMULATED_DB["quarterly_metrics"] # default + + # Respect LIMIT if present + limit_match = re.search(r"limit\s+(\d+)", q) + if limit_match: + limit = int(limit_match.group(1)) + rows = rows[:limit] + + return json.dumps(rows, indent=2) + + +# =========================================================================== +# Tool 1: Researcher -- query_database +# =========================================================================== + +async def _query_database(query: str) -> str: + """Execute a database query (simulated). Protected by Agent Control.""" + return simulate_query(query) + + +# Mark as tool for step detection +_query_database.name = "query_database" # type: ignore[attr-defined] +_query_database.tool_name = "query_database" # type: ignore[attr-defined] + +# Apply @control() decorator +_controlled_query_database = control()(_query_database) + + +@tool("query_database") +def query_database(query: str) -> str: + """Query the research database. Input must be a SQL SELECT statement with a LIMIT clause. + Dangerous operations (DROP, DELETE, TRUNCATE) are blocked. + Access to sensitive tables (salary_data, admin_users) is denied. + + Args: + query: A SQL SELECT query string. + """ + print(f"\n [Researcher] Executing query: {query[:80]}...") + try: + result = asyncio.run(_controlled_query_database(query=query)) + print(" [Researcher] Query succeeded.") + return result + except ControlViolationError as e: + msg = f"BLOCKED by data-access-policy: {e.message}" + print(f" [Researcher] {msg}") + return msg + except RuntimeError as e: + msg = f"ERROR: {e}" + print(f" [Researcher] {msg}") + return msg + + +# =========================================================================== +# Tool 2: Analyst -- validate_data +# =========================================================================== + +async def _validate_data(request: dict) -> str: + """Validate and process research data. Protected by Agent Control. + + The request dict should contain: + - dataset: str + - findings: str + - confidence_score: float (0-1) + - methodology: str (optional, but will be steered if missing) + """ + # If we reach here, controls passed -- produce analysis output + return json.dumps({ + "status": "validated", + "dataset": request.get("dataset"), + "findings": request.get("findings"), + "confidence_score": request.get("confidence_score"), + "methodology": request.get("methodology", ""), + "summary": ( + f"Analysis of '{request.get('dataset')}' confirms findings with " + f"confidence {request.get('confidence_score')}. " + f"Methodology: {request.get('methodology', 'N/A')}." + ), + }, indent=2) + + +_validate_data.name = "validate_data" # type: ignore[attr-defined] +_validate_data.tool_name = "validate_data" # type: ignore[attr-defined] + +_controlled_validate_data = control()(_validate_data) + + +@tool("validate_data") +def validate_data(request_json: str) -> str: + """Validate research data. Input must be a JSON string with fields: + dataset (str), findings (str), confidence_score (float 0-1). + A methodology field is recommended -- you will be asked to add one if missing. + + Args: + request_json: JSON string with the analysis request. + """ + try: + request = json.loads(request_json) + except json.JSONDecodeError: + return "ERROR: Input must be valid JSON." + + print(f"\n [Analyst] Validating data for dataset: {request.get('dataset', '?')}") + + max_attempts = 3 + current_request = dict(request) + + for attempt in range(1, max_attempts + 1): + try: + result = asyncio.run(_controlled_validate_data(request=current_request)) + print(f" [Analyst] Validation passed (attempt {attempt}).") + return result + except ControlViolationError as e: + msg = f"BLOCKED by analysis-validation-policy: {e.message}" + print(f" [Analyst] {msg}") + return msg + except ControlSteerError as e: + print(f" [Analyst] STEERED (attempt {attempt}): {e.message}") + # Parse steering context for corrective instructions + try: + guidance = json.loads(e.steering_context) + except (json.JSONDecodeError, TypeError): + guidance = {"reason": e.steering_context} + + reason = guidance.get("reason", "Correction required") + print(f" [Analyst] Steering reason: {reason}") + + # Apply corrections + retry_with = guidance.get("retry_with", {}) + for key, hint in retry_with.items(): + if key not in current_request or not current_request[key]: + # Auto-fill with a reasonable default + if key == "methodology": + current_request[key] = ( + "Data collected via automated database queries. " + "Validated through cross-referencing multiple tables " + "and statistical confidence scoring." + ) + else: + current_request[key] = hint + print(f" [Analyst] Added missing field '{key}'.") + continue + + return "ERROR: Failed to pass validation after max attempts." + + +# =========================================================================== +# Tool 3: Writer -- write_report +# =========================================================================== + +async def _write_report(content: str, sources: str = "") -> str: + """Generate a formatted report. Protected by Agent Control. + + The @control decorator sends the output to the server for PII checking. + """ + # Build the report body + report = f"""Research Report +{'=' * 40} + +{content} +""" + if sources: + report += f""" +Sources: +{sources} +""" + return report + + +_write_report.name = "write_report" # type: ignore[attr-defined] +_write_report.tool_name = "write_report" # type: ignore[attr-defined] + +_controlled_write_report = control()(_write_report) + + +@tool("write_report") +def write_report(content: str) -> str: + """Generate the final research report. The content should NOT contain + PII (social security numbers, email addresses, phone numbers). + Include a 'Sources:' section at the end for citations. + + Args: + content: The report body text, including a Sources section. + """ + # Split content and sources if the LLM included them together + sources = "" + for marker in ["Sources:", "References:", "Citations:"]: + if marker in content: + parts = content.split(marker, 1) + content = parts[0].strip() + sources = parts[1].strip() + break + + print(f"\n [Writer] Generating report ({len(content)} chars)...") + + max_attempts = 3 + current_content = content + current_sources = sources + + for attempt in range(1, max_attempts + 1): + try: + result = asyncio.run( + _controlled_write_report(content=current_content, sources=current_sources) + ) + + # Client-side citation check: steer if no sources section + if not current_sources: + print(f" [Writer] STEERED (attempt {attempt}): Report lacks source citations.") + current_sources = "Internal database queries (employees, projects, quarterly_metrics tables)" + print(" [Writer] Added default citation sources.") + # Re-run with sources added + result = asyncio.run( + _controlled_write_report(content=current_content, sources=current_sources) + ) + + print(f" [Writer] Report generated successfully (attempt {attempt}).") + return result + + except ControlViolationError as e: + msg = f"BLOCKED by content-safety-policy: {e.message}" + print(f" [Writer] {msg}") + return msg + + except ControlSteerError as e: + print(f" [Writer] STEERED (attempt {attempt}): {e.message}") + try: + guidance = json.loads(e.steering_context) + except (json.JSONDecodeError, TypeError): + guidance = {"reason": e.steering_context} + print(f" [Writer] Steering reason: {guidance.get('reason', e.steering_context)}") + continue + + return "ERROR: Failed to generate report after max attempts." + + +# =========================================================================== +# Scenario runner (direct tool calls -- avoids LLM costs for testing) +# =========================================================================== + +def header(title: str) -> None: + """Print a scenario header.""" + print("\n" + "=" * 70) + print(f" {title}") + print("=" * 70) + + +def run_scenario_1_happy_path(): + """Happy path: all three tools pass controls, report generated.""" + header("SCENARIO 1: Happy Path -- All Agents Pass Controls") + + # Step 1: Researcher queries safely + print("\n--- Step 1: Researcher queries database ---") + data = query_database.run("SELECT name, department FROM employees LIMIT 10") + print(f" Result: {data[:120]}...") + + # Step 2: Analyst validates with all required fields + methodology + print("\n--- Step 2: Analyst validates data ---") + analysis_request = json.dumps({ + "dataset": "employees", + "findings": "Engineering department has 2 senior staff members", + "confidence_score": 0.92, + "methodology": "Cross-referenced employee records with department roster", + }) + validated = validate_data.run(analysis_request) + print(f" Result: {validated[:120]}...") + + # Step 3: Writer generates clean report + print("\n--- Step 3: Writer generates report ---") + report_content = ( + "The engineering department analysis reveals 2 senior staff members " + "with strong project involvement. Project Alpha and Gamma are active " + "with combined budgets exceeding $370,000. Quarterly revenue shows " + "consistent 12-18% growth.\n\n" + "Sources: Internal database (employees, projects, quarterly_metrics)" + ) + report = write_report.run(report_content) + print(f" Result:\n{report}") + + +def run_scenario_2_sql_injection(): + """Researcher blocked: SQL injection attempt with DROP TABLE.""" + header("SCENARIO 2: Researcher Blocked -- SQL Injection Attempt") + + result = query_database.run("DROP TABLE employees; SELECT * FROM employees LIMIT 10") + print(f" Result: {result}") + assert "BLOCKED" in result, "Expected the query to be blocked!" + print("\n [OK] SQL injection correctly blocked by data-access-policy") + + +def run_scenario_3_restricted_table(): + """Researcher restricted: query to salary_data table denied.""" + header("SCENARIO 3: Researcher Restricted -- Sensitive Table Access") + + result = query_database.run("SELECT * FROM salary_data LIMIT 10") + print(f" Result: {result}") + assert "BLOCKED" in result, "Expected the query to be blocked!" + print("\n [OK] Sensitive table access correctly blocked by data-access-policy") + + +def run_scenario_4_analyst_steered(): + """Analyst steered: methodology missing, auto-corrected, then succeeds.""" + header("SCENARIO 4: Analyst Steered -- Missing Methodology") + + # First attempt WITHOUT methodology -- should be steered, then auto-corrected + analysis_request = json.dumps({ + "dataset": "quarterly_metrics", + "findings": "Revenue growing at 15% average quarterly rate", + "confidence_score": 0.88, + # NOTE: methodology intentionally omitted + }) + result = validate_data.run(analysis_request) + print(f" Result: {result[:200]}...") + + # The tool should have auto-added methodology after steering + if "validated" in result: + print("\n [OK] Analyst was steered to add methodology and succeeded on retry") + elif "BLOCKED" in result: + print("\n [INFO] Analyst was blocked (deny control fired before steer)") + else: + print(f"\n [INFO] Result: {result}") + + +def run_scenario_5_writer_pii(): + """Writer blocked: PII detected in report output.""" + header("SCENARIO 5: Writer Blocked -- PII in Report Output") + + # Content that contains PII (email and phone number) + pii_content = ( + "The project lead is Alice Johnson. For questions, contact her at " + "alice.johnson@company.com or call 555-123-4567. " + "Her SSN is 123-45-6789.\n\n" + "Sources: HR database, project management system" + ) + result = write_report.run(pii_content) + print(f" Result: {result}") + assert "BLOCKED" in result, "Expected PII to be blocked!" + print("\n [OK] PII correctly blocked by content-safety-policy") + + +def run_full_crew(): + """Run the full 3-agent CrewAI crew for the happy path.""" + header("FULL CREW RUN: 3-Agent Sequential Pipeline") + + if not os.getenv("OPENAI_API_KEY"): + print("\n [SKIP] OPENAI_API_KEY not set -- skipping full crew run.") + print(" The direct tool scenarios above demonstrate all control behavior.") + return + + # Define agents + researcher = Agent( + role="Research Data Analyst", + goal="Query the database to gather employee and project data for analysis", + backstory=( + "You are a meticulous data researcher who queries databases to gather " + "information. You always use proper SQL with LIMIT clauses and never " + "attempt to access restricted tables." + ), + tools=[query_database], + verbose=True, + ) + + analyst = Agent( + role="Data Validation Analyst", + goal="Validate the research data and produce a structured analysis with methodology", + backstory=( + "You are a rigorous analyst who validates data quality. You always " + "include dataset name, findings, confidence scores, AND methodology " + "in your analysis. You format your output as JSON." + ), + tools=[validate_data], + verbose=True, + ) + + writer = Agent( + role="Report Writer", + goal="Generate a professional research report without any PII", + backstory=( + "You are a skilled report writer who creates clear, professional " + "research reports. You NEVER include personal information like " + "email addresses, phone numbers, or SSNs. You always cite sources." + ), + tools=[write_report], + verbose=True, + ) + + # Define tasks + research_task = Task( + description=( + "Query the employees and projects tables to gather data about " + "engineering department staffing and active projects. " + "Use the query_database tool with proper SQL SELECT statements " + "that include LIMIT clauses." + ), + expected_output="Raw data from employee and project queries in JSON format", + agent=researcher, + ) + + analysis_task = Task( + description=( + "Take the research data and validate it using the validate_data tool. " + "Submit a JSON string with these fields: dataset, findings, " + "confidence_score (0-1), and methodology. Example:\n" + '{"dataset": "employees", "findings": "...", ' + '"confidence_score": 0.9, "methodology": "..."}' + ), + expected_output="Validated analysis with methodology in JSON format", + agent=analyst, + ) + + report_task = Task( + description=( + "Write a professional research report using the write_report tool. " + "The report should summarize the validated analysis findings. " + "Do NOT include any email addresses, phone numbers, or SSNs. " + "Include a 'Sources:' section at the end citing the data tables used." + ), + expected_output="A formatted research report with findings and source citations", + agent=writer, + ) + + # Create and run crew + crew = Crew( + agents=[researcher, analyst, writer], + tasks=[research_task, analysis_task, report_task], + verbose=True, + ) + + print("\n Running crew... (this uses LLM calls)\n") + result = crew.kickoff() + print("\n" + "-" * 70) + print(" CREW OUTPUT:") + print("-" * 70) + print(result) + + +# =========================================================================== +# Verification +# =========================================================================== + +def verify_setup() -> bool: + """Check that the Agent Control server is running and controls are configured.""" + import httpx + + try: + print("[setup] Verifying Agent Control server...") + response = httpx.get(f"{SERVER_URL}/api/v1/controls", timeout=5.0) + response.raise_for_status() + + data = response.json() + control_names = [c["name"] for c in data.get("controls", [])] + + required = [ + "researcher-sql-safety", + "researcher-restricted-tables", + "analyst-required-fields", + "analyst-methodology-check", + "writer-pii-blocker", + ] + missing = [c for c in required if c not in control_names] + + if missing: + print(f"[setup] Missing controls: {missing}") + print("[setup] Run setup_controls.py first.") + return False + + print(f"[setup] Server OK -- {len(control_names)} controls found") + return True + + except httpx.ConnectError: + print(f"[setup] Cannot connect to {SERVER_URL}") + print("[setup] Start the Agent Control server first (make server-run)") + return False + except Exception as e: + print(f"[setup] Error: {e}") + return False + + +# =========================================================================== +# Main +# =========================================================================== + +def main(): + print("=" * 70) + print(" Secure Research Crew -- Agent Control Multi-Agent Demo") + print("=" * 70) + print() + print("This demo runs 5 scenarios showing how different Agent Control") + print("policies protect each agent in a CrewAI crew:") + print() + print(" 1. Happy path -- all agents pass controls") + print(" 2. SQL injection -- researcher blocked by SQL evaluator") + print(" 3. Restricted table -- researcher blocked by LIST evaluator") + print(" 4. Missing methodology-- analyst steered, then succeeds") + print(" 5. PII in report -- writer blocked by REGEX evaluator") + print() + + if not verify_setup(): + print("\nSetup verification failed. Exiting.") + return + + # Run all direct-call scenarios (no LLM needed) + run_scenario_1_happy_path() + run_scenario_2_sql_injection() + run_scenario_3_restricted_table() + run_scenario_4_analyst_steered() + run_scenario_5_writer_pii() + + # Summary + header("SUMMARY") + print(""" + Scenario 1 (Happy Path): All 3 agents passed controls + Scenario 2 (SQL Injection): Researcher BLOCKED by sql evaluator + Scenario 3 (Restricted Table): Researcher BLOCKED by list evaluator + Scenario 4 (Missing Method): Analyst STEERED, then succeeded + Scenario 5 (PII in Report): Writer BLOCKED by regex evaluator + + Controls are enforced per-agent via policies: + - data-access-policy -> query_database tool + - analysis-validation-policy -> validate_data tool + - content-safety-policy -> write_report tool + + Each policy targets specific step_names, so controls only fire + for the tools belonging to that agent role. +""") + + # Optionally run full crew (requires OPENAI_API_KEY) + print("-" * 70) + if not os.getenv("OPENAI_API_KEY"): + print(" Skipping full crew run (OPENAI_API_KEY not set).") + else: + answer = input(" Run full CrewAI crew with LLM? (y/N): ").strip().lower() + if answer == "y": + run_full_crew() + else: + print(" Skipping full crew run.") + + print("\n" + "=" * 70) + print(" Demo complete!") + print("=" * 70) + + +if __name__ == "__main__": + main() diff --git a/examples/crewai/secure_research_crew/setup_controls.py b/examples/crewai/secure_research_crew/setup_controls.py new file mode 100644 index 00000000..99bd4a1c --- /dev/null +++ b/examples/crewai/secure_research_crew/setup_controls.py @@ -0,0 +1,363 @@ +""" +Setup script for the Secure Research Crew controls and policies. + +Creates three policies (one per crew agent) with distinct controls: + - data-access-policy (Researcher): SQL safety + restricted table list + - analysis-validation-policy (Analyst): JSON field validation + steer on missing methodology + - content-safety-policy (Writer): PII regex blocking + +All controls are also directly associated with the runtime agent +("secure-research-crew") for immediate enforcement. The policies provide +organizational grouping and can be managed independently on the server. + +This script is fully idempotent -- safe to run multiple times. + +Usage: + uv run python setup_controls.py +""" + +import asyncio +import os + +from agent_control import Agent, AgentControlClient, agents, controls, policies + +AGENT_NAME = "secure-research-crew" +SERVER_URL = os.getenv("AGENT_CONTROL_URL", "http://localhost:8000") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +async def create_control_idempotent(client, name: str, data: dict) -> int: + """Create a control, handling 409 conflicts gracefully. + + On conflict, looks up the existing control by name and updates its data + to ensure the configuration matches the desired state. + """ + try: + result = await controls.create_control(client, name=name, data=data) + control_id = result["control_id"] + print(f" + Created control: {name} (ID: {control_id})") + return control_id + except Exception as e: + if "409" in str(e): + controls_list = await controls.list_controls(client, name=name, limit=1) + if controls_list["controls"]: + control_id = controls_list["controls"][0]["id"] + await controls.set_control_data(client, control_id, data) + print(f" ~ Control exists: {name} (ID: {control_id}) -- data updated") + return control_id + raise RuntimeError(f"409 conflict but could not find control '{name}'") + raise + + +async def create_policy_safe(client, name: str) -> int | None: + """Create a policy. Returns the ID on success, None on 409 conflict. + + The server does not expose a list-policies-by-name endpoint, so on + conflict we cannot reliably retrieve the existing policy ID. In that + case we return None and the caller uses direct agent-control associations + as a fallback (which are always idempotent). + """ + try: + result = await policies.create_policy(client, name=name) + policy_id = result["policy_id"] + print(f" + Created policy: {name} (ID: {policy_id})") + return policy_id + except Exception as e: + if "409" in str(e): + print(f" ~ Policy already exists: {name} (skipping policy-level association)") + # Try to extract policy_id from error response body + if hasattr(e, "response"): + try: + body = e.response.json() + if "policy_id" in body: + return body["policy_id"] + except Exception: + pass + return None + raise + + +# --------------------------------------------------------------------------- +# Control definitions +# --------------------------------------------------------------------------- + +def researcher_controls() -> list[tuple[str, dict]]: + """Controls for the Researcher agent's query_database tool.""" + sql_safety = { + "description": "Block dangerous SQL operations and enforce LIMIT clause", + "enabled": True, + "execution": "server", + "scope": { + "step_types": ["tool"], + "step_names": ["query_database"], + "stages": ["pre"], + }, + "selector": {"path": "input.query"}, + "evaluator": { + "name": "sql", + "config": { + "blocked_operations": ["DROP", "DELETE", "TRUNCATE", "ALTER", "INSERT", "UPDATE"], + "allow_multi_statements": False, + "require_limit": True, + "max_limit": 100, + }, + }, + "action": {"decision": "deny"}, + } + + restricted_tables = { + "description": "Block access to sensitive tables (salary_data, admin_users, credentials)", + "enabled": True, + "execution": "server", + "scope": { + "step_types": ["tool"], + "step_names": ["query_database"], + "stages": ["pre"], + }, + "selector": {"path": "input.query"}, + "evaluator": { + "name": "list", + "config": { + "values": ["salary_data", "admin_users", "credentials", "auth_tokens"], + "logic": "any", + "match_mode": "contains", + "case_sensitive": False, + }, + }, + "action": {"decision": "deny"}, + } + + return [ + ("researcher-sql-safety", sql_safety), + ("researcher-restricted-tables", restricted_tables), + ] + + +def analyst_controls() -> list[tuple[str, dict]]: + """Controls for the Analyst agent's validate_data tool.""" + required_fields = { + "description": "Require dataset, findings, and confidence_score in analysis", + "enabled": True, + "execution": "server", + "scope": { + "step_types": ["tool"], + "step_names": ["validate_data"], + "stages": ["pre"], + }, + "selector": {"path": "input.request"}, + "evaluator": { + "name": "json", + "config": { + "required_fields": ["dataset", "findings", "confidence_score"], + "field_constraints": { + "confidence_score": { + "type": "number", + "min": 0, + "max": 1, + } + }, + }, + }, + "action": {"decision": "deny"}, + } + + missing_methodology = { + "description": "Steer analyst to add methodology when missing", + "enabled": True, + "execution": "server", + "scope": { + "step_types": ["tool"], + "step_names": ["validate_data"], + "stages": ["pre"], + }, + "selector": {"path": "input.request"}, + "evaluator": { + "name": "json", + "config": { + "json_schema": { + "type": "object", + "oneOf": [ + { + "required": ["methodology"], + "properties": { + "methodology": {"type": "string", "minLength": 1} + }, + } + ], + } + }, + }, + "action": { + "decision": "steer", + "steering_context": { + "message": ( + '{"required_actions": ["add_methodology"], ' + '"reason": "Analysis must include a methodology section ' + 'describing how data was collected and validated.", ' + '"retry_with": {"methodology": ' + '""}}' + ) + }, + }, + } + + return [ + ("analyst-required-fields", required_fields), + ("analyst-methodology-check", missing_methodology), + ] + + +def writer_controls() -> list[tuple[str, dict]]: + """Controls for the Writer agent's write_report tool.""" + pii_regex = { + "description": "Block PII (SSN, email, phone) in report output", + "enabled": True, + "execution": "server", + "scope": { + "step_types": ["tool"], + "step_names": ["write_report"], + "stages": ["post"], + }, + "selector": {"path": "output"}, + "evaluator": { + "name": "regex", + "config": { + "pattern": ( + r"(?:" + r"\b\d{3}-\d{2}-\d{4}\b" # SSN + r"|[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}" # Email + r"|\b\d{3}[-.]?\d{3}[-.]?\d{4}\b" # Phone + r")" + ) + }, + }, + "action": {"decision": "deny"}, + } + + # NOTE: The citation-presence check is handled client-side in the tool + # wrapper (write_report in research_crew.py) because the regex evaluator + # triggers actions on match, not on non-match. The tool adds citations + # if they are missing before returning the report. + + return [ + ("writer-pii-blocker", pii_regex), + ] + + +# --------------------------------------------------------------------------- +# Main setup +# --------------------------------------------------------------------------- + +async def setup(): + """Create all controls, policies, and agent associations.""" + async with AgentControlClient(base_url=SERVER_URL) as client: + # 1. Register the runtime agent + agent = Agent( + agent_name=AGENT_NAME, + agent_description=( + "Multi-agent research crew with per-agent policies: " + "Researcher (data access), Analyst (validation), Writer (content safety)" + ), + ) + try: + await agents.register_agent(client, agent, steps=[]) + print(f"[agent] Registered: {AGENT_NAME}") + except Exception as e: + if "409" in str(e) or "already" in str(e).lower(): + print(f"[agent] Already exists: {AGENT_NAME}") + else: + raise + + # 2. Create controls grouped by role + print("\n[controls] Creating researcher controls...") + researcher_ids = [] + for name, data in researcher_controls(): + cid = await create_control_idempotent(client, name, data) + researcher_ids.append(cid) + + print("\n[controls] Creating analyst controls...") + analyst_ids = [] + for name, data in analyst_controls(): + cid = await create_control_idempotent(client, name, data) + analyst_ids.append(cid) + + print("\n[controls] Creating writer controls...") + writer_ids = [] + for name, data in writer_controls(): + cid = await create_control_idempotent(client, name, data) + writer_ids.append(cid) + + all_control_ids = researcher_ids + analyst_ids + writer_ids + + # 3. Create policies and associate controls + print("\n[policies] Creating policies and associating controls...") + + policy_groups = [ + ("data-access-policy", researcher_ids), + ("analysis-validation-policy", analyst_ids), + ("content-safety-policy", writer_ids), + ] + + for policy_name, control_ids in policy_groups: + pid = await create_policy_safe(client, policy_name) + if pid is not None: + for cid in control_ids: + await policies.add_control_to_policy(client, pid, cid) + print(f" {policy_name}: {len(control_ids)} controls linked") + # Assign policy to agent + try: + await agents.add_agent_policy(client, AGENT_NAME, pid) + print(f" {policy_name} -> {AGENT_NAME}") + except Exception as e: + if "409" in str(e) or "already" in str(e).lower(): + print(f" {policy_name} already assigned (OK)") + else: + raise + + # 4. Also add controls directly to the agent as a reliable fallback. + # Direct associations are always idempotent and guarantee the controls + # are active even if policy association had issues on re-run. + print("\n[agent] Adding direct control associations (idempotent)...") + for cid in all_control_ids: + try: + await agents.add_agent_control(client, AGENT_NAME, cid) + except Exception as e: + if "409" in str(e) or "already" in str(e).lower(): + pass # Already associated, which is fine + else: + raise + print(f" + {len(all_control_ids)} controls directly associated with {AGENT_NAME}") + + # Summary + print("\n" + "=" * 60) + print("Setup complete!") + print("=" * 60) + print(f""" +Agent: {AGENT_NAME} +Policies: 3 +Controls: {len(all_control_ids)} + + data-access-policy (Researcher -> query_database): + - researcher-sql-safety [pre, deny] Block DROP/DELETE, enforce LIMIT + - researcher-restricted-tables [pre, deny] Block salary_data, admin_users, ... + + analysis-validation-policy (Analyst -> validate_data): + - analyst-required-fields [pre, deny] Require dataset, findings, confidence_score + - analyst-methodology-check [pre, steer] Steer if methodology missing + + content-safety-policy (Writer -> write_report): + - writer-pii-blocker [post, deny] Block SSN, email, phone in output + +You can now run: uv run python research_crew.py +""") + + +if __name__ == "__main__": + print("=" * 60) + print("Secure Research Crew -- Control & Policy Setup") + print("=" * 60) + print() + asyncio.run(setup()) diff --git a/examples/crewai/steering_financial_agent/.env.example b/examples/crewai/steering_financial_agent/.env.example new file mode 100644 index 00000000..a3e138cd --- /dev/null +++ b/examples/crewai/steering_financial_agent/.env.example @@ -0,0 +1,5 @@ +# OpenAI API key for CrewAI agents +OPENAI_API_KEY=your-openai-api-key-here + +# Agent Control server URL (default: http://localhost:8000) +AGENT_CONTROL_URL=http://localhost:8000 diff --git a/examples/crewai/steering_financial_agent/README.md b/examples/crewai/steering_financial_agent/README.md new file mode 100644 index 00000000..5ae0f9ae --- /dev/null +++ b/examples/crewai/steering_financial_agent/README.md @@ -0,0 +1,83 @@ +# CrewAI Financial Agent with Steering Controls + +Demonstrates all Agent Control action types in a realistic wire-transfer scenario using CrewAI. + +## Action Types + +| Action | Behavior | Example | +|--------|----------|---------| +| **DENY** | Hard block, agent cannot recover | Sanctioned country, fraud detected | +| **STEER** | Pause execution, guide agent to correct and retry | 2FA required, manager approval needed | +| **WARN** | Log for audit, agent continues uninterrupted | New recipient, unusual activity | + +The key difference: **DENY** raises `ControlViolationError` (permanent), **STEER** raises `ControlSteerError` (recoverable), and **WARN** logs silently. + +## Scenarios + +| # | Scenario | Amount | Controls Triggered | Outcome | +|---|----------|--------|--------------------|---------| +| 1 | Small transfer to new vendor | $2,500 | warn (new recipient) | ALLOWED + audit log | +| 2 | Transfer to North Korea | $500 | deny (sanctioned country) | BLOCKED | +| 3 | Large transfer | $15,000 | steer (2FA required) | STEERED -> verified -> ALLOWED | +| 4 | Very large transfer | $75,000 | steer (2FA + manager) | STEERED -> approved -> ALLOWED | +| 5 | High fraud score | $3,000 | deny (fraud > 0.8) | BLOCKED | + +## Controls Created + +- `deny-sanctioned-countries` — LIST evaluator, blocks OFAC countries +- `deny-high-fraud-score` — JSON evaluator, blocks fraud_score > 0.8 +- `steer-require-2fa` — JSON evaluator with oneOf schema, steers for 2FA +- `steer-require-manager-approval` — JSON evaluator, steers for approval +- `warn-new-recipient` — LIST evaluator, logs unknown recipients +- `warn-pii-in-confirmation` — REGEX evaluator, logs PII in output + +## Prerequisites + +- Python 3.12+ +- Agent Control server running (`make server-run` from repo root) +- OpenAI API key + +## Running + +```bash +# From repo root — install dependencies +make sync + +# Navigate to example +cd examples/crewai/steering_financial_agent + +# Install example dependencies +uv pip install -e . --upgrade + +# Set your OpenAI key +export OPENAI_API_KEY="your-key" + +# Set up controls (one-time) +uv run python setup_controls.py + +# Run the demo +uv run python financial_agent.py +``` + +## How Steering Works + +When Agent Control returns a **steer** action, the SDK raises `ControlSteerError` with a `steering_context` containing JSON guidance: + +```json +{ + "required_actions": ["verify_2fa"], + "reason": "Transfers >= $10,000 require identity verification.", + "retry_with": {"verified_2fa": true} +} +``` + +The agent catches this error, performs the required actions (verify 2FA, get approval, etc.), then retries the same operation with the corrected parameters. This is the key pattern: + +```python +try: + result = await protected_function(amount=15000, verified_2fa=False) +except ControlSteerError as e: + guidance = json.loads(e.steering_context) + # Perform required actions... + result = await protected_function(amount=15000, verified_2fa=True) # Retry +``` diff --git a/examples/crewai/steering_financial_agent/financial_agent.py b/examples/crewai/steering_financial_agent/financial_agent.py new file mode 100644 index 00000000..b3b83ba1 --- /dev/null +++ b/examples/crewai/steering_financial_agent/financial_agent.py @@ -0,0 +1,403 @@ +""" +CrewAI Financial Agent with Steering, Deny, and Warn actions. + +Demonstrates the three key Agent Control action types in a realistic +wire-transfer scenario using a CrewAI crew: + + DENY - Sanctioned country or fraud score blocks the transfer immediately. + STEER - Large transfers pause execution and guide the agent through + 2FA verification or manager approval before retrying. + WARN - New recipients and PII in output are logged for audit + without blocking the transfer. + +PREREQUISITE: + Run setup_controls.py first: + + $ uv run python setup_controls.py + + Then run this example: + + $ uv run python financial_agent.py + +Scenarios: + 1. Small legitimate transfer -> ALLOW (warn on new recipient) + 2. Sanctioned country -> DENY (hard block) + 3. Large transfer ($15k) -> STEER (2FA required, then allowed) + 4. Very large transfer ($75k) -> STEER (manager approval, then allowed) + 5. High fraud score -> DENY (hard block) +""" + +import asyncio +import json +import os + +import agent_control +from agent_control import ControlSteerError, ControlViolationError, control +from crewai import Agent, Crew, LLM, Task +from crewai.tools import tool + +# ── Configuration ─────────────────────────────────────────────────────── +AGENT_NAME = "crewai-financial-agent" +SERVER_URL = os.getenv("AGENT_CONTROL_URL", "http://localhost:8000") + +agent_control.init( + agent_name=AGENT_NAME, + agent_description="CrewAI financial agent with steering controls", + server_url=SERVER_URL, +) + +# Simulated state — in production this comes from your auth system +SIMULATED_2FA_CODE = "482901" +SIMULATED_MANAGER = "Sarah Chen (VP Operations)" + + +# ── Protected Transfer Function ───────────────────────────────────────── + +def create_transfer_tool(): + """Build the CrewAI tool with @control protection and steering logic.""" + + llm = LLM(model="gpt-4o-mini", temperature=0.3) + + async def _process_transfer( + amount: float, + recipient: str, + destination_country: str, + fraud_score: float = 0.0, + verified_2fa: bool = False, + manager_approved: bool = False, + ) -> str: + """Process a wire transfer (protected by Agent Control).""" + prompt = f"""You are a banking operations system. Process this wire transfer +and return a short confirmation message (2-3 sentences). + +Transfer details: +- Amount: ${amount:,.2f} +- Recipient: {recipient} +- Destination: {destination_country} +- 2FA verified: {verified_2fa} +- Manager approved: {manager_approved} + +Return a professional confirmation with a reference number.""" + + return llm.call([{"role": "user", "content": prompt}]) + + # Set function metadata for @control() step detection + _process_transfer.name = "process_transfer" # type: ignore[attr-defined] + _process_transfer.tool_name = "process_transfer" # type: ignore[attr-defined] + + # Wrap with Agent Control + controlled_fn = control()(_process_transfer) + + @tool("process_transfer") + def process_transfer_tool(transfer_request: str) -> str: + """Process a wire transfer with compliance, fraud, and approval controls. + + Args: + transfer_request: JSON string with transfer details (amount, recipient, + destination_country). May also include fraud_score, verified_2fa, + manager_approved. + """ + # Parse the request — CrewAI may pass str or dict + if isinstance(transfer_request, dict): + params = transfer_request + else: + try: + params = json.loads(transfer_request) + except (json.JSONDecodeError, TypeError): + return f"Invalid transfer request format. Expected JSON, got: {transfer_request!r}" + + amount = float(params.get("amount", 0)) + recipient = params.get("recipient", "Unknown") + destination_country = params.get("destination_country", "Unknown") + fraud_score = float(params.get("fraud_score", 0.0)) + verified_2fa = params.get("verified_2fa", False) + manager_approved = params.get("manager_approved", False) + + header = ( + f"\n{'=' * 60}\n" + f" TRANSFER REQUEST: ${amount:,.2f} to {recipient} ({destination_country})\n" + f"{'=' * 60}" + ) + print(header) + + # ── Attempt loop: handles steering with retries ───────────── + max_attempts = 3 + for attempt in range(1, max_attempts + 1): + try: + print(f"\n Attempt {attempt}/{max_attempts}") + print(f" 2FA verified: {verified_2fa} | Manager approved: {manager_approved}") + print(f" Sending to Agent Control for evaluation...") + + result = asyncio.run( + controlled_fn( + amount=amount, + recipient=recipient, + destination_country=destination_country, + fraud_score=fraud_score, + verified_2fa=verified_2fa, + manager_approved=manager_approved, + ) + ) + + # If we reach here, all controls passed + print(f"\n ALLOWED - Transfer processed successfully") + return result + + except ControlViolationError as e: + # ── DENY: Permanent block, no retry ───────────────── + print(f"\n DENIED by control: {e.control_name}") + print(f" Reason: {e.message}") + return ( + f"TRANSFER BLOCKED: {e.message}\n" + f"This violation has been logged for compliance review." + ) + + except ControlSteerError as e: + # ── STEER: Agent must correct and retry ───────────── + print(f"\n STEERED by control: {e.control_name}") + + # Parse the steering guidance + try: + guidance = json.loads(e.steering_context) + except (json.JSONDecodeError, TypeError): + guidance = {"reason": str(e.steering_context)} + + reason = guidance.get("reason", "Additional verification required") + actions = guidance.get("required_actions", []) + retry_with = guidance.get("retry_with", {}) + + print(f" Reason: {reason}") + print(f" Required actions: {actions}") + + # ── Handle each required action ───────────────────── + if "verify_2fa" in actions: + print(f"\n [2FA VERIFICATION]") + print(f" Sending 2FA code to customer's registered device...") + print(f" Customer entered code: {SIMULATED_2FA_CODE}") + print(f" Code verified successfully") + verified_2fa = True + + if "collect_justification" in actions: + print(f"\n [BUSINESS JUSTIFICATION]") + print(f" Collecting justification from requestor...") + print(f' Justification: "Quarterly vendor payment per contract #QV-2024-889"') + + if "get_manager_approval" in actions: + print(f"\n [MANAGER APPROVAL]") + print(f" Routing to {SIMULATED_MANAGER} for approval...") + print(f" Manager reviewed transfer details and justification") + print(f" Approval granted by {SIMULATED_MANAGER}") + manager_approved = True + + # Apply any retry flags from steering context + if retry_with.get("verified_2fa"): + verified_2fa = True + if retry_with.get("manager_approved"): + manager_approved = True + + print(f"\n Retrying with corrected parameters...") + continue + + return "TRANSFER FAILED: Maximum steering attempts exceeded." + + return process_transfer_tool + + +# ── CrewAI Crew Setup ─────────────────────────────────────────────────── + +def create_financial_crew(): + transfer_tool = create_transfer_tool() + + banker = Agent( + role="Financial Operations Agent", + goal=( + "Process wire transfer requests accurately and comply with all " + "security and compliance controls" + ), + backstory=( + "You are a senior financial operations agent at a major bank. " + "You process wire transfers using the process_transfer tool. " + "You always pass the transfer details as a JSON string with the " + "exact fields: amount, recipient, destination_country, and " + "optionally fraud_score. You respect all security controls." + ), + tools=[transfer_tool], + verbose=True, + ) + + task = Task( + description=( + "Process this wire transfer request: {transfer_request}\n\n" + "Use the process_transfer tool with a JSON string containing " + "the transfer details. Report the outcome." + ), + expected_output="Transfer confirmation or explanation of why it was blocked", + agent=banker, + ) + + return Crew(agents=[banker], tasks=[task], verbose=True) + + +# ── Scenario Runner ───────────────────────────────────────────────────── + +def verify_server(): + """Check that Agent Control server is reachable and controls exist.""" + import httpx + + try: + r = httpx.get(f"{SERVER_URL}/api/v1/controls", timeout=5.0) + r.raise_for_status() + data = r.json() + names = [c["name"] for c in data.get("controls", [])] + required = [ + "deny-sanctioned-countries", + "deny-high-fraud-score", + "steer-require-2fa", + "steer-require-manager-approval", + "warn-new-recipient", + ] + missing = [n for n in required if n not in names] + if missing: + print(f"Missing controls: {missing}") + print("Run: uv run python setup_controls.py") + return False + print(f"Server OK - {len(names)} controls active") + return True + except Exception as e: + print(f"Cannot reach server at {SERVER_URL}: {e}") + print("Start the server: make server-run (from repo root)") + return False + + +def run_scenario(crew, number, title, request, expected): + """Run a single scenario and print results.""" + print(f"\n{'#' * 60}") + print(f" SCENARIO {number}: {title}") + print(f"{'#' * 60}") + print(f" Request: {json.dumps(request)}") + print(f" Expected: {expected}") + + result = crew.kickoff(inputs={"transfer_request": json.dumps(request)}) + + print(f"\n Result: {str(result)[:300]}") + print(f"{'#' * 60}\n") + return result + + +def main(): + print("=" * 60) + print(" CrewAI Financial Agent") + print(" Steering, Deny & Warn with Agent Control") + print("=" * 60) + print() + + if not verify_server(): + return + + if not os.getenv("OPENAI_API_KEY"): + print("\nSet OPENAI_API_KEY to run this example.") + return + + crew = create_financial_crew() + + # ── Scenario 1: Small Legitimate Transfer ─────────────────────── + # Amount < $10k, known country, low fraud → ALLOW + # Unknown recipient → WARN (logged, not blocked) + run_scenario( + crew, + 1, + "Small Legitimate Transfer (ALLOW + WARN)", + { + "amount": 2500, + "recipient": "New Vendor XYZ", + "destination_country": "Germany", + "fraud_score": 0.1, + }, + "ALLOWED with warning (new recipient logged for audit)", + ) + + # ── Scenario 2: Sanctioned Country ────────────────────────────── + # Destination is North Korea → DENY immediately + run_scenario( + crew, + 2, + "Sanctioned Country (DENY)", + { + "amount": 500, + "recipient": "Trade Partner", + "destination_country": "North Korea", + "fraud_score": 0.0, + }, + "DENIED - OFAC sanctioned country", + ) + + # ── Scenario 3: Large Transfer Requiring 2FA ──────────────────── + # Amount $15k → STEER (2FA required), agent verifies, retries → ALLOW + run_scenario( + crew, + 3, + "Large Transfer - 2FA Steering (STEER then ALLOW)", + { + "amount": 15000, + "recipient": "Acme Corp", + "destination_country": "United Kingdom", + "fraud_score": 0.2, + }, + "STEERED (2FA), then ALLOWED after verification", + ) + + # ── Scenario 4: Very Large Transfer Requiring Manager Approval ── + # Amount $75k → STEER (2FA + manager approval), agent handles both → ALLOW + run_scenario( + crew, + 4, + "Very Large Transfer - Manager Approval (STEER then ALLOW)", + { + "amount": 75000, + "recipient": "Global Suppliers Inc", + "destination_country": "Japan", + "fraud_score": 0.15, + }, + "STEERED (2FA + manager), then ALLOWED after approvals", + ) + + # ── Scenario 5: Fraud Detected ────────────────────────────────── + # Fraud score 0.95 → DENY immediately + run_scenario( + crew, + 5, + "High Fraud Score (DENY)", + { + "amount": 3000, + "recipient": "Suspicious Entity", + "destination_country": "Cayman Islands", + "fraud_score": 0.95, + }, + "DENIED - fraud score exceeds threshold", + ) + + # ── Summary ───────────────────────────────────────────────────── + print("=" * 60) + print(" Demo Complete!") + print("=" * 60) + print(""" + Action Types Demonstrated: + + DENY Sanctioned country (Scenario 2) - hard block, no recovery + High fraud score (Scenario 5) - hard block, no recovery + + STEER 2FA verification (Scenario 3) - pause, verify, retry + Manager approval (Scenario 4) - pause, collect + approve, retry + + WARN New recipient (Scenario 1) - logged for audit, not blocked + PII in output (if triggered) - logged for compliance, not blocked + + Key Differences: + DENY = ControlViolationError (agent cannot recover) + STEER = ControlSteerError (agent corrects and retries) + WARN = Logged silently (agent continues uninterrupted) +""") + + +if __name__ == "__main__": + main() diff --git a/examples/crewai/steering_financial_agent/pyproject.toml b/examples/crewai/steering_financial_agent/pyproject.toml new file mode 100644 index 00000000..ae6ef7d4 --- /dev/null +++ b/examples/crewai/steering_financial_agent/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "agent-control-crewai-steering-example" +version = "0.1.0" +description = "CrewAI financial agent with steering, deny, and warn actions via Agent Control" +requires-python = ">=3.12" +dependencies = [ + "agent-control-sdk>=6.3.0", + "crewai>=0.80.0", + "crewai-tools>=0.12.0", + "openai>=1.0.0", + "python-dotenv>=1.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +include = ["*.py"] diff --git a/examples/crewai/steering_financial_agent/setup_controls.py b/examples/crewai/steering_financial_agent/setup_controls.py new file mode 100644 index 00000000..82ab01c2 --- /dev/null +++ b/examples/crewai/steering_financial_agent/setup_controls.py @@ -0,0 +1,337 @@ +""" +Setup script for the CrewAI Financial Agent steering example. + +Creates controls that demonstrate all three non-blocking action types: +- DENY: Hard-block sanctioned countries and fraud (no recovery) +- STEER: Guide the agent through 2FA and manager approval workflows +- WARN: Flag new recipients and unusual hours for audit (no blocking) + +Run once before running the agent: + uv run python setup_controls.py +""" + +import asyncio +import os + +from agent_control import Agent, AgentControlClient, agents, controls + +AGENT_NAME = "crewai-financial-agent" +SERVER_URL = os.getenv("AGENT_CONTROL_URL", "http://localhost:8000") + + +async def setup(): + async with AgentControlClient(base_url=SERVER_URL) as client: + # ── Register Agent ────────────────────────────────────────────── + agent = Agent( + agent_name=AGENT_NAME, + agent_description=( + "CrewAI financial services agent that processes wire transfers " + "with compliance, fraud, and approval controls" + ), + ) + + try: + await agents.register_agent(client, agent, steps=[]) + print(f" Agent registered: {AGENT_NAME}") + except Exception as e: + if "already exists" in str(e).lower() or "409" in str(e): + print(f" Agent already registered: {AGENT_NAME}") + else: + raise + + # ── Control Definitions ───────────────────────────────────────── + control_defs = [ + # ┌─────────────────────────────────────────────────────────┐ + # │ DENY: Sanctioned Countries │ + # │ Uses LIST evaluator with contains matching. │ + # │ Blocks transfers to OFAC-sanctioned destinations. │ + # └─────────────────────────────────────────────────────────┘ + ( + "deny-sanctioned-countries", + { + "description": "Block transfers to OFAC-sanctioned countries", + "enabled": True, + "execution": "server", + "scope": { + "step_types": ["tool"], + "step_names": ["process_transfer"], + "stages": ["pre"], + }, + "selector": {"path": "input.destination_country"}, + "evaluator": { + "name": "list", + "config": { + "values": [ + "north korea", + "iran", + "syria", + "cuba", + "crimea", + ], + "logic": "any", + "match_mode": "contains", + "case_sensitive": False, + }, + }, + "action": {"decision": "deny"}, + }, + ), + # ┌─────────────────────────────────────────────────────────┐ + # │ DENY: Fraud Score Too High │ + # │ Uses JSON evaluator with field constraints. │ + # │ Blocks when fraud_score exceeds 0.8 threshold. │ + # └─────────────────────────────────────────────────────────┘ + ( + "deny-high-fraud-score", + { + "description": "Block transactions with fraud score above 0.8", + "enabled": True, + "execution": "server", + "scope": { + "step_types": ["tool"], + "step_names": ["process_transfer"], + "stages": ["pre"], + }, + "selector": {"path": "input"}, + "evaluator": { + "name": "json", + "config": { + "field_constraints": { + "fraud_score": {"type": "number", "max": 0.8} + } + }, + }, + "action": {"decision": "deny"}, + }, + ), + # ┌─────────────────────────────────────────────────────────┐ + # │ STEER: Large Transfer → Require 2FA │ + # │ Uses JSON evaluator with oneOf schema. │ + # │ Either amount < $10k OR amount >= $10k with 2FA. │ + # │ Provides steering_context so agent knows what to do. │ + # └─────────────────────────────────────────────────────────┘ + ( + "steer-require-2fa", + { + "description": "Require 2FA verification for transfers >= $10,000", + "enabled": True, + "execution": "server", + "scope": { + "step_types": ["tool"], + "step_names": ["process_transfer"], + "stages": ["pre"], + }, + "selector": {"path": "input"}, + "evaluator": { + "name": "json", + "config": { + "json_schema": { + "type": "object", + "oneOf": [ + { + "properties": { + "amount": { + "type": "number", + "exclusiveMaximum": 10000, + } + } + }, + { + "properties": { + "amount": { + "type": "number", + "minimum": 10000, + }, + "verified_2fa": {"const": True}, + } + }, + ] + } + }, + }, + "action": { + "decision": "steer", + "steering_context": { + "message": ( + '{"required_actions": ["verify_2fa"],' + ' "reason": "Transfers >= $10,000 require identity verification.' + " Please verify the customer's 2FA code before proceeding.\"," + ' "retry_with": {"verified_2fa": true}}' + ) + }, + }, + }, + ), + # ┌─────────────────────────────────────────────────────────┐ + # │ STEER: Very Large Transfer → Manager Approval │ + # │ Uses JSON evaluator with oneOf schema. │ + # │ Either amount < $50k OR amount >= $50k with approval. │ + # │ Multi-step: collect justification, then get approval. │ + # └─────────────────────────────────────────────────────────┘ + ( + "steer-require-manager-approval", + { + "description": "Require manager approval for transfers >= $50,000", + "enabled": True, + "execution": "server", + "scope": { + "step_types": ["tool"], + "step_names": ["process_transfer"], + "stages": ["pre"], + }, + "selector": {"path": "input"}, + "evaluator": { + "name": "json", + "config": { + "json_schema": { + "type": "object", + "oneOf": [ + { + "properties": { + "amount": { + "type": "number", + "exclusiveMaximum": 50000, + } + } + }, + { + "properties": { + "amount": { + "type": "number", + "minimum": 50000, + }, + "manager_approved": {"const": True}, + } + }, + ] + } + }, + }, + "action": { + "decision": "steer", + "steering_context": { + "message": ( + '{"required_actions": ["collect_justification", "get_manager_approval"],' + ' "reason": "Transfers >= $50,000 require manager sign-off.' + " Collect a business justification from the requestor," + ' then obtain manager approval before retrying.",' + ' "retry_with": {"manager_approved": true}}' + ) + }, + }, + }, + ), + # ┌─────────────────────────────────────────────────────────┐ + # │ WARN: New Recipient │ + # │ Uses LIST evaluator with "not in" logic. │ + # │ Logs a warning when recipient is unknown — but does │ + # │ NOT block the transfer. Useful for audit trails. │ + # └─────────────────────────────────────────────────────────┘ + ( + "warn-new-recipient", + { + "description": "Log warning for transfers to unknown recipients", + "enabled": True, + "execution": "server", + "scope": { + "step_types": ["tool"], + "step_names": ["process_transfer"], + "stages": ["pre"], + }, + "selector": {"path": "input.recipient"}, + "evaluator": { + "name": "list", + "config": { + "values": [ + "Acme Corp", + "Global Suppliers Inc", + "TechVentures LLC", + ], + "match_type": "not_in", + "case_sensitive": False, + }, + }, + "action": {"decision": "warn"}, + }, + ), + # ┌─────────────────────────────────────────────────────────┐ + # │ WARN: PII in Output │ + # │ Uses REGEX evaluator to detect leaked PII in the │ + # │ transfer confirmation. Logs for compliance review. │ + # └─────────────────────────────────────────────────────────┘ + ( + "warn-pii-in-confirmation", + { + "description": "Log warning if transfer confirmation contains PII patterns", + "enabled": True, + "execution": "server", + "scope": { + "step_types": ["tool"], + "step_names": ["process_transfer"], + "stages": ["post"], + }, + "selector": {"path": "output"}, + "evaluator": { + "name": "regex", + "config": { + "pattern": r"(?:\b\d{3}-\d{2}-\d{4}\b|\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b)" + }, + }, + "action": {"decision": "warn"}, + }, + ), + ] + + # ── Create Controls & Associate with Agent ────────────────────── + control_ids = [] + for name, data in control_defs: + try: + result = await controls.create_control(client, name=name, data=data) + cid = result["control_id"] + decision = data["action"]["decision"].upper() + print(f" [{decision:5s}] Created: {name} (ID: {cid})") + control_ids.append(cid) + except Exception as e: + if "409" in str(e): + clist = await controls.list_controls(client, name=name, limit=1) + if clist["controls"]: + cid = clist["controls"][0]["id"] + print(f" [EXIST] Already exists: {name} (ID: {cid})") + control_ids.append(cid) + else: + raise + + print() + for cid in control_ids: + try: + await agents.add_agent_control(client, AGENT_NAME, cid) + except Exception: + pass # Already associated + + print(f" All {len(control_ids)} controls associated with agent") + + # ── Summary ───────────────────────────────────────────────────── + print() + print("Setup complete! Controls created:") + print() + print(" DENY (hard block, no recovery):") + print(" - Sanctioned countries (list evaluator)") + print(" - Fraud score > 0.8 (json evaluator)") + print() + print(" STEER (guide agent, retry after correction):") + print(" - 2FA required for >= $10k (json evaluator)") + print(" - Manager approval for >= $50k (json evaluator)") + print() + print(" WARN (log for audit, no blocking):") + print(" - New/unknown recipient (list evaluator)") + print(" - PII in confirmation output (regex evaluator)") + print() + print("Run the demo: uv run python financial_agent.py") + + +if __name__ == "__main__": + print("=" * 60) + print(" CrewAI Financial Agent - Control Setup") + print("=" * 60) + print() + asyncio.run(setup()) From d3ae61a6ba781a0795ca32392c73d206e3bd92b5 Mon Sep 17 00:00:00 2001 From: Joao Moura Date: Wed, 11 Mar 2026 01:29:50 -0700 Subject: [PATCH 2/5] chore(examples): bump crewai dependency to >=1.10.1 Co-Authored-By: Claude Opus 4.6 --- examples/crewai/content_publishing_flow/pyproject.toml | 2 +- examples/crewai/evaluator_showcase/pyproject.toml | 2 +- examples/crewai/pyproject.toml | 2 +- examples/crewai/secure_research_crew/pyproject.toml | 2 +- examples/crewai/steering_financial_agent/pyproject.toml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/crewai/content_publishing_flow/pyproject.toml b/examples/crewai/content_publishing_flow/pyproject.toml index 3458001a..7a1d7b9c 100644 --- a/examples/crewai/content_publishing_flow/pyproject.toml +++ b/examples/crewai/content_publishing_flow/pyproject.toml @@ -5,7 +5,7 @@ description = "CrewAI Flow with routing, embedded crews, and Agent Control guard requires-python = ">=3.12" dependencies = [ "agent-control-sdk>=6.3.0", - "crewai>=0.80.0", + "crewai>=1.10.1", "crewai-tools>=0.12.0", "openai>=1.0.0", "python-dotenv>=1.0.0", diff --git a/examples/crewai/evaluator_showcase/pyproject.toml b/examples/crewai/evaluator_showcase/pyproject.toml index 2142c2c3..907681ad 100644 --- a/examples/crewai/evaluator_showcase/pyproject.toml +++ b/examples/crewai/evaluator_showcase/pyproject.toml @@ -5,7 +5,7 @@ description = "CrewAI agent demonstrating all built-in evaluators: regex, list, requires-python = ">=3.12" dependencies = [ "agent-control-sdk>=6.3.0", - "crewai>=0.80.0", + "crewai>=1.10.1", "crewai-tools>=0.12.0", "openai>=1.0.0", "python-dotenv>=1.0.0", diff --git a/examples/crewai/pyproject.toml b/examples/crewai/pyproject.toml index dc792673..d58bd3fb 100644 --- a/examples/crewai/pyproject.toml +++ b/examples/crewai/pyproject.toml @@ -5,7 +5,7 @@ description = "CrewAI content moderation example with Agent Control" requires-python = ">=3.12" dependencies = [ "agent-control-sdk>=6.3.0", - "crewai>=0.80.0", + "crewai>=1.10.1", "crewai-tools>=0.12.0", "openai>=1.0.0", "python-dotenv>=1.0.0", diff --git a/examples/crewai/secure_research_crew/pyproject.toml b/examples/crewai/secure_research_crew/pyproject.toml index a7ff257e..95f714dc 100644 --- a/examples/crewai/secure_research_crew/pyproject.toml +++ b/examples/crewai/secure_research_crew/pyproject.toml @@ -5,7 +5,7 @@ description = "CrewAI multi-agent research crew with per-agent policies via Agen requires-python = ">=3.12" dependencies = [ "agent-control-sdk>=6.3.0", - "crewai>=0.80.0", + "crewai>=1.10.1", "crewai-tools>=0.12.0", "openai>=1.0.0", "python-dotenv>=1.0.0", diff --git a/examples/crewai/steering_financial_agent/pyproject.toml b/examples/crewai/steering_financial_agent/pyproject.toml index ae6ef7d4..83439de0 100644 --- a/examples/crewai/steering_financial_agent/pyproject.toml +++ b/examples/crewai/steering_financial_agent/pyproject.toml @@ -5,7 +5,7 @@ description = "CrewAI financial agent with steering, deny, and warn actions via requires-python = ">=3.12" dependencies = [ "agent-control-sdk>=6.3.0", - "crewai>=0.80.0", + "crewai>=1.10.1", "crewai-tools>=0.12.0", "openai>=1.0.0", "python-dotenv>=1.0.0", From 50e1d25c4034c8aa56ee57ce53b431fe755557e8 Mon Sep 17 00:00:00 2001 From: Joao Moura Date: Wed, 11 Mar 2026 01:42:32 -0700 Subject: [PATCH 3/5] fix(examples): fix pagination limit and deferred LLM init in CrewAI examples Add ?limit=100 to all verify_setup API calls to handle pagination when many controls exist. Defer LLM creation in evaluator_showcase behind OPENAI_API_KEY check so examples work without an API key. Co-Authored-By: Claude Opus 4.6 --- examples/crewai/content_agent_protection.py | 2 +- examples/crewai/content_publishing_flow/publishing_flow.py | 2 +- examples/crewai/evaluator_showcase/data_analyst.py | 7 +++++-- examples/crewai/secure_research_crew/research_crew.py | 2 +- .../crewai/steering_financial_agent/financial_agent.py | 2 +- 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/examples/crewai/content_agent_protection.py b/examples/crewai/content_agent_protection.py index 43be4bb6..2fdfbd94 100644 --- a/examples/crewai/content_agent_protection.py +++ b/examples/crewai/content_agent_protection.py @@ -467,7 +467,7 @@ def verify_setup(): try: print("Verifying Agent Control server connection...") - response = httpx.get(f"{server_url}/api/v1/controls", timeout=5.0) + response = httpx.get(f"{server_url}/api/v1/controls?limit=100", timeout=5.0) response.raise_for_status() controls_data = response.json() diff --git a/examples/crewai/content_publishing_flow/publishing_flow.py b/examples/crewai/content_publishing_flow/publishing_flow.py index 67068735..7a6259ca 100644 --- a/examples/crewai/content_publishing_flow/publishing_flow.py +++ b/examples/crewai/content_publishing_flow/publishing_flow.py @@ -738,7 +738,7 @@ def verify_setup() -> bool: try: print("Verifying Agent Control server...") - response = httpx.get(f"{SERVER_URL}/api/v1/controls", timeout=5.0) + response = httpx.get(f"{SERVER_URL}/api/v1/controls?limit=100", timeout=5.0) response.raise_for_status() data = response.json() diff --git a/examples/crewai/evaluator_showcase/data_analyst.py b/examples/crewai/evaluator_showcase/data_analyst.py index 0e038662..5b2bfefe 100644 --- a/examples/crewai/evaluator_showcase/data_analyst.py +++ b/examples/crewai/evaluator_showcase/data_analyst.py @@ -122,7 +122,10 @@ def run_sql_query_tool(query: str) -> str: def create_analysis_tool(): """Build the analysis tool with JSON validation and steering.""" - llm = LLM(model="gpt-4o-mini", temperature=0.3) + # Defer LLM creation -- only needed if OPENAI_API_KEY is set + llm = None + if os.getenv("OPENAI_API_KEY"): + llm = LLM(model="gpt-4o-mini", temperature=0.3) async def _analyze_data(request: dict) -> str: """Run data analysis (protected by JSON validation controls). @@ -250,7 +253,7 @@ def verify_server(): import httpx try: - r = httpx.get(f"{SERVER_URL}/api/v1/controls", timeout=5.0) + r = httpx.get(f"{SERVER_URL}/api/v1/controls?limit=100", timeout=5.0) r.raise_for_status() names = [c["name"] for c in r.json().get("controls", [])] required = [ diff --git a/examples/crewai/secure_research_crew/research_crew.py b/examples/crewai/secure_research_crew/research_crew.py index e4c920e1..bd51ae94 100644 --- a/examples/crewai/secure_research_crew/research_crew.py +++ b/examples/crewai/secure_research_crew/research_crew.py @@ -528,7 +528,7 @@ def verify_setup() -> bool: try: print("[setup] Verifying Agent Control server...") - response = httpx.get(f"{SERVER_URL}/api/v1/controls", timeout=5.0) + response = httpx.get(f"{SERVER_URL}/api/v1/controls?limit=100", timeout=5.0) response.raise_for_status() data = response.json() diff --git a/examples/crewai/steering_financial_agent/financial_agent.py b/examples/crewai/steering_financial_agent/financial_agent.py index b3b83ba1..6cb6c2c2 100644 --- a/examples/crewai/steering_financial_agent/financial_agent.py +++ b/examples/crewai/steering_financial_agent/financial_agent.py @@ -245,7 +245,7 @@ def verify_server(): import httpx try: - r = httpx.get(f"{SERVER_URL}/api/v1/controls", timeout=5.0) + r = httpx.get(f"{SERVER_URL}/api/v1/controls?limit=100", timeout=5.0) r.raise_for_status() data = r.json() names = [c["name"] for c in data.get("controls", [])] From be080e1577b1e10cb8f98c0fe1917c148db75b5a Mon Sep 17 00:00:00 2001 From: Joao Moura Date: Wed, 11 Mar 2026 02:14:50 -0700 Subject: [PATCH 4/5] refactor(examples): restructure CrewAI examples to standard crewai create layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor all 4 CrewAI examples (steering_financial_agent, evaluator_showcase, secure_research_crew, content_publishing_flow) from single-file monoliths to the standard `crewai create crew/flow` project structure: - src//main.py — entry point, scenarios, verify_setup - src//crew.py — @CrewBase class with YAML config (crew examples) - src//config/agents.yaml + tasks.yaml — agent/task definitions - src//tools/*.py — tool implementations with @control() wrappers - pyproject.toml — [tool.crewai] type, [project.scripts] entry points All examples tested and passing with identical behavior. Co-Authored-By: Claude Opus 4.6 --- examples/crewai/README.md | 10 +- .../crewai/content_publishing_flow/README.md | 2 +- .../content_publishing_flow/pyproject.toml | 12 +- .../content_publishing_flow/setup_controls.py | 2 +- .../src/content_publishing_flow/__init__.py | 0 .../content_publishing_flow/main.py} | 251 +------ .../content_publishing_flow/tools/__init__.py | 19 + .../tools/edit_content.py | 23 + .../tools/fact_check.py | 19 + .../tools/legal_review.py | 22 + .../tools/publish_content.py | 19 + .../tools/request_human_review.py | 18 + .../tools/research_topic.py | 20 + .../tools/validate_request.py | 23 + .../tools/write_draft.py | 54 ++ examples/crewai/evaluator_showcase/README.md | 2 +- .../crewai/evaluator_showcase/pyproject.toml | 11 +- .../evaluator_showcase/setup_controls.py | 2 +- .../src/evaluator_showcase/__init__.py | 0 .../src/evaluator_showcase/config/agents.yaml | 9 + .../src/evaluator_showcase/config/tasks.yaml | 8 + .../src/evaluator_showcase/crew.py | 37 ++ .../evaluator_showcase/main.py} | 208 +----- .../src/evaluator_showcase/tools/__init__.py | 4 + .../evaluator_showcase/tools/analyze_data.py | 110 +++ .../evaluator_showcase/tools/run_sql_query.py | 71 ++ .../crewai/secure_research_crew/README.md | 2 +- .../secure_research_crew/pyproject.toml | 11 +- .../secure_research_crew/research_crew.py | 628 ------------------ .../secure_research_crew/setup_controls.py | 2 +- .../src/secure_research_crew/__init__.py | 0 .../secure_research_crew/config/agents.yaml | 23 + .../secure_research_crew/config/tasks.yaml | 24 + .../src/secure_research_crew/crew.py | 67 ++ .../src/secure_research_crew/main.py | 288 ++++++++ .../secure_research_crew/tools/__init__.py | 5 + .../tools/query_database.py | 96 +++ .../tools/validate_data.py | 102 +++ .../tools/write_report.py | 99 +++ .../crewai/steering_financial_agent/README.md | 2 +- .../financial_agent.py | 403 ----------- .../steering_financial_agent/pyproject.toml | 15 +- .../setup_controls.py | 2 +- .../src/steering_financial_agent/__init__.py | 0 .../config/agents.yaml | 11 + .../config/tasks.yaml | 8 + .../src/steering_financial_agent/crew.py | 38 ++ .../src/steering_financial_agent/main.py | 225 +++++++ .../tools/__init__.py | 0 .../tools/process_transfer.py | 162 +++++ 50 files changed, 1691 insertions(+), 1478 deletions(-) create mode 100644 examples/crewai/content_publishing_flow/src/content_publishing_flow/__init__.py rename examples/crewai/content_publishing_flow/{publishing_flow.py => src/content_publishing_flow/main.py} (74%) create mode 100644 examples/crewai/content_publishing_flow/src/content_publishing_flow/tools/__init__.py create mode 100644 examples/crewai/content_publishing_flow/src/content_publishing_flow/tools/edit_content.py create mode 100644 examples/crewai/content_publishing_flow/src/content_publishing_flow/tools/fact_check.py create mode 100644 examples/crewai/content_publishing_flow/src/content_publishing_flow/tools/legal_review.py create mode 100644 examples/crewai/content_publishing_flow/src/content_publishing_flow/tools/publish_content.py create mode 100644 examples/crewai/content_publishing_flow/src/content_publishing_flow/tools/request_human_review.py create mode 100644 examples/crewai/content_publishing_flow/src/content_publishing_flow/tools/research_topic.py create mode 100644 examples/crewai/content_publishing_flow/src/content_publishing_flow/tools/validate_request.py create mode 100644 examples/crewai/content_publishing_flow/src/content_publishing_flow/tools/write_draft.py create mode 100644 examples/crewai/evaluator_showcase/src/evaluator_showcase/__init__.py create mode 100644 examples/crewai/evaluator_showcase/src/evaluator_showcase/config/agents.yaml create mode 100644 examples/crewai/evaluator_showcase/src/evaluator_showcase/config/tasks.yaml create mode 100644 examples/crewai/evaluator_showcase/src/evaluator_showcase/crew.py rename examples/crewai/evaluator_showcase/{data_analyst.py => src/evaluator_showcase/main.py} (61%) create mode 100644 examples/crewai/evaluator_showcase/src/evaluator_showcase/tools/__init__.py create mode 100644 examples/crewai/evaluator_showcase/src/evaluator_showcase/tools/analyze_data.py create mode 100644 examples/crewai/evaluator_showcase/src/evaluator_showcase/tools/run_sql_query.py delete mode 100644 examples/crewai/secure_research_crew/research_crew.py create mode 100644 examples/crewai/secure_research_crew/src/secure_research_crew/__init__.py create mode 100644 examples/crewai/secure_research_crew/src/secure_research_crew/config/agents.yaml create mode 100644 examples/crewai/secure_research_crew/src/secure_research_crew/config/tasks.yaml create mode 100644 examples/crewai/secure_research_crew/src/secure_research_crew/crew.py create mode 100644 examples/crewai/secure_research_crew/src/secure_research_crew/main.py create mode 100644 examples/crewai/secure_research_crew/src/secure_research_crew/tools/__init__.py create mode 100644 examples/crewai/secure_research_crew/src/secure_research_crew/tools/query_database.py create mode 100644 examples/crewai/secure_research_crew/src/secure_research_crew/tools/validate_data.py create mode 100644 examples/crewai/secure_research_crew/src/secure_research_crew/tools/write_report.py delete mode 100644 examples/crewai/steering_financial_agent/financial_agent.py create mode 100644 examples/crewai/steering_financial_agent/src/steering_financial_agent/__init__.py create mode 100644 examples/crewai/steering_financial_agent/src/steering_financial_agent/config/agents.yaml create mode 100644 examples/crewai/steering_financial_agent/src/steering_financial_agent/config/tasks.yaml create mode 100644 examples/crewai/steering_financial_agent/src/steering_financial_agent/crew.py create mode 100644 examples/crewai/steering_financial_agent/src/steering_financial_agent/main.py create mode 100644 examples/crewai/steering_financial_agent/src/steering_financial_agent/tools/__init__.py create mode 100644 examples/crewai/steering_financial_agent/src/steering_financial_agent/tools/process_transfer.py diff --git a/examples/crewai/README.md b/examples/crewai/README.md index cd43942a..f6ea58b0 100644 --- a/examples/crewai/README.md +++ b/examples/crewai/README.md @@ -70,7 +70,7 @@ Each example has a `setup_controls.py` (one-time, idempotent) and a main script: ```bash cd examples/crewai/secure_research_crew uv run python setup_controls.py -uv run python research_crew.py +uv run python -m secure_research_crew.main ``` --- @@ -112,7 +112,7 @@ Demonstrates all three Agent Control action types in a wire-transfer scenario: ```bash cd examples/crewai/steering_financial_agent uv run python setup_controls.py -uv run python financial_agent.py +uv run python -m steering_financial_agent.main ``` ### 3. [Evaluator Showcase](./evaluator_showcase/) -- All 4 Built-in Evaluators @@ -129,7 +129,7 @@ Demonstrates every built-in evaluator in a data-analyst scenario: ```bash cd examples/crewai/evaluator_showcase uv run python setup_controls.py -uv run python data_analyst.py +uv run python -m evaluator_showcase.main ``` ### 4. [Secure Research Crew](./secure_research_crew/) -- Multi-Agent Crew with Per-Role Policies @@ -162,7 +162,7 @@ A production-quality **3-agent sequential crew** (Researcher, Analyst, Writer) w ```bash cd examples/crewai/secure_research_crew uv run python setup_controls.py -uv run python research_crew.py +uv run python -m secure_research_crew.main ``` ### 5. [Content Publishing Flow](./content_publishing_flow/) -- CrewAI Flow with Routing & Human-in-the-Loop @@ -197,7 +197,7 @@ A complete **CrewAI Flow** using `@start`, `@listen`, and `@router` decorators w ```bash cd examples/crewai/content_publishing_flow uv run python setup_controls.py -uv run python publishing_flow.py +uv run python -m content_publishing_flow.main ``` --- diff --git a/examples/crewai/content_publishing_flow/README.md b/examples/crewai/content_publishing_flow/README.md index 371b2ead..4fe8a86b 100644 --- a/examples/crewai/content_publishing_flow/README.md +++ b/examples/crewai/content_publishing_flow/README.md @@ -103,7 +103,7 @@ This creates 9 controls covering all pipeline stages and associates them with th ### 5. Run the Flow ```bash -uv run python publishing_flow.py +uv run python -m content_publishing_flow.main ``` ## Controls Reference diff --git a/examples/crewai/content_publishing_flow/pyproject.toml b/examples/crewai/content_publishing_flow/pyproject.toml index 7a1d7b9c..6981738f 100644 --- a/examples/crewai/content_publishing_flow/pyproject.toml +++ b/examples/crewai/content_publishing_flow/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "agent-control-crewai-publishing-flow" +name = "content-publishing-flow" version = "0.1.0" description = "CrewAI Flow with routing, embedded crews, and Agent Control guardrails" requires-python = ">=3.12" @@ -11,9 +11,17 @@ dependencies = [ "python-dotenv>=1.0.0", ] +[project.scripts] +content_publishing_flow = "content_publishing_flow.main:main" +kickoff = "content_publishing_flow.main:kickoff" +plot = "content_publishing_flow.main:plot" + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -include = ["*.py"] +packages = ["src/content_publishing_flow"] + +[tool.crewai] +type = "flow" diff --git a/examples/crewai/content_publishing_flow/setup_controls.py b/examples/crewai/content_publishing_flow/setup_controls.py index 5685c8d8..7be389c9 100644 --- a/examples/crewai/content_publishing_flow/setup_controls.py +++ b/examples/crewai/content_publishing_flow/setup_controls.py @@ -417,7 +417,7 @@ async def setup_publishing_controls(): print(" - flow-human-review-steer (STEER: manager approval)") print(" AUTO-PUBLISH (low_risk path):") print(" - flow-publish-pii-scan (REGEX: final PII scan)") - print("\nRun the flow: uv run python publishing_flow.py") + print("\nRun the flow: uv run python -m content_publishing_flow.main") if __name__ == "__main__": diff --git a/examples/crewai/content_publishing_flow/src/content_publishing_flow/__init__.py b/examples/crewai/content_publishing_flow/src/content_publishing_flow/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/crewai/content_publishing_flow/publishing_flow.py b/examples/crewai/content_publishing_flow/src/content_publishing_flow/main.py similarity index 74% rename from examples/crewai/content_publishing_flow/publishing_flow.py rename to examples/crewai/content_publishing_flow/src/content_publishing_flow/main.py index 7a6259ca..34300c04 100644 --- a/examples/crewai/content_publishing_flow/publishing_flow.py +++ b/examples/crewai/content_publishing_flow/src/content_publishing_flow/main.py @@ -32,14 +32,13 @@ $ uv run python setup_controls.py Then run this flow: - $ uv run python publishing_flow.py + $ uv run kickoff """ import asyncio import json import os import sys -import time from crewai.flow.flow import Flow, listen, router, start from pydantic import BaseModel @@ -47,6 +46,17 @@ import agent_control from agent_control import ControlSteerError, ControlViolationError, control +from content_publishing_flow.tools import ( + controlled_validate_request, + controlled_research, + controlled_fact_check, + controlled_write_draft, + controlled_legal_review, + controlled_edit_content, + controlled_publish, + controlled_human_review, +) + # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- @@ -72,212 +82,6 @@ class PublishingState(BaseModel): error: str = "" -# --------------------------------------------------------------------------- -# Controlled tool wrappers -# -# Each wrapper follows the same pattern: -# 1. Define an async inner function with named parameters (not **kwargs). -# 2. Set .name and .tool_name for step detection. -# 3. Apply @control() to get the controlled version. -# 4. The outer CrewAI @tool calls asyncio.run(controlled_fn(...)). -# --------------------------------------------------------------------------- - -# --- Intake: validate_request --- - -async def _validate_request(request: dict) -> str: - """Validate that the content request has required fields.""" - # The JSON evaluator on the server checks for topic, audience, content_type. - # If they are present the control passes; if missing it denies. - topic = request.get("topic", "") - audience = request.get("audience", "") - content_type = request.get("content_type", "") - return json.dumps({ - "topic": topic, - "audience": audience, - "content_type": content_type, - "valid": bool(topic and audience and content_type), - }) - - -_validate_request.name = "validate_request" # type: ignore[attr-defined] -_validate_request.tool_name = "validate_request" # type: ignore[attr-defined] -_controlled_validate_request = control()(_validate_request) - - -# --- Research: research_topic --- - -async def _research_topic(topic: str, audience: str) -> str: - """Research a topic. Returns simulated research notes.""" - # Simulated output - the LIST control checks for banned sources in output. - return ( - f"Research findings on '{topic}' for {audience}:\n\n" - f"1. Industry reports from McKinsey and Gartner highlight growing adoption.\n" - f"2. Academic papers from MIT and Stanford confirm key trends.\n" - f"3. Government statistics (census.gov, bls.gov) provide baseline data.\n" - f"4. Recent surveys show 73% of professionals consider this a priority.\n\n" - f"Key insight: The intersection of {topic} and modern workflows is driving " - f"measurable ROI for early adopters." - ) - - -_research_topic.name = "research_topic" # type: ignore[attr-defined] -_research_topic.tool_name = "research_topic" # type: ignore[attr-defined] -_controlled_research = control()(_research_topic) - - -# --- Research: fact_check --- - -async def _fact_check(research_text: str) -> str: - """Fact-check research output. Returns simulated verification report.""" - # The REGEX control checks output for unverified-claim markers. - return ( - "Fact-Check Report:\n" - "- Claim 1 (industry reports): VERIFIED - matches published data.\n" - "- Claim 2 (academic papers): VERIFIED - citations confirmed.\n" - "- Claim 3 (government stats): VERIFIED - data matches official sources.\n" - "- Claim 4 (survey data): VERIFIED - survey methodology reviewed.\n\n" - "Overall assessment: All claims verified. No corrections needed." - ) - - -_fact_check.name = "fact_check" # type: ignore[attr-defined] -_fact_check.tool_name = "fact_check" # type: ignore[attr-defined] -_controlled_fact_check = control()(_fact_check) - - -# --- Draft: write_draft --- - -async def _write_draft(topic: str, audience: str, content_type: str, research: str) -> str: - """Write a content draft based on research. Returns simulated draft.""" - # The REGEX control blocks PII; the LIST control blocks banned topics. - if content_type == "blog_post": - return ( - f"# {topic}: What You Need to Know\n\n" - f"*Written for {audience}*\n\n" - f"## Introduction\n" - f"The landscape of {topic} is evolving rapidly. Industry leaders are " - f"taking notice, and for good reason.\n\n" - f"## Key Findings\n" - f"Our research reveals several important trends that professionals " - f"should be aware of. Early adopters are seeing measurable returns.\n\n" - f"## What This Means For You\n" - f"Whether you are a seasoned professional or just getting started, " - f"understanding these trends is essential for staying competitive.\n\n" - f"## Conclusion\n" - f"The data is clear: {topic} represents a significant opportunity. " - f"Now is the time to act.\n" - ) - elif content_type == "press_release": - return ( - f"FOR IMMEDIATE RELEASE\n\n" - f"{topic}\n\n" - f"Company announces major developments in {topic}, " - f"targeting {audience}.\n\n" - f"Key highlights:\n" - f"- New initiatives launched to address industry needs\n" - f"- Strategic partnerships formed with leading organizations\n" - f"- Measurable impact expected within the first quarter\n\n" - f"About the Company:\n" - f"We are committed to innovation and excellence in our field.\n" - ) - else: # internal_memo - return ( - f"INTERNAL MEMO\n\n" - f"Subject: {topic}\n" - f"Audience: {audience}\n\n" - f"Team,\n\n" - f"This memo outlines our strategy regarding {topic}. " - f"Based on recent research, we recommend the following actions:\n\n" - f"1. Allocate resources for pilot program\n" - f"2. Form cross-functional task force\n" - f"3. Establish KPIs and reporting cadence\n\n" - f"Please review and provide feedback by end of week.\n" - ) - - -_write_draft.name = "write_draft" # type: ignore[attr-defined] -_write_draft.tool_name = "write_draft" # type: ignore[attr-defined] -_controlled_write_draft = control()(_write_draft) - - -# --- Compliance: legal_review --- - -async def _legal_review(content: str) -> str: - """Legal review of content. Returns JSON with disclaimer and approval.""" - # The JSON control checks output for required fields: disclaimer, legal_reviewed. - return json.dumps({ - "disclaimer": ( - "This content has been reviewed for legal compliance. " - "Forward-looking statements are subject to change." - ), - "legal_reviewed": True, - "notes": "No legal issues found. Approved for publication.", - "reviewed_content": content, - }) - - -_legal_review.name = "legal_review" # type: ignore[attr-defined] -_legal_review.tool_name = "legal_review" # type: ignore[attr-defined] -_controlled_legal_review = control()(_legal_review) - - -# --- Compliance: edit_content --- - -async def _edit_content(content: str, include_executive_summary: bool = False) -> str: - """Edit content for quality. REGEX blocks PII; STEER requires exec summary.""" - if include_executive_summary: - summary_section = ( - "## Executive Summary\n\n" - "This press release announces key developments that position the company " - "for significant growth. Stakeholders should note the strategic partnerships " - "and projected impact outlined below.\n\n" - ) - # Insert after the first line (FOR IMMEDIATE RELEASE) - lines = content.split("\n", 2) - if len(lines) >= 3: - return lines[0] + "\n" + summary_section + lines[2] - return summary_section + content - return content - - -_edit_content.name = "edit_content" # type: ignore[attr-defined] -_edit_content.tool_name = "edit_content" # type: ignore[attr-defined] -_controlled_edit_content = control()(_edit_content) - - -# --- Publish: publish_content --- - -async def _publish_content(content: str, content_type: str) -> str: - """Publish content. Final PII scan happens as a pre-check.""" - return json.dumps({ - "status": "published", - "content_type": content_type, - "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ"), - "content_preview": content[:200] + "..." if len(content) > 200 else content, - }) - - -_publish_content.name = "publish_content" # type: ignore[attr-defined] -_publish_content.tool_name = "publish_content" # type: ignore[attr-defined] -_controlled_publish = control()(_publish_content) - - -# --- Human Review: request_human_review --- - -async def _request_human_review(content: str, content_type: str) -> str: - """Submit content for human review. STEER control pauses for approval.""" - return json.dumps({ - "status": "pending_review", - "content_type": content_type, - "submitted_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"), - }) - - -_request_human_review.name = "request_human_review" # type: ignore[attr-defined] -_request_human_review.tool_name = "request_human_review" # type: ignore[attr-defined] -_controlled_human_review = control()(_request_human_review) - - # --------------------------------------------------------------------------- # Helper: run a controlled function and handle errors # --------------------------------------------------------------------------- @@ -349,7 +153,7 @@ async def intake_request(self): try: result = await run_controlled_async( - _controlled_validate_request, + controlled_validate_request, "Intake Validation", request=request, ) @@ -386,7 +190,7 @@ async def research(self): # Step 1: Research try: research_output = await run_controlled_async( - _controlled_research, + controlled_research, "Research", topic=self.state.topic, audience=self.state.audience, @@ -399,7 +203,7 @@ async def research(self): # Step 2: Fact-check try: fact_check_output = await run_controlled_async( - _controlled_fact_check, + controlled_fact_check, "Fact-Check", research_text=research_output, ) @@ -428,7 +232,7 @@ async def draft_content(self): try: draft = await run_controlled_async( - _controlled_write_draft, + controlled_write_draft, "Draft Writer", topic=self.state.topic, audience=self.state.audience, @@ -481,7 +285,7 @@ async def auto_publish(self): try: result = await run_controlled_async( - _controlled_publish, + controlled_publish, "Publish (PII Scan)", content=self.state.draft, content_type=self.state.content_type, @@ -511,7 +315,7 @@ async def compliance_review(self): # Step 1: Legal review try: legal_result = await run_controlled_async( - _controlled_legal_review, + controlled_legal_review, "Legal Review", content=self.state.draft, ) @@ -533,7 +337,7 @@ async def compliance_review(self): for attempt in range(1, max_attempts + 1): try: edited = await run_controlled_async( - _controlled_edit_content, + controlled_edit_content, f"Editor (attempt {attempt})", content=self.state.draft, include_executive_summary=include_exec_summary, @@ -553,7 +357,7 @@ async def compliance_review(self): # Publish try: result = await run_controlled_async( - _controlled_publish, + controlled_publish, "Publish (after compliance)", content=edited, content_type=self.state.content_type, @@ -593,7 +397,7 @@ async def human_review(self): for attempt in range(1, max_attempts + 1): try: result = await run_controlled_async( - _controlled_human_review, + controlled_human_review, f"Human Review (attempt {attempt})", content=self.state.draft, content_type=self.state.content_type, @@ -836,7 +640,7 @@ def main(): run_direct_tool_scenario( title="SCENARIO 4: Invalid Request (missing fields -> JSON block)", tool_label="Intake Validation", - controlled_fn=_controlled_validate_request, + controlled_fn=controlled_validate_request, kwargs={"request": {"topic": "Something"}}, # Missing audience and content_type ) @@ -941,5 +745,16 @@ async def _write_draft_pii( """) +def kickoff(): + """Standard CrewAI flow entry point.""" + main() + + +def plot(): + """Plot the flow graph.""" + flow = ContentPublishingFlow() + flow.plot() + + if __name__ == "__main__": main() diff --git a/examples/crewai/content_publishing_flow/src/content_publishing_flow/tools/__init__.py b/examples/crewai/content_publishing_flow/src/content_publishing_flow/tools/__init__.py new file mode 100644 index 00000000..0effe28d --- /dev/null +++ b/examples/crewai/content_publishing_flow/src/content_publishing_flow/tools/__init__.py @@ -0,0 +1,19 @@ +from content_publishing_flow.tools.validate_request import controlled_validate_request +from content_publishing_flow.tools.research_topic import controlled_research +from content_publishing_flow.tools.fact_check import controlled_fact_check +from content_publishing_flow.tools.write_draft import controlled_write_draft +from content_publishing_flow.tools.legal_review import controlled_legal_review +from content_publishing_flow.tools.edit_content import controlled_edit_content +from content_publishing_flow.tools.publish_content import controlled_publish +from content_publishing_flow.tools.request_human_review import controlled_human_review + +__all__ = [ + "controlled_validate_request", + "controlled_research", + "controlled_fact_check", + "controlled_write_draft", + "controlled_legal_review", + "controlled_edit_content", + "controlled_publish", + "controlled_human_review", +] diff --git a/examples/crewai/content_publishing_flow/src/content_publishing_flow/tools/edit_content.py b/examples/crewai/content_publishing_flow/src/content_publishing_flow/tools/edit_content.py new file mode 100644 index 00000000..fd235650 --- /dev/null +++ b/examples/crewai/content_publishing_flow/src/content_publishing_flow/tools/edit_content.py @@ -0,0 +1,23 @@ +from agent_control import control + + +async def _edit_content(content: str, include_executive_summary: bool = False) -> str: + """Edit content for quality. REGEX blocks PII; STEER requires exec summary.""" + if include_executive_summary: + summary_section = ( + "## Executive Summary\n\n" + "This press release announces key developments that position the company " + "for significant growth. Stakeholders should note the strategic partnerships " + "and projected impact outlined below.\n\n" + ) + # Insert after the first line (FOR IMMEDIATE RELEASE) + lines = content.split("\n", 2) + if len(lines) >= 3: + return lines[0] + "\n" + summary_section + lines[2] + return summary_section + content + return content + + +_edit_content.name = "edit_content" # type: ignore[attr-defined] +_edit_content.tool_name = "edit_content" # type: ignore[attr-defined] +controlled_edit_content = control()(_edit_content) diff --git a/examples/crewai/content_publishing_flow/src/content_publishing_flow/tools/fact_check.py b/examples/crewai/content_publishing_flow/src/content_publishing_flow/tools/fact_check.py new file mode 100644 index 00000000..549d6d2c --- /dev/null +++ b/examples/crewai/content_publishing_flow/src/content_publishing_flow/tools/fact_check.py @@ -0,0 +1,19 @@ +from agent_control import control + + +async def _fact_check(research_text: str) -> str: + """Fact-check research output. Returns simulated verification report.""" + # The REGEX control checks output for unverified-claim markers. + return ( + "Fact-Check Report:\n" + "- Claim 1 (industry reports): VERIFIED - matches published data.\n" + "- Claim 2 (academic papers): VERIFIED - citations confirmed.\n" + "- Claim 3 (government stats): VERIFIED - data matches official sources.\n" + "- Claim 4 (survey data): VERIFIED - survey methodology reviewed.\n\n" + "Overall assessment: All claims verified. No corrections needed." + ) + + +_fact_check.name = "fact_check" # type: ignore[attr-defined] +_fact_check.tool_name = "fact_check" # type: ignore[attr-defined] +controlled_fact_check = control()(_fact_check) diff --git a/examples/crewai/content_publishing_flow/src/content_publishing_flow/tools/legal_review.py b/examples/crewai/content_publishing_flow/src/content_publishing_flow/tools/legal_review.py new file mode 100644 index 00000000..3bf74846 --- /dev/null +++ b/examples/crewai/content_publishing_flow/src/content_publishing_flow/tools/legal_review.py @@ -0,0 +1,22 @@ +import json + +from agent_control import control + + +async def _legal_review(content: str) -> str: + """Legal review of content. Returns JSON with disclaimer and approval.""" + # The JSON control checks output for required fields: disclaimer, legal_reviewed. + return json.dumps({ + "disclaimer": ( + "This content has been reviewed for legal compliance. " + "Forward-looking statements are subject to change." + ), + "legal_reviewed": True, + "notes": "No legal issues found. Approved for publication.", + "reviewed_content": content, + }) + + +_legal_review.name = "legal_review" # type: ignore[attr-defined] +_legal_review.tool_name = "legal_review" # type: ignore[attr-defined] +controlled_legal_review = control()(_legal_review) diff --git a/examples/crewai/content_publishing_flow/src/content_publishing_flow/tools/publish_content.py b/examples/crewai/content_publishing_flow/src/content_publishing_flow/tools/publish_content.py new file mode 100644 index 00000000..81fd3461 --- /dev/null +++ b/examples/crewai/content_publishing_flow/src/content_publishing_flow/tools/publish_content.py @@ -0,0 +1,19 @@ +import json +import time + +from agent_control import control + + +async def _publish_content(content: str, content_type: str) -> str: + """Publish content. Final PII scan happens as a pre-check.""" + return json.dumps({ + "status": "published", + "content_type": content_type, + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ"), + "content_preview": content[:200] + "..." if len(content) > 200 else content, + }) + + +_publish_content.name = "publish_content" # type: ignore[attr-defined] +_publish_content.tool_name = "publish_content" # type: ignore[attr-defined] +controlled_publish = control()(_publish_content) diff --git a/examples/crewai/content_publishing_flow/src/content_publishing_flow/tools/request_human_review.py b/examples/crewai/content_publishing_flow/src/content_publishing_flow/tools/request_human_review.py new file mode 100644 index 00000000..dbb477db --- /dev/null +++ b/examples/crewai/content_publishing_flow/src/content_publishing_flow/tools/request_human_review.py @@ -0,0 +1,18 @@ +import json +import time + +from agent_control import control + + +async def _request_human_review(content: str, content_type: str) -> str: + """Submit content for human review. STEER control pauses for approval.""" + return json.dumps({ + "status": "pending_review", + "content_type": content_type, + "submitted_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"), + }) + + +_request_human_review.name = "request_human_review" # type: ignore[attr-defined] +_request_human_review.tool_name = "request_human_review" # type: ignore[attr-defined] +controlled_human_review = control()(_request_human_review) diff --git a/examples/crewai/content_publishing_flow/src/content_publishing_flow/tools/research_topic.py b/examples/crewai/content_publishing_flow/src/content_publishing_flow/tools/research_topic.py new file mode 100644 index 00000000..ff7ed370 --- /dev/null +++ b/examples/crewai/content_publishing_flow/src/content_publishing_flow/tools/research_topic.py @@ -0,0 +1,20 @@ +from agent_control import control + + +async def _research_topic(topic: str, audience: str) -> str: + """Research a topic. Returns simulated research notes.""" + # Simulated output - the LIST control checks for banned sources in output. + return ( + f"Research findings on '{topic}' for {audience}:\n\n" + f"1. Industry reports from McKinsey and Gartner highlight growing adoption.\n" + f"2. Academic papers from MIT and Stanford confirm key trends.\n" + f"3. Government statistics (census.gov, bls.gov) provide baseline data.\n" + f"4. Recent surveys show 73% of professionals consider this a priority.\n\n" + f"Key insight: The intersection of {topic} and modern workflows is driving " + f"measurable ROI for early adopters." + ) + + +_research_topic.name = "research_topic" # type: ignore[attr-defined] +_research_topic.tool_name = "research_topic" # type: ignore[attr-defined] +controlled_research = control()(_research_topic) diff --git a/examples/crewai/content_publishing_flow/src/content_publishing_flow/tools/validate_request.py b/examples/crewai/content_publishing_flow/src/content_publishing_flow/tools/validate_request.py new file mode 100644 index 00000000..8a071d84 --- /dev/null +++ b/examples/crewai/content_publishing_flow/src/content_publishing_flow/tools/validate_request.py @@ -0,0 +1,23 @@ +import json + +from agent_control import control + + +async def _validate_request(request: dict) -> str: + """Validate that the content request has required fields.""" + # The JSON evaluator on the server checks for topic, audience, content_type. + # If they are present the control passes; if missing it denies. + topic = request.get("topic", "") + audience = request.get("audience", "") + content_type = request.get("content_type", "") + return json.dumps({ + "topic": topic, + "audience": audience, + "content_type": content_type, + "valid": bool(topic and audience and content_type), + }) + + +_validate_request.name = "validate_request" # type: ignore[attr-defined] +_validate_request.tool_name = "validate_request" # type: ignore[attr-defined] +controlled_validate_request = control()(_validate_request) diff --git a/examples/crewai/content_publishing_flow/src/content_publishing_flow/tools/write_draft.py b/examples/crewai/content_publishing_flow/src/content_publishing_flow/tools/write_draft.py new file mode 100644 index 00000000..fc0d822a --- /dev/null +++ b/examples/crewai/content_publishing_flow/src/content_publishing_flow/tools/write_draft.py @@ -0,0 +1,54 @@ +from agent_control import control + + +async def _write_draft(topic: str, audience: str, content_type: str, research: str) -> str: + """Write a content draft based on research. Returns simulated draft.""" + # The REGEX control blocks PII; the LIST control blocks banned topics. + if content_type == "blog_post": + return ( + f"# {topic}: What You Need to Know\n\n" + f"*Written for {audience}*\n\n" + f"## Introduction\n" + f"The landscape of {topic} is evolving rapidly. Industry leaders are " + f"taking notice, and for good reason.\n\n" + f"## Key Findings\n" + f"Our research reveals several important trends that professionals " + f"should be aware of. Early adopters are seeing measurable returns.\n\n" + f"## What This Means For You\n" + f"Whether you are a seasoned professional or just getting started, " + f"understanding these trends is essential for staying competitive.\n\n" + f"## Conclusion\n" + f"The data is clear: {topic} represents a significant opportunity. " + f"Now is the time to act.\n" + ) + elif content_type == "press_release": + return ( + f"FOR IMMEDIATE RELEASE\n\n" + f"{topic}\n\n" + f"Company announces major developments in {topic}, " + f"targeting {audience}.\n\n" + f"Key highlights:\n" + f"- New initiatives launched to address industry needs\n" + f"- Strategic partnerships formed with leading organizations\n" + f"- Measurable impact expected within the first quarter\n\n" + f"About the Company:\n" + f"We are committed to innovation and excellence in our field.\n" + ) + else: # internal_memo + return ( + f"INTERNAL MEMO\n\n" + f"Subject: {topic}\n" + f"Audience: {audience}\n\n" + f"Team,\n\n" + f"This memo outlines our strategy regarding {topic}. " + f"Based on recent research, we recommend the following actions:\n\n" + f"1. Allocate resources for pilot program\n" + f"2. Form cross-functional task force\n" + f"3. Establish KPIs and reporting cadence\n\n" + f"Please review and provide feedback by end of week.\n" + ) + + +_write_draft.name = "write_draft" # type: ignore[attr-defined] +_write_draft.tool_name = "write_draft" # type: ignore[attr-defined] +controlled_write_draft = control()(_write_draft) diff --git a/examples/crewai/evaluator_showcase/README.md b/examples/crewai/evaluator_showcase/README.md index 5f5b028f..16bf6dac 100644 --- a/examples/crewai/evaluator_showcase/README.md +++ b/examples/crewai/evaluator_showcase/README.md @@ -75,7 +75,7 @@ export OPENAI_API_KEY="your-key" uv run python setup_controls.py # Run the demo -uv run python data_analyst.py +uv run python -m evaluator_showcase.main ``` ## Key Insight diff --git a/examples/crewai/evaluator_showcase/pyproject.toml b/examples/crewai/evaluator_showcase/pyproject.toml index 907681ad..f589816b 100644 --- a/examples/crewai/evaluator_showcase/pyproject.toml +++ b/examples/crewai/evaluator_showcase/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "agent-control-crewai-evaluator-showcase" +name = "evaluator-showcase" version = "0.1.0" description = "CrewAI agent demonstrating all built-in evaluators: regex, list, json, and sql" requires-python = ">=3.12" @@ -17,9 +17,16 @@ dev = [ "pytest-asyncio>=0.23.0", ] +[project.scripts] +evaluator_showcase = "evaluator_showcase.main:main" +run_crew = "evaluator_showcase.main:main" + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -include = ["*.py"] +packages = ["src/evaluator_showcase"] + +[tool.crewai] +type = "crew" diff --git a/examples/crewai/evaluator_showcase/setup_controls.py b/examples/crewai/evaluator_showcase/setup_controls.py index 3865cb16..c0ab737a 100644 --- a/examples/crewai/evaluator_showcase/setup_controls.py +++ b/examples/crewai/evaluator_showcase/setup_controls.py @@ -276,7 +276,7 @@ async def setup(): print(" JSON - Validate request structure and field constraints") print(" JSON - Steer agent to provide analysis purpose") print() - print("Run the demo: uv run python data_analyst.py") + print("Run the demo: uv run python -m evaluator_showcase.main") if __name__ == "__main__": diff --git a/examples/crewai/evaluator_showcase/src/evaluator_showcase/__init__.py b/examples/crewai/evaluator_showcase/src/evaluator_showcase/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/crewai/evaluator_showcase/src/evaluator_showcase/config/agents.yaml b/examples/crewai/evaluator_showcase/src/evaluator_showcase/config/agents.yaml new file mode 100644 index 00000000..4dc2a84d --- /dev/null +++ b/examples/crewai/evaluator_showcase/src/evaluator_showcase/config/agents.yaml @@ -0,0 +1,9 @@ +data_analyst: + role: > + Data Analyst + goal: > + Execute data queries and analysis while respecting all data governance controls + backstory: > + You are a data analyst at a company with strict data governance policies. + You use run_sql_query to query the database and analyze_data for analysis. + You always comply with security controls and never attempt to bypass them. diff --git a/examples/crewai/evaluator_showcase/src/evaluator_showcase/config/tasks.yaml b/examples/crewai/evaluator_showcase/src/evaluator_showcase/config/tasks.yaml new file mode 100644 index 00000000..73011092 --- /dev/null +++ b/examples/crewai/evaluator_showcase/src/evaluator_showcase/config/tasks.yaml @@ -0,0 +1,8 @@ +analysis_task: + description: > + Execute this data request: {request} + + Use the appropriate tool and report the outcome. + expected_output: > + Query results or analysis, or an explanation if blocked by controls + agent: data_analyst diff --git a/examples/crewai/evaluator_showcase/src/evaluator_showcase/crew.py b/examples/crewai/evaluator_showcase/src/evaluator_showcase/crew.py new file mode 100644 index 00000000..8e8942c7 --- /dev/null +++ b/examples/crewai/evaluator_showcase/src/evaluator_showcase/crew.py @@ -0,0 +1,37 @@ +"""CrewBase crew definition for the Evaluator Showcase.""" + +from crewai import Agent, Crew, Process, Task +from crewai.project import CrewBase, agent, crew, task + +from evaluator_showcase.tools import create_sql_tool, create_analysis_tool + + +@CrewBase +class EvaluatorShowcaseCrew: + """Data analyst crew demonstrating all evaluator types.""" + + agents_config = "config/agents.yaml" + tasks_config = "config/tasks.yaml" + + @agent + def data_analyst(self) -> Agent: + sql_tool = create_sql_tool() + analysis_tool = create_analysis_tool() + return Agent( + config=self.agents_config["data_analyst"], + tools=[sql_tool, analysis_tool], + verbose=True, + ) + + @task + def analysis_task(self) -> Task: + return Task(config=self.tasks_config["analysis_task"]) + + @crew + def crew(self) -> Crew: + return Crew( + agents=self.agents, + tasks=self.tasks, + process=Process.sequential, + verbose=True, + ) diff --git a/examples/crewai/evaluator_showcase/data_analyst.py b/examples/crewai/evaluator_showcase/src/evaluator_showcase/main.py similarity index 61% rename from examples/crewai/evaluator_showcase/data_analyst.py rename to examples/crewai/evaluator_showcase/src/evaluator_showcase/main.py index 5b2bfefe..ace0abe7 100644 --- a/examples/crewai/evaluator_showcase/data_analyst.py +++ b/examples/crewai/evaluator_showcase/src/evaluator_showcase/main.py @@ -17,7 +17,7 @@ Then run this example: - $ uv run python data_analyst.py + $ uv run evaluator_showcase Scenarios: 1. Safe SELECT query -> SQL evaluator ALLOWS @@ -30,14 +30,13 @@ 8. Missing purpose field -> JSON evaluator STEERS (then allowed) """ -import asyncio import json import os import agent_control -from agent_control import ControlSteerError, ControlViolationError, control -from crewai import Agent, Crew, LLM, Task -from crewai.tools import tool + +from evaluator_showcase.tools import create_sql_tool, create_analysis_tool +from evaluator_showcase.crew import EvaluatorShowcaseCrew # ── Configuration ─────────────────────────────────────────────────────── AGENT_NAME = "crewai-data-analyst" @@ -50,203 +49,6 @@ ) -# ── Simulated Database ────────────────────────────────────────────────── -# In a real app these would hit an actual database. The simulated responses -# let us demonstrate how Agent Control inspects both input AND output. - -SIMULATED_RESULTS = { - "safe": ( - "| order_id | product | total |\n" - "|----------|-----------|--------|\n" - "| 1001 | Widget A | 29.99 |\n" - "| 1002 | Widget B | 49.99 |\n" - "| 1003 | Gadget X | 149.99 |\n" - "\n3 rows returned." - ), - "pii": ( - "| customer_id | name | ssn | email |\n" - "|-------------|------------|-------------|--------------------| \n" - "| C-101 | John Smith | 123-45-6789 | john@example.com |\n" - "| C-102 | Jane Doe | 987-65-4321 | jane@example.com |\n" - "\n2 rows returned." - ), -} - - -# ── Tool 1: SQL Query Runner ──────────────────────────────────────────── - -def create_sql_tool(): - """Build the SQL query tool with @control protection.""" - - async def _run_sql_query(query: str) -> str: - """Execute a SQL query against the database (protected).""" - # Simulate: if query touches customers_full, return PII results - if "customers_full" in query.lower(): - return SIMULATED_RESULTS["pii"] - return SIMULATED_RESULTS["safe"] - - _run_sql_query.name = "run_sql_query" # type: ignore[attr-defined] - _run_sql_query.tool_name = "run_sql_query" # type: ignore[attr-defined] - controlled_fn = control()(_run_sql_query) - - @tool("run_sql_query") - def run_sql_query_tool(query: str) -> str: - """Run a SQL query against the company database. - - Args: - query: The SQL query to execute - """ - if isinstance(query, dict): - query = query.get("query", str(query)) - - print(f"\n [SQL TOOL] Query: {query[:80]}...") - - try: - result = asyncio.run(controlled_fn(query=query)) - print(f" [SQL TOOL] Query executed successfully") - return result - - except ControlViolationError as e: - print(f" [SQL TOOL] BLOCKED by {e.control_name}: {e.message[:100]}") - return f"QUERY BLOCKED: {e.message}" - - except Exception as e: - print(f" [SQL TOOL] Error: {e}") - return f"Query error: {e}" - - return run_sql_query_tool - - -# ── Tool 2: Data Analyzer ─────────────────────────────────────────────── - -def create_analysis_tool(): - """Build the analysis tool with JSON validation and steering.""" - - # Defer LLM creation -- only needed if OPENAI_API_KEY is set - llm = None - if os.getenv("OPENAI_API_KEY"): - llm = LLM(model="gpt-4o-mini", temperature=0.3) - - async def _analyze_data(request: dict) -> str: - """Run data analysis (protected by JSON validation controls). - - Takes a single dict param so the @control() decorator sends it - as input.request — and the JSON evaluator can check which fields - are present or absent. - """ - dataset = request.get("dataset", "") - date_range = request.get("date_range", "") - max_rows = request.get("max_rows", 1000) - purpose = request.get("purpose", "") - - prompt = f"""Summarize this data analysis in 2-3 sentences: -- Dataset: {dataset} -- Date range: {date_range} -- Max rows: {max_rows} -- Purpose: {purpose} - -Provide a brief, professional analysis summary.""" - - return llm.call([{"role": "user", "content": prompt}]) - - _analyze_data.name = "analyze_data" # type: ignore[attr-defined] - _analyze_data.tool_name = "analyze_data" # type: ignore[attr-defined] - controlled_fn = control()(_analyze_data) - - @tool("analyze_data") - def analyze_data_tool(request: str) -> str: - """Analyze a dataset with validation controls. - - Args: - request: JSON string with fields: dataset (required), date_range (required), - max_rows (optional, 1-10000), purpose (recommended for audit compliance) - """ - if isinstance(request, dict): - params = request - else: - try: - params = json.loads(request) - except (json.JSONDecodeError, TypeError): - return f"Invalid request format. Expected JSON, got: {request!r}" - - # Build the request dict — only include fields that have values. - # The JSON evaluator checks which fields are PRESENT in this dict, - # so omitting a field triggers the "required_fields" check. - request_dict: dict = {} - if params.get("dataset"): - request_dict["dataset"] = params["dataset"] - if params.get("date_range"): - request_dict["date_range"] = params["date_range"] - if params.get("max_rows") is not None: - request_dict["max_rows"] = int(params["max_rows"]) - if params.get("purpose"): - request_dict["purpose"] = params["purpose"] - - print(f"\n [ANALYSIS TOOL] Request: {request_dict}") - - # Steering retry loop - max_attempts = 3 - for attempt in range(1, max_attempts + 1): - try: - result = asyncio.run(controlled_fn(request=request_dict)) - print(f" [ANALYSIS TOOL] Analysis complete") - return result - - except ControlViolationError as e: - print(f" [ANALYSIS TOOL] BLOCKED by {e.control_name}: {e.message[:100]}") - return f"ANALYSIS BLOCKED: {e.message}" - - except ControlSteerError as e: - print(f" [ANALYSIS TOOL] STEERED by {e.control_name}") - try: - guidance = json.loads(e.steering_context) - except (json.JSONDecodeError, TypeError): - guidance = {} - - reason = guidance.get("reason", "Correction needed") - actions = guidance.get("required_actions", []) - print(f" Reason: {reason}") - print(f" Actions: {actions}") - - if "collect_purpose" in actions: - auto_purpose = f"Quarterly {request_dict.get('dataset', 'data')} analysis for business reporting" - request_dict["purpose"] = auto_purpose - print(f" Auto-filled purpose: {auto_purpose}") - - continue - - return "ANALYSIS FAILED: Could not satisfy all controls." - - return analyze_data_tool - - -# ── CrewAI Crew ───────────────────────────────────────────────────────── - -def create_analyst_crew(sql_tool, analysis_tool): - analyst = Agent( - role="Data Analyst", - goal="Execute data queries and analysis while respecting all data governance controls", - backstory=( - "You are a data analyst at a company with strict data governance policies. " - "You use run_sql_query to query the database and analyze_data for analysis. " - "You always comply with security controls and never attempt to bypass them." - ), - tools=[sql_tool, analysis_tool], - verbose=True, - ) - - task = Task( - description=( - "Execute this data request: {request}\n\n" - "Use the appropriate tool and report the outcome." - ), - expected_output="Query results or analysis, or an explanation if blocked by controls", - agent=analyst, - ) - - return Crew(agents=[analyst], tasks=[task], verbose=True) - - # ── Scenario Runner ───────────────────────────────────────────────────── def verify_server(): @@ -498,7 +300,7 @@ def main(): print(" Agent autonomously handles a multi-step data request") print("#" * 60) - crew = create_analyst_crew(sql_tool, analysis_tool) + crew = EvaluatorShowcaseCrew().crew() print("\n Running crew with a safe data request...") result = crew.kickoff( diff --git a/examples/crewai/evaluator_showcase/src/evaluator_showcase/tools/__init__.py b/examples/crewai/evaluator_showcase/src/evaluator_showcase/tools/__init__.py new file mode 100644 index 00000000..136c55d0 --- /dev/null +++ b/examples/crewai/evaluator_showcase/src/evaluator_showcase/tools/__init__.py @@ -0,0 +1,4 @@ +from evaluator_showcase.tools.run_sql_query import create_sql_tool +from evaluator_showcase.tools.analyze_data import create_analysis_tool + +__all__ = ["create_sql_tool", "create_analysis_tool"] diff --git a/examples/crewai/evaluator_showcase/src/evaluator_showcase/tools/analyze_data.py b/examples/crewai/evaluator_showcase/src/evaluator_showcase/tools/analyze_data.py new file mode 100644 index 00000000..0bff8d58 --- /dev/null +++ b/examples/crewai/evaluator_showcase/src/evaluator_showcase/tools/analyze_data.py @@ -0,0 +1,110 @@ +"""Data analysis tool with JSON validation and steering.""" + +import asyncio +import json +import os + +from agent_control import ControlSteerError, ControlViolationError, control +from crewai import LLM +from crewai.tools import tool + + +def create_analysis_tool(): + """Build the analysis tool with JSON validation and steering.""" + + # Defer LLM creation -- only needed if OPENAI_API_KEY is set + llm = None + if os.getenv("OPENAI_API_KEY"): + llm = LLM(model="gpt-4o-mini", temperature=0.3) + + async def _analyze_data(request: dict) -> str: + """Run data analysis (protected by JSON validation controls). + + Takes a single dict param so the @control() decorator sends it + as input.request — and the JSON evaluator can check which fields + are present or absent. + """ + dataset = request.get("dataset", "") + date_range = request.get("date_range", "") + max_rows = request.get("max_rows", 1000) + purpose = request.get("purpose", "") + + prompt = f"""Summarize this data analysis in 2-3 sentences: +- Dataset: {dataset} +- Date range: {date_range} +- Max rows: {max_rows} +- Purpose: {purpose} + +Provide a brief, professional analysis summary.""" + + return llm.call([{"role": "user", "content": prompt}]) + + _analyze_data.name = "analyze_data" # type: ignore[attr-defined] + _analyze_data.tool_name = "analyze_data" # type: ignore[attr-defined] + controlled_fn = control()(_analyze_data) + + @tool("analyze_data") + def analyze_data_tool(request: str) -> str: + """Analyze a dataset with validation controls. + + Args: + request: JSON string with fields: dataset (required), date_range (required), + max_rows (optional, 1-10000), purpose (recommended for audit compliance) + """ + if isinstance(request, dict): + params = request + else: + try: + params = json.loads(request) + except (json.JSONDecodeError, TypeError): + return f"Invalid request format. Expected JSON, got: {request!r}" + + # Build the request dict — only include fields that have values. + # The JSON evaluator checks which fields are PRESENT in this dict, + # so omitting a field triggers the "required_fields" check. + request_dict: dict = {} + if params.get("dataset"): + request_dict["dataset"] = params["dataset"] + if params.get("date_range"): + request_dict["date_range"] = params["date_range"] + if params.get("max_rows") is not None: + request_dict["max_rows"] = int(params["max_rows"]) + if params.get("purpose"): + request_dict["purpose"] = params["purpose"] + + print(f"\n [ANALYSIS TOOL] Request: {request_dict}") + + # Steering retry loop + max_attempts = 3 + for attempt in range(1, max_attempts + 1): + try: + result = asyncio.run(controlled_fn(request=request_dict)) + print(f" [ANALYSIS TOOL] Analysis complete") + return result + + except ControlViolationError as e: + print(f" [ANALYSIS TOOL] BLOCKED by {e.control_name}: {e.message[:100]}") + return f"ANALYSIS BLOCKED: {e.message}" + + except ControlSteerError as e: + print(f" [ANALYSIS TOOL] STEERED by {e.control_name}") + try: + guidance = json.loads(e.steering_context) + except (json.JSONDecodeError, TypeError): + guidance = {} + + reason = guidance.get("reason", "Correction needed") + actions = guidance.get("required_actions", []) + print(f" Reason: {reason}") + print(f" Actions: {actions}") + + if "collect_purpose" in actions: + auto_purpose = f"Quarterly {request_dict.get('dataset', 'data')} analysis for business reporting" + request_dict["purpose"] = auto_purpose + print(f" Auto-filled purpose: {auto_purpose}") + + continue + + return "ANALYSIS FAILED: Could not satisfy all controls." + + return analyze_data_tool diff --git a/examples/crewai/evaluator_showcase/src/evaluator_showcase/tools/run_sql_query.py b/examples/crewai/evaluator_showcase/src/evaluator_showcase/tools/run_sql_query.py new file mode 100644 index 00000000..7c81f3de --- /dev/null +++ b/examples/crewai/evaluator_showcase/src/evaluator_showcase/tools/run_sql_query.py @@ -0,0 +1,71 @@ +"""SQL query tool with @control protection.""" + +import asyncio + +from agent_control import ControlViolationError, control +from crewai.tools import tool + + +# ── Simulated Database ────────────────────────────────────────────────── +# In a real app these would hit an actual database. The simulated responses +# let us demonstrate how Agent Control inspects both input AND output. + +SIMULATED_RESULTS = { + "safe": ( + "| order_id | product | total |\n" + "|----------|-----------|--------|\n" + "| 1001 | Widget A | 29.99 |\n" + "| 1002 | Widget B | 49.99 |\n" + "| 1003 | Gadget X | 149.99 |\n" + "\n3 rows returned." + ), + "pii": ( + "| customer_id | name | ssn | email |\n" + "|-------------|------------|-------------|--------------------| \n" + "| C-101 | John Smith | 123-45-6789 | john@example.com |\n" + "| C-102 | Jane Doe | 987-65-4321 | jane@example.com |\n" + "\n2 rows returned." + ), +} + + +def create_sql_tool(): + """Build the SQL query tool with @control protection.""" + + async def _run_sql_query(query: str) -> str: + """Execute a SQL query against the database (protected).""" + # Simulate: if query touches customers_full, return PII results + if "customers_full" in query.lower(): + return SIMULATED_RESULTS["pii"] + return SIMULATED_RESULTS["safe"] + + _run_sql_query.name = "run_sql_query" # type: ignore[attr-defined] + _run_sql_query.tool_name = "run_sql_query" # type: ignore[attr-defined] + controlled_fn = control()(_run_sql_query) + + @tool("run_sql_query") + def run_sql_query_tool(query: str) -> str: + """Run a SQL query against the company database. + + Args: + query: The SQL query to execute + """ + if isinstance(query, dict): + query = query.get("query", str(query)) + + print(f"\n [SQL TOOL] Query: {query[:80]}...") + + try: + result = asyncio.run(controlled_fn(query=query)) + print(f" [SQL TOOL] Query executed successfully") + return result + + except ControlViolationError as e: + print(f" [SQL TOOL] BLOCKED by {e.control_name}: {e.message[:100]}") + return f"QUERY BLOCKED: {e.message}" + + except Exception as e: + print(f" [SQL TOOL] Error: {e}") + return f"Query error: {e}" + + return run_sql_query_tool diff --git a/examples/crewai/secure_research_crew/README.md b/examples/crewai/secure_research_crew/README.md index a3dc20df..50569a41 100644 --- a/examples/crewai/secure_research_crew/README.md +++ b/examples/crewai/secure_research_crew/README.md @@ -93,7 +93,7 @@ uv run python setup_controls.py ### 4. Run the demo ```bash -uv run python research_crew.py +uv run python -m secure_research_crew.main ``` Scenarios 1-5 run with direct tool calls (no LLM needed). The optional full crew run at the end requires `OPENAI_API_KEY`. diff --git a/examples/crewai/secure_research_crew/pyproject.toml b/examples/crewai/secure_research_crew/pyproject.toml index 95f714dc..5f7d4405 100644 --- a/examples/crewai/secure_research_crew/pyproject.toml +++ b/examples/crewai/secure_research_crew/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "agent-control-crewai-research-crew" +name = "secure-research-crew" version = "0.1.0" description = "CrewAI multi-agent research crew with per-agent policies via Agent Control" requires-python = ">=3.12" @@ -11,9 +11,16 @@ dependencies = [ "python-dotenv>=1.0.0", ] +[project.scripts] +secure_research_crew = "secure_research_crew.main:main" +run_crew = "secure_research_crew.main:main" + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -include = ["*.py"] +packages = ["src/secure_research_crew"] + +[tool.crewai] +type = "crew" diff --git a/examples/crewai/secure_research_crew/research_crew.py b/examples/crewai/secure_research_crew/research_crew.py deleted file mode 100644 index bd51ae94..00000000 --- a/examples/crewai/secure_research_crew/research_crew.py +++ /dev/null @@ -1,628 +0,0 @@ -""" -Secure Research Crew -- CrewAI multi-agent demo with per-agent Agent Control policies. - -A 3-agent sequential crew where each agent has different controls: - - 1. Researcher -- queries a simulated database - Controls: SQL evaluator (block DROP/DELETE, enforce LIMIT) - LIST evaluator (block sensitive tables) - - 2. Analyst -- validates and processes research data - Controls: JSON evaluator (require dataset, findings, confidence_score) - JSON schema steer (add methodology if missing) - - 3. Writer -- generates the final report - Controls: REGEX evaluator (block PII in output) - Client-side citation check (steer if missing) - -Scenarios: - 1. Happy path -- all agents pass, report generated - 2. Researcher blocked -- SQL injection attempt (DROP TABLE) - 3. Researcher restricted-- query to salary_data table - 4. Analyst steered -- missing methodology, corrected, then succeeds - 5. Writer blocked -- PII in report output - -PREREQUISITE: - uv run python setup_controls.py - -Usage: - uv run python research_crew.py -""" - -import asyncio -import json -import os -import re - -import agent_control -from agent_control import ControlSteerError, ControlViolationError, control -from crewai import Agent, Crew, Task -from crewai.tools import tool - -# --------------------------------------------------------------------------- -# Configuration -# --------------------------------------------------------------------------- - -AGENT_NAME = "secure-research-crew" -SERVER_URL = os.getenv("AGENT_CONTROL_URL", "http://localhost:8000") - -agent_control.init( - agent_name=AGENT_NAME, - agent_description="Multi-agent research crew with per-agent policies", - server_url=SERVER_URL, -) - -# --------------------------------------------------------------------------- -# Simulated database (no real DB needed) -# --------------------------------------------------------------------------- - -SIMULATED_DB = { - "employees": [ - {"id": 1, "name": "Alice Johnson", "department": "Engineering", "role": "Senior Engineer"}, - {"id": 2, "name": "Bob Williams", "department": "Marketing", "role": "Marketing Lead"}, - {"id": 3, "name": "Carol Davis", "department": "Engineering", "role": "Staff Engineer"}, - ], - "projects": [ - {"id": 101, "name": "Project Alpha", "status": "active", "budget": 150000}, - {"id": 102, "name": "Project Beta", "status": "completed", "budget": 80000}, - {"id": 103, "name": "Project Gamma", "status": "active", "budget": 220000}, - ], - "quarterly_metrics": [ - {"quarter": "Q1", "revenue": 2400000, "growth": 0.12}, - {"quarter": "Q2", "revenue": 2700000, "growth": 0.15}, - {"quarter": "Q3", "revenue": 3100000, "growth": 0.18}, - ], -} - - -def simulate_query(query: str) -> str: - """Return simulated data based on query content.""" - q = query.lower() - if "employee" in q or "staff" in q: - rows = SIMULATED_DB["employees"] - elif "project" in q: - rows = SIMULATED_DB["projects"] - elif "metric" in q or "revenue" in q or "quarterly" in q: - rows = SIMULATED_DB["quarterly_metrics"] - else: - rows = SIMULATED_DB["quarterly_metrics"] # default - - # Respect LIMIT if present - limit_match = re.search(r"limit\s+(\d+)", q) - if limit_match: - limit = int(limit_match.group(1)) - rows = rows[:limit] - - return json.dumps(rows, indent=2) - - -# =========================================================================== -# Tool 1: Researcher -- query_database -# =========================================================================== - -async def _query_database(query: str) -> str: - """Execute a database query (simulated). Protected by Agent Control.""" - return simulate_query(query) - - -# Mark as tool for step detection -_query_database.name = "query_database" # type: ignore[attr-defined] -_query_database.tool_name = "query_database" # type: ignore[attr-defined] - -# Apply @control() decorator -_controlled_query_database = control()(_query_database) - - -@tool("query_database") -def query_database(query: str) -> str: - """Query the research database. Input must be a SQL SELECT statement with a LIMIT clause. - Dangerous operations (DROP, DELETE, TRUNCATE) are blocked. - Access to sensitive tables (salary_data, admin_users) is denied. - - Args: - query: A SQL SELECT query string. - """ - print(f"\n [Researcher] Executing query: {query[:80]}...") - try: - result = asyncio.run(_controlled_query_database(query=query)) - print(" [Researcher] Query succeeded.") - return result - except ControlViolationError as e: - msg = f"BLOCKED by data-access-policy: {e.message}" - print(f" [Researcher] {msg}") - return msg - except RuntimeError as e: - msg = f"ERROR: {e}" - print(f" [Researcher] {msg}") - return msg - - -# =========================================================================== -# Tool 2: Analyst -- validate_data -# =========================================================================== - -async def _validate_data(request: dict) -> str: - """Validate and process research data. Protected by Agent Control. - - The request dict should contain: - - dataset: str - - findings: str - - confidence_score: float (0-1) - - methodology: str (optional, but will be steered if missing) - """ - # If we reach here, controls passed -- produce analysis output - return json.dumps({ - "status": "validated", - "dataset": request.get("dataset"), - "findings": request.get("findings"), - "confidence_score": request.get("confidence_score"), - "methodology": request.get("methodology", ""), - "summary": ( - f"Analysis of '{request.get('dataset')}' confirms findings with " - f"confidence {request.get('confidence_score')}. " - f"Methodology: {request.get('methodology', 'N/A')}." - ), - }, indent=2) - - -_validate_data.name = "validate_data" # type: ignore[attr-defined] -_validate_data.tool_name = "validate_data" # type: ignore[attr-defined] - -_controlled_validate_data = control()(_validate_data) - - -@tool("validate_data") -def validate_data(request_json: str) -> str: - """Validate research data. Input must be a JSON string with fields: - dataset (str), findings (str), confidence_score (float 0-1). - A methodology field is recommended -- you will be asked to add one if missing. - - Args: - request_json: JSON string with the analysis request. - """ - try: - request = json.loads(request_json) - except json.JSONDecodeError: - return "ERROR: Input must be valid JSON." - - print(f"\n [Analyst] Validating data for dataset: {request.get('dataset', '?')}") - - max_attempts = 3 - current_request = dict(request) - - for attempt in range(1, max_attempts + 1): - try: - result = asyncio.run(_controlled_validate_data(request=current_request)) - print(f" [Analyst] Validation passed (attempt {attempt}).") - return result - except ControlViolationError as e: - msg = f"BLOCKED by analysis-validation-policy: {e.message}" - print(f" [Analyst] {msg}") - return msg - except ControlSteerError as e: - print(f" [Analyst] STEERED (attempt {attempt}): {e.message}") - # Parse steering context for corrective instructions - try: - guidance = json.loads(e.steering_context) - except (json.JSONDecodeError, TypeError): - guidance = {"reason": e.steering_context} - - reason = guidance.get("reason", "Correction required") - print(f" [Analyst] Steering reason: {reason}") - - # Apply corrections - retry_with = guidance.get("retry_with", {}) - for key, hint in retry_with.items(): - if key not in current_request or not current_request[key]: - # Auto-fill with a reasonable default - if key == "methodology": - current_request[key] = ( - "Data collected via automated database queries. " - "Validated through cross-referencing multiple tables " - "and statistical confidence scoring." - ) - else: - current_request[key] = hint - print(f" [Analyst] Added missing field '{key}'.") - continue - - return "ERROR: Failed to pass validation after max attempts." - - -# =========================================================================== -# Tool 3: Writer -- write_report -# =========================================================================== - -async def _write_report(content: str, sources: str = "") -> str: - """Generate a formatted report. Protected by Agent Control. - - The @control decorator sends the output to the server for PII checking. - """ - # Build the report body - report = f"""Research Report -{'=' * 40} - -{content} -""" - if sources: - report += f""" -Sources: -{sources} -""" - return report - - -_write_report.name = "write_report" # type: ignore[attr-defined] -_write_report.tool_name = "write_report" # type: ignore[attr-defined] - -_controlled_write_report = control()(_write_report) - - -@tool("write_report") -def write_report(content: str) -> str: - """Generate the final research report. The content should NOT contain - PII (social security numbers, email addresses, phone numbers). - Include a 'Sources:' section at the end for citations. - - Args: - content: The report body text, including a Sources section. - """ - # Split content and sources if the LLM included them together - sources = "" - for marker in ["Sources:", "References:", "Citations:"]: - if marker in content: - parts = content.split(marker, 1) - content = parts[0].strip() - sources = parts[1].strip() - break - - print(f"\n [Writer] Generating report ({len(content)} chars)...") - - max_attempts = 3 - current_content = content - current_sources = sources - - for attempt in range(1, max_attempts + 1): - try: - result = asyncio.run( - _controlled_write_report(content=current_content, sources=current_sources) - ) - - # Client-side citation check: steer if no sources section - if not current_sources: - print(f" [Writer] STEERED (attempt {attempt}): Report lacks source citations.") - current_sources = "Internal database queries (employees, projects, quarterly_metrics tables)" - print(" [Writer] Added default citation sources.") - # Re-run with sources added - result = asyncio.run( - _controlled_write_report(content=current_content, sources=current_sources) - ) - - print(f" [Writer] Report generated successfully (attempt {attempt}).") - return result - - except ControlViolationError as e: - msg = f"BLOCKED by content-safety-policy: {e.message}" - print(f" [Writer] {msg}") - return msg - - except ControlSteerError as e: - print(f" [Writer] STEERED (attempt {attempt}): {e.message}") - try: - guidance = json.loads(e.steering_context) - except (json.JSONDecodeError, TypeError): - guidance = {"reason": e.steering_context} - print(f" [Writer] Steering reason: {guidance.get('reason', e.steering_context)}") - continue - - return "ERROR: Failed to generate report after max attempts." - - -# =========================================================================== -# Scenario runner (direct tool calls -- avoids LLM costs for testing) -# =========================================================================== - -def header(title: str) -> None: - """Print a scenario header.""" - print("\n" + "=" * 70) - print(f" {title}") - print("=" * 70) - - -def run_scenario_1_happy_path(): - """Happy path: all three tools pass controls, report generated.""" - header("SCENARIO 1: Happy Path -- All Agents Pass Controls") - - # Step 1: Researcher queries safely - print("\n--- Step 1: Researcher queries database ---") - data = query_database.run("SELECT name, department FROM employees LIMIT 10") - print(f" Result: {data[:120]}...") - - # Step 2: Analyst validates with all required fields + methodology - print("\n--- Step 2: Analyst validates data ---") - analysis_request = json.dumps({ - "dataset": "employees", - "findings": "Engineering department has 2 senior staff members", - "confidence_score": 0.92, - "methodology": "Cross-referenced employee records with department roster", - }) - validated = validate_data.run(analysis_request) - print(f" Result: {validated[:120]}...") - - # Step 3: Writer generates clean report - print("\n--- Step 3: Writer generates report ---") - report_content = ( - "The engineering department analysis reveals 2 senior staff members " - "with strong project involvement. Project Alpha and Gamma are active " - "with combined budgets exceeding $370,000. Quarterly revenue shows " - "consistent 12-18% growth.\n\n" - "Sources: Internal database (employees, projects, quarterly_metrics)" - ) - report = write_report.run(report_content) - print(f" Result:\n{report}") - - -def run_scenario_2_sql_injection(): - """Researcher blocked: SQL injection attempt with DROP TABLE.""" - header("SCENARIO 2: Researcher Blocked -- SQL Injection Attempt") - - result = query_database.run("DROP TABLE employees; SELECT * FROM employees LIMIT 10") - print(f" Result: {result}") - assert "BLOCKED" in result, "Expected the query to be blocked!" - print("\n [OK] SQL injection correctly blocked by data-access-policy") - - -def run_scenario_3_restricted_table(): - """Researcher restricted: query to salary_data table denied.""" - header("SCENARIO 3: Researcher Restricted -- Sensitive Table Access") - - result = query_database.run("SELECT * FROM salary_data LIMIT 10") - print(f" Result: {result}") - assert "BLOCKED" in result, "Expected the query to be blocked!" - print("\n [OK] Sensitive table access correctly blocked by data-access-policy") - - -def run_scenario_4_analyst_steered(): - """Analyst steered: methodology missing, auto-corrected, then succeeds.""" - header("SCENARIO 4: Analyst Steered -- Missing Methodology") - - # First attempt WITHOUT methodology -- should be steered, then auto-corrected - analysis_request = json.dumps({ - "dataset": "quarterly_metrics", - "findings": "Revenue growing at 15% average quarterly rate", - "confidence_score": 0.88, - # NOTE: methodology intentionally omitted - }) - result = validate_data.run(analysis_request) - print(f" Result: {result[:200]}...") - - # The tool should have auto-added methodology after steering - if "validated" in result: - print("\n [OK] Analyst was steered to add methodology and succeeded on retry") - elif "BLOCKED" in result: - print("\n [INFO] Analyst was blocked (deny control fired before steer)") - else: - print(f"\n [INFO] Result: {result}") - - -def run_scenario_5_writer_pii(): - """Writer blocked: PII detected in report output.""" - header("SCENARIO 5: Writer Blocked -- PII in Report Output") - - # Content that contains PII (email and phone number) - pii_content = ( - "The project lead is Alice Johnson. For questions, contact her at " - "alice.johnson@company.com or call 555-123-4567. " - "Her SSN is 123-45-6789.\n\n" - "Sources: HR database, project management system" - ) - result = write_report.run(pii_content) - print(f" Result: {result}") - assert "BLOCKED" in result, "Expected PII to be blocked!" - print("\n [OK] PII correctly blocked by content-safety-policy") - - -def run_full_crew(): - """Run the full 3-agent CrewAI crew for the happy path.""" - header("FULL CREW RUN: 3-Agent Sequential Pipeline") - - if not os.getenv("OPENAI_API_KEY"): - print("\n [SKIP] OPENAI_API_KEY not set -- skipping full crew run.") - print(" The direct tool scenarios above demonstrate all control behavior.") - return - - # Define agents - researcher = Agent( - role="Research Data Analyst", - goal="Query the database to gather employee and project data for analysis", - backstory=( - "You are a meticulous data researcher who queries databases to gather " - "information. You always use proper SQL with LIMIT clauses and never " - "attempt to access restricted tables." - ), - tools=[query_database], - verbose=True, - ) - - analyst = Agent( - role="Data Validation Analyst", - goal="Validate the research data and produce a structured analysis with methodology", - backstory=( - "You are a rigorous analyst who validates data quality. You always " - "include dataset name, findings, confidence scores, AND methodology " - "in your analysis. You format your output as JSON." - ), - tools=[validate_data], - verbose=True, - ) - - writer = Agent( - role="Report Writer", - goal="Generate a professional research report without any PII", - backstory=( - "You are a skilled report writer who creates clear, professional " - "research reports. You NEVER include personal information like " - "email addresses, phone numbers, or SSNs. You always cite sources." - ), - tools=[write_report], - verbose=True, - ) - - # Define tasks - research_task = Task( - description=( - "Query the employees and projects tables to gather data about " - "engineering department staffing and active projects. " - "Use the query_database tool with proper SQL SELECT statements " - "that include LIMIT clauses." - ), - expected_output="Raw data from employee and project queries in JSON format", - agent=researcher, - ) - - analysis_task = Task( - description=( - "Take the research data and validate it using the validate_data tool. " - "Submit a JSON string with these fields: dataset, findings, " - "confidence_score (0-1), and methodology. Example:\n" - '{"dataset": "employees", "findings": "...", ' - '"confidence_score": 0.9, "methodology": "..."}' - ), - expected_output="Validated analysis with methodology in JSON format", - agent=analyst, - ) - - report_task = Task( - description=( - "Write a professional research report using the write_report tool. " - "The report should summarize the validated analysis findings. " - "Do NOT include any email addresses, phone numbers, or SSNs. " - "Include a 'Sources:' section at the end citing the data tables used." - ), - expected_output="A formatted research report with findings and source citations", - agent=writer, - ) - - # Create and run crew - crew = Crew( - agents=[researcher, analyst, writer], - tasks=[research_task, analysis_task, report_task], - verbose=True, - ) - - print("\n Running crew... (this uses LLM calls)\n") - result = crew.kickoff() - print("\n" + "-" * 70) - print(" CREW OUTPUT:") - print("-" * 70) - print(result) - - -# =========================================================================== -# Verification -# =========================================================================== - -def verify_setup() -> bool: - """Check that the Agent Control server is running and controls are configured.""" - import httpx - - try: - print("[setup] Verifying Agent Control server...") - response = httpx.get(f"{SERVER_URL}/api/v1/controls?limit=100", timeout=5.0) - response.raise_for_status() - - data = response.json() - control_names = [c["name"] for c in data.get("controls", [])] - - required = [ - "researcher-sql-safety", - "researcher-restricted-tables", - "analyst-required-fields", - "analyst-methodology-check", - "writer-pii-blocker", - ] - missing = [c for c in required if c not in control_names] - - if missing: - print(f"[setup] Missing controls: {missing}") - print("[setup] Run setup_controls.py first.") - return False - - print(f"[setup] Server OK -- {len(control_names)} controls found") - return True - - except httpx.ConnectError: - print(f"[setup] Cannot connect to {SERVER_URL}") - print("[setup] Start the Agent Control server first (make server-run)") - return False - except Exception as e: - print(f"[setup] Error: {e}") - return False - - -# =========================================================================== -# Main -# =========================================================================== - -def main(): - print("=" * 70) - print(" Secure Research Crew -- Agent Control Multi-Agent Demo") - print("=" * 70) - print() - print("This demo runs 5 scenarios showing how different Agent Control") - print("policies protect each agent in a CrewAI crew:") - print() - print(" 1. Happy path -- all agents pass controls") - print(" 2. SQL injection -- researcher blocked by SQL evaluator") - print(" 3. Restricted table -- researcher blocked by LIST evaluator") - print(" 4. Missing methodology-- analyst steered, then succeeds") - print(" 5. PII in report -- writer blocked by REGEX evaluator") - print() - - if not verify_setup(): - print("\nSetup verification failed. Exiting.") - return - - # Run all direct-call scenarios (no LLM needed) - run_scenario_1_happy_path() - run_scenario_2_sql_injection() - run_scenario_3_restricted_table() - run_scenario_4_analyst_steered() - run_scenario_5_writer_pii() - - # Summary - header("SUMMARY") - print(""" - Scenario 1 (Happy Path): All 3 agents passed controls - Scenario 2 (SQL Injection): Researcher BLOCKED by sql evaluator - Scenario 3 (Restricted Table): Researcher BLOCKED by list evaluator - Scenario 4 (Missing Method): Analyst STEERED, then succeeded - Scenario 5 (PII in Report): Writer BLOCKED by regex evaluator - - Controls are enforced per-agent via policies: - - data-access-policy -> query_database tool - - analysis-validation-policy -> validate_data tool - - content-safety-policy -> write_report tool - - Each policy targets specific step_names, so controls only fire - for the tools belonging to that agent role. -""") - - # Optionally run full crew (requires OPENAI_API_KEY) - print("-" * 70) - if not os.getenv("OPENAI_API_KEY"): - print(" Skipping full crew run (OPENAI_API_KEY not set).") - else: - answer = input(" Run full CrewAI crew with LLM? (y/N): ").strip().lower() - if answer == "y": - run_full_crew() - else: - print(" Skipping full crew run.") - - print("\n" + "=" * 70) - print(" Demo complete!") - print("=" * 70) - - -if __name__ == "__main__": - main() diff --git a/examples/crewai/secure_research_crew/setup_controls.py b/examples/crewai/secure_research_crew/setup_controls.py index 99bd4a1c..c4981a60 100644 --- a/examples/crewai/secure_research_crew/setup_controls.py +++ b/examples/crewai/secure_research_crew/setup_controls.py @@ -351,7 +351,7 @@ async def setup(): content-safety-policy (Writer -> write_report): - writer-pii-blocker [post, deny] Block SSN, email, phone in output -You can now run: uv run python research_crew.py +You can now run: uv run python -m secure_research_crew.main """) diff --git a/examples/crewai/secure_research_crew/src/secure_research_crew/__init__.py b/examples/crewai/secure_research_crew/src/secure_research_crew/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/crewai/secure_research_crew/src/secure_research_crew/config/agents.yaml b/examples/crewai/secure_research_crew/src/secure_research_crew/config/agents.yaml new file mode 100644 index 00000000..f0e0b50a --- /dev/null +++ b/examples/crewai/secure_research_crew/src/secure_research_crew/config/agents.yaml @@ -0,0 +1,23 @@ +researcher: + role: "Research Data Analyst" + goal: "Query the database to gather employee and project data for analysis" + backstory: > + You are a meticulous data researcher who queries databases to gather + information. You always use proper SQL with LIMIT clauses and never + attempt to access restricted tables. + +analyst: + role: "Data Validation Analyst" + goal: "Validate the research data and produce a structured analysis with methodology" + backstory: > + You are a rigorous analyst who validates data quality. You always + include dataset name, findings, confidence scores, AND methodology + in your analysis. You format your output as JSON. + +writer: + role: "Report Writer" + goal: "Generate a professional research report without any PII" + backstory: > + You are a skilled report writer who creates clear, professional + research reports. You NEVER include personal information like + email addresses, phone numbers, or SSNs. You always cite sources. diff --git a/examples/crewai/secure_research_crew/src/secure_research_crew/config/tasks.yaml b/examples/crewai/secure_research_crew/src/secure_research_crew/config/tasks.yaml new file mode 100644 index 00000000..8f731763 --- /dev/null +++ b/examples/crewai/secure_research_crew/src/secure_research_crew/config/tasks.yaml @@ -0,0 +1,24 @@ +research_task: + description: > + Query the employees and projects tables to gather data about + engineering department staffing and active projects. + Use the query_database tool with proper SQL SELECT statements + that include LIMIT clauses. + expected_output: "Raw data from employee and project queries in JSON format" + +analysis_task: + description: > + Take the research data and validate it using the validate_data tool. + Submit a JSON string with these fields: dataset, findings, + confidence_score (0-1), and methodology. Example: + {"dataset": "employees", "findings": "...", + "confidence_score": 0.9, "methodology": "..."} + expected_output: "Validated analysis with methodology in JSON format" + +report_task: + description: > + Write a professional research report using the write_report tool. + The report should summarize the validated analysis findings. + Do NOT include any email addresses, phone numbers, or SSNs. + Include a 'Sources:' section at the end citing the data tables used. + expected_output: "A formatted research report with findings and source citations" diff --git a/examples/crewai/secure_research_crew/src/secure_research_crew/crew.py b/examples/crewai/secure_research_crew/src/secure_research_crew/crew.py new file mode 100644 index 00000000..55bd0223 --- /dev/null +++ b/examples/crewai/secure_research_crew/src/secure_research_crew/crew.py @@ -0,0 +1,67 @@ +"""SecureResearchCrew -- @CrewBase class definition.""" + +from crewai import Agent, Crew, Process, Task +from crewai.project import CrewBase, agent, crew, task + +from secure_research_crew.tools.query_database import query_database +from secure_research_crew.tools.validate_data import validate_data +from secure_research_crew.tools.write_report import write_report + + +@CrewBase +class SecureResearchCrew: + """Multi-agent research crew with per-agent Agent Control policies.""" + + agents_config = "config/agents.yaml" + tasks_config = "config/tasks.yaml" + + @agent + def researcher(self) -> Agent: + return Agent( + config=self.agents_config["researcher"], # type: ignore[index] + tools=[query_database], + verbose=True, + ) + + @agent + def analyst(self) -> Agent: + return Agent( + config=self.agents_config["analyst"], # type: ignore[index] + tools=[validate_data], + verbose=True, + ) + + @agent + def writer(self) -> Agent: + return Agent( + config=self.agents_config["writer"], # type: ignore[index] + tools=[write_report], + verbose=True, + ) + + @task + def research_task(self) -> Task: + return Task( + config=self.tasks_config["research_task"], # type: ignore[index] + ) + + @task + def analysis_task(self) -> Task: + return Task( + config=self.tasks_config["analysis_task"], # type: ignore[index] + ) + + @task + def report_task(self) -> Task: + return Task( + config=self.tasks_config["report_task"], # type: ignore[index] + ) + + @crew + def crew(self) -> Crew: + return Crew( + agents=self.agents, # type: ignore[arg-type] + tasks=self.tasks, # type: ignore[arg-type] + process=Process.sequential, + verbose=True, + ) diff --git a/examples/crewai/secure_research_crew/src/secure_research_crew/main.py b/examples/crewai/secure_research_crew/src/secure_research_crew/main.py new file mode 100644 index 00000000..82c70a9d --- /dev/null +++ b/examples/crewai/secure_research_crew/src/secure_research_crew/main.py @@ -0,0 +1,288 @@ +""" +Secure Research Crew -- CrewAI multi-agent demo with per-agent Agent Control policies. + +A 3-agent sequential crew where each agent has different controls: + + 1. Researcher -- queries a simulated database + Controls: SQL evaluator (block DROP/DELETE, enforce LIMIT) + LIST evaluator (block sensitive tables) + + 2. Analyst -- validates and processes research data + Controls: JSON evaluator (require dataset, findings, confidence_score) + JSON schema steer (add methodology if missing) + + 3. Writer -- generates the final report + Controls: REGEX evaluator (block PII in output) + Client-side citation check (steer if missing) + +Scenarios: + 1. Happy path -- all agents pass, report generated + 2. Researcher blocked -- SQL injection attempt (DROP TABLE) + 3. Researcher restricted-- query to salary_data table + 4. Analyst steered -- missing methodology, corrected, then succeeds + 5. Writer blocked -- PII in report output + +PREREQUISITE: + uv run python setup_controls.py + +Usage: + uv run secure_research_crew +""" + +import json +import os + +import agent_control + +# --------------------------------------------------------------------------- +# Configuration -- init must happen before tools are invoked (but can be +# after they are imported, since @control() is lazy). +# --------------------------------------------------------------------------- + +AGENT_NAME = "secure-research-crew" +SERVER_URL = os.getenv("AGENT_CONTROL_URL", "http://localhost:8000") + +agent_control.init( + agent_name=AGENT_NAME, + agent_description="Multi-agent research crew with per-agent policies", + server_url=SERVER_URL, +) + +# Import tools AFTER agent_control.init() -- the @control() decorator is +# applied at import time but does not call the server until invoked. +from secure_research_crew.tools.query_database import query_database # noqa: E402 +from secure_research_crew.tools.validate_data import validate_data # noqa: E402 +from secure_research_crew.tools.write_report import write_report # noqa: E402 + + +# =========================================================================== +# Scenario runners (direct tool calls -- avoids LLM costs for testing) +# =========================================================================== + +def header(title: str) -> None: + """Print a scenario header.""" + print("\n" + "=" * 70) + print(f" {title}") + print("=" * 70) + + +def run_scenario_1_happy_path(): + """Happy path: all three tools pass controls, report generated.""" + header("SCENARIO 1: Happy Path -- All Agents Pass Controls") + + # Step 1: Researcher queries safely + print("\n--- Step 1: Researcher queries database ---") + data = query_database.run("SELECT name, department FROM employees LIMIT 10") + print(f" Result: {data[:120]}...") + + # Step 2: Analyst validates with all required fields + methodology + print("\n--- Step 2: Analyst validates data ---") + analysis_request = json.dumps({ + "dataset": "employees", + "findings": "Engineering department has 2 senior staff members", + "confidence_score": 0.92, + "methodology": "Cross-referenced employee records with department roster", + }) + validated = validate_data.run(analysis_request) + print(f" Result: {validated[:120]}...") + + # Step 3: Writer generates clean report + print("\n--- Step 3: Writer generates report ---") + report_content = ( + "The engineering department analysis reveals 2 senior staff members " + "with strong project involvement. Project Alpha and Gamma are active " + "with combined budgets exceeding $370,000. Quarterly revenue shows " + "consistent 12-18% growth.\n\n" + "Sources: Internal database (employees, projects, quarterly_metrics)" + ) + report = write_report.run(report_content) + print(f" Result:\n{report}") + + +def run_scenario_2_sql_injection(): + """Researcher blocked: SQL injection attempt with DROP TABLE.""" + header("SCENARIO 2: Researcher Blocked -- SQL Injection Attempt") + + result = query_database.run("DROP TABLE employees; SELECT * FROM employees LIMIT 10") + print(f" Result: {result}") + assert "BLOCKED" in result, "Expected the query to be blocked!" + print("\n [OK] SQL injection correctly blocked by data-access-policy") + + +def run_scenario_3_restricted_table(): + """Researcher restricted: query to salary_data table denied.""" + header("SCENARIO 3: Researcher Restricted -- Sensitive Table Access") + + result = query_database.run("SELECT * FROM salary_data LIMIT 10") + print(f" Result: {result}") + assert "BLOCKED" in result, "Expected the query to be blocked!" + print("\n [OK] Sensitive table access correctly blocked by data-access-policy") + + +def run_scenario_4_analyst_steered(): + """Analyst steered: methodology missing, auto-corrected, then succeeds.""" + header("SCENARIO 4: Analyst Steered -- Missing Methodology") + + # First attempt WITHOUT methodology -- should be steered, then auto-corrected + analysis_request = json.dumps({ + "dataset": "quarterly_metrics", + "findings": "Revenue growing at 15% average quarterly rate", + "confidence_score": 0.88, + # NOTE: methodology intentionally omitted + }) + result = validate_data.run(analysis_request) + print(f" Result: {result[:200]}...") + + # The tool should have auto-added methodology after steering + if "validated" in result: + print("\n [OK] Analyst was steered to add methodology and succeeded on retry") + elif "BLOCKED" in result: + print("\n [INFO] Analyst was blocked (deny control fired before steer)") + else: + print(f"\n [INFO] Result: {result}") + + +def run_scenario_5_writer_pii(): + """Writer blocked: PII detected in report output.""" + header("SCENARIO 5: Writer Blocked -- PII in Report Output") + + # Content that contains PII (email and phone number) + pii_content = ( + "The project lead is Alice Johnson. For questions, contact her at " + "alice.johnson@company.com or call 555-123-4567. " + "Her SSN is 123-45-6789.\n\n" + "Sources: HR database, project management system" + ) + result = write_report.run(pii_content) + print(f" Result: {result}") + assert "BLOCKED" in result, "Expected PII to be blocked!" + print("\n [OK] PII correctly blocked by content-safety-policy") + + +def run_full_crew(): + """Run the full 3-agent CrewAI crew for the happy path.""" + header("FULL CREW RUN: 3-Agent Sequential Pipeline") + + if not os.getenv("OPENAI_API_KEY"): + print("\n [SKIP] OPENAI_API_KEY not set -- skipping full crew run.") + print(" The direct tool scenarios above demonstrate all control behavior.") + return + + from secure_research_crew.crew import SecureResearchCrew + + print("\n Running crew... (this uses LLM calls)\n") + result = SecureResearchCrew().crew().kickoff() + print("\n" + "-" * 70) + print(" CREW OUTPUT:") + print("-" * 70) + print(result) + + +# =========================================================================== +# Verification +# =========================================================================== + +def verify_setup() -> bool: + """Check that the Agent Control server is running and controls are configured.""" + import httpx + + try: + print("[setup] Verifying Agent Control server...") + response = httpx.get(f"{SERVER_URL}/api/v1/controls?limit=100", timeout=5.0) + response.raise_for_status() + + data = response.json() + control_names = [c["name"] for c in data.get("controls", [])] + + required = [ + "researcher-sql-safety", + "researcher-restricted-tables", + "analyst-required-fields", + "analyst-methodology-check", + "writer-pii-blocker", + ] + missing = [c for c in required if c not in control_names] + + if missing: + print(f"[setup] Missing controls: {missing}") + print("[setup] Run setup_controls.py first.") + return False + + print(f"[setup] Server OK -- {len(control_names)} controls found") + return True + + except httpx.ConnectError: + print(f"[setup] Cannot connect to {SERVER_URL}") + print("[setup] Start the Agent Control server first (make server-run)") + return False + except Exception as e: + print(f"[setup] Error: {e}") + return False + + +# =========================================================================== +# Main +# =========================================================================== + +def main(): + print("=" * 70) + print(" Secure Research Crew -- Agent Control Multi-Agent Demo") + print("=" * 70) + print() + print("This demo runs 5 scenarios showing how different Agent Control") + print("policies protect each agent in a CrewAI crew:") + print() + print(" 1. Happy path -- all agents pass controls") + print(" 2. SQL injection -- researcher blocked by SQL evaluator") + print(" 3. Restricted table -- researcher blocked by LIST evaluator") + print(" 4. Missing methodology-- analyst steered, then succeeds") + print(" 5. PII in report -- writer blocked by REGEX evaluator") + print() + + if not verify_setup(): + print("\nSetup verification failed. Exiting.") + return + + # Run all direct-call scenarios (no LLM needed) + run_scenario_1_happy_path() + run_scenario_2_sql_injection() + run_scenario_3_restricted_table() + run_scenario_4_analyst_steered() + run_scenario_5_writer_pii() + + # Summary + header("SUMMARY") + print(""" + Scenario 1 (Happy Path): All 3 agents passed controls + Scenario 2 (SQL Injection): Researcher BLOCKED by sql evaluator + Scenario 3 (Restricted Table): Researcher BLOCKED by list evaluator + Scenario 4 (Missing Method): Analyst STEERED, then succeeded + Scenario 5 (PII in Report): Writer BLOCKED by regex evaluator + + Controls are enforced per-agent via policies: + - data-access-policy -> query_database tool + - analysis-validation-policy -> validate_data tool + - content-safety-policy -> write_report tool + + Each policy targets specific step_names, so controls only fire + for the tools belonging to that agent role. +""") + + # Optionally run full crew (requires OPENAI_API_KEY) + print("-" * 70) + if not os.getenv("OPENAI_API_KEY"): + print(" Skipping full crew run (OPENAI_API_KEY not set).") + else: + answer = input(" Run full CrewAI crew with LLM? (y/N): ").strip().lower() + if answer == "y": + run_full_crew() + else: + print(" Skipping full crew run.") + + print("\n" + "=" * 70) + print(" Demo complete!") + print("=" * 70) + + +if __name__ == "__main__": + main() diff --git a/examples/crewai/secure_research_crew/src/secure_research_crew/tools/__init__.py b/examples/crewai/secure_research_crew/src/secure_research_crew/tools/__init__.py new file mode 100644 index 00000000..b6c00b21 --- /dev/null +++ b/examples/crewai/secure_research_crew/src/secure_research_crew/tools/__init__.py @@ -0,0 +1,5 @@ +from secure_research_crew.tools.query_database import query_database +from secure_research_crew.tools.validate_data import validate_data +from secure_research_crew.tools.write_report import write_report + +__all__ = ["query_database", "validate_data", "write_report"] diff --git a/examples/crewai/secure_research_crew/src/secure_research_crew/tools/query_database.py b/examples/crewai/secure_research_crew/src/secure_research_crew/tools/query_database.py new file mode 100644 index 00000000..8975363d --- /dev/null +++ b/examples/crewai/secure_research_crew/src/secure_research_crew/tools/query_database.py @@ -0,0 +1,96 @@ +"""Researcher tool: query_database -- queries a simulated database with Agent Control protection.""" + +import asyncio +import json +import re + +from agent_control import ControlViolationError, control +from crewai.tools import tool + +# --------------------------------------------------------------------------- +# Simulated database (no real DB needed) +# --------------------------------------------------------------------------- + +SIMULATED_DB = { + "employees": [ + {"id": 1, "name": "Alice Johnson", "department": "Engineering", "role": "Senior Engineer"}, + {"id": 2, "name": "Bob Williams", "department": "Marketing", "role": "Marketing Lead"}, + {"id": 3, "name": "Carol Davis", "department": "Engineering", "role": "Staff Engineer"}, + ], + "projects": [ + {"id": 101, "name": "Project Alpha", "status": "active", "budget": 150000}, + {"id": 102, "name": "Project Beta", "status": "completed", "budget": 80000}, + {"id": 103, "name": "Project Gamma", "status": "active", "budget": 220000}, + ], + "quarterly_metrics": [ + {"quarter": "Q1", "revenue": 2400000, "growth": 0.12}, + {"quarter": "Q2", "revenue": 2700000, "growth": 0.15}, + {"quarter": "Q3", "revenue": 3100000, "growth": 0.18}, + ], +} + + +def simulate_query(query: str) -> str: + """Return simulated data based on query content.""" + q = query.lower() + if "employee" in q or "staff" in q: + rows = SIMULATED_DB["employees"] + elif "project" in q: + rows = SIMULATED_DB["projects"] + elif "metric" in q or "revenue" in q or "quarterly" in q: + rows = SIMULATED_DB["quarterly_metrics"] + else: + rows = SIMULATED_DB["quarterly_metrics"] # default + + # Respect LIMIT if present + limit_match = re.search(r"limit\s+(\d+)", q) + if limit_match: + limit = int(limit_match.group(1)) + rows = rows[:limit] + + return json.dumps(rows, indent=2) + + +# --------------------------------------------------------------------------- +# Async inner function + @control() decorator +# --------------------------------------------------------------------------- + +async def _query_database(query: str) -> str: + """Execute a database query (simulated). Protected by Agent Control.""" + return simulate_query(query) + + +# Mark as tool for step detection +_query_database.name = "query_database" # type: ignore[attr-defined] +_query_database.tool_name = "query_database" # type: ignore[attr-defined] + +# Apply @control() decorator +_controlled_query_database = control()(_query_database) + + +# --------------------------------------------------------------------------- +# CrewAI @tool wrapper +# --------------------------------------------------------------------------- + +@tool("query_database") +def query_database(query: str) -> str: + """Query the research database. Input must be a SQL SELECT statement with a LIMIT clause. + Dangerous operations (DROP, DELETE, TRUNCATE) are blocked. + Access to sensitive tables (salary_data, admin_users) is denied. + + Args: + query: A SQL SELECT query string. + """ + print(f"\n [Researcher] Executing query: {query[:80]}...") + try: + result = asyncio.run(_controlled_query_database(query=query)) + print(" [Researcher] Query succeeded.") + return result + except ControlViolationError as e: + msg = f"BLOCKED by data-access-policy: {e.message}" + print(f" [Researcher] {msg}") + return msg + except RuntimeError as e: + msg = f"ERROR: {e}" + print(f" [Researcher] {msg}") + return msg diff --git a/examples/crewai/secure_research_crew/src/secure_research_crew/tools/validate_data.py b/examples/crewai/secure_research_crew/src/secure_research_crew/tools/validate_data.py new file mode 100644 index 00000000..02573c06 --- /dev/null +++ b/examples/crewai/secure_research_crew/src/secure_research_crew/tools/validate_data.py @@ -0,0 +1,102 @@ +"""Analyst tool: validate_data -- validates research data with Agent Control steering support.""" + +import asyncio +import json + +from agent_control import ControlSteerError, ControlViolationError, control +from crewai.tools import tool + +# --------------------------------------------------------------------------- +# Async inner function + @control() decorator +# --------------------------------------------------------------------------- + +async def _validate_data(request: dict) -> str: + """Validate and process research data. Protected by Agent Control. + + The request dict should contain: + - dataset: str + - findings: str + - confidence_score: float (0-1) + - methodology: str (optional, but will be steered if missing) + """ + # If we reach here, controls passed -- produce analysis output + return json.dumps({ + "status": "validated", + "dataset": request.get("dataset"), + "findings": request.get("findings"), + "confidence_score": request.get("confidence_score"), + "methodology": request.get("methodology", ""), + "summary": ( + f"Analysis of '{request.get('dataset')}' confirms findings with " + f"confidence {request.get('confidence_score')}. " + f"Methodology: {request.get('methodology', 'N/A')}." + ), + }, indent=2) + + +_validate_data.name = "validate_data" # type: ignore[attr-defined] +_validate_data.tool_name = "validate_data" # type: ignore[attr-defined] + +_controlled_validate_data = control()(_validate_data) + + +# --------------------------------------------------------------------------- +# CrewAI @tool wrapper +# --------------------------------------------------------------------------- + +@tool("validate_data") +def validate_data(request_json: str) -> str: + """Validate research data. Input must be a JSON string with fields: + dataset (str), findings (str), confidence_score (float 0-1). + A methodology field is recommended -- you will be asked to add one if missing. + + Args: + request_json: JSON string with the analysis request. + """ + try: + request = json.loads(request_json) + except json.JSONDecodeError: + return "ERROR: Input must be valid JSON." + + print(f"\n [Analyst] Validating data for dataset: {request.get('dataset', '?')}") + + max_attempts = 3 + current_request = dict(request) + + for attempt in range(1, max_attempts + 1): + try: + result = asyncio.run(_controlled_validate_data(request=current_request)) + print(f" [Analyst] Validation passed (attempt {attempt}).") + return result + except ControlViolationError as e: + msg = f"BLOCKED by analysis-validation-policy: {e.message}" + print(f" [Analyst] {msg}") + return msg + except ControlSteerError as e: + print(f" [Analyst] STEERED (attempt {attempt}): {e.message}") + # Parse steering context for corrective instructions + try: + guidance = json.loads(e.steering_context) + except (json.JSONDecodeError, TypeError): + guidance = {"reason": e.steering_context} + + reason = guidance.get("reason", "Correction required") + print(f" [Analyst] Steering reason: {reason}") + + # Apply corrections + retry_with = guidance.get("retry_with", {}) + for key, hint in retry_with.items(): + if key not in current_request or not current_request[key]: + # Auto-fill with a reasonable default + if key == "methodology": + current_request[key] = ( + "Data collected via automated database queries. " + "Validated through cross-referencing multiple tables " + "and statistical confidence scoring." + ) + else: + current_request[key] = hint + print(f" [Analyst] Added missing field '{key}'.") + continue + + return "ERROR: Failed to pass validation after max attempts." diff --git a/examples/crewai/secure_research_crew/src/secure_research_crew/tools/write_report.py b/examples/crewai/secure_research_crew/src/secure_research_crew/tools/write_report.py new file mode 100644 index 00000000..a27f8312 --- /dev/null +++ b/examples/crewai/secure_research_crew/src/secure_research_crew/tools/write_report.py @@ -0,0 +1,99 @@ +"""Writer tool: write_report -- generates reports with Agent Control PII protection and citation check.""" + +import asyncio +import json + +from agent_control import ControlSteerError, ControlViolationError, control +from crewai.tools import tool + +# --------------------------------------------------------------------------- +# Async inner function + @control() decorator +# --------------------------------------------------------------------------- + +async def _write_report(content: str, sources: str = "") -> str: + """Generate a formatted report. Protected by Agent Control. + + The @control decorator sends the output to the server for PII checking. + """ + # Build the report body + report = f"""Research Report +{'=' * 40} + +{content} +""" + if sources: + report += f""" +Sources: +{sources} +""" + return report + + +_write_report.name = "write_report" # type: ignore[attr-defined] +_write_report.tool_name = "write_report" # type: ignore[attr-defined] + +_controlled_write_report = control()(_write_report) + + +# --------------------------------------------------------------------------- +# CrewAI @tool wrapper +# --------------------------------------------------------------------------- + +@tool("write_report") +def write_report(content: str) -> str: + """Generate the final research report. The content should NOT contain + PII (social security numbers, email addresses, phone numbers). + Include a 'Sources:' section at the end for citations. + + Args: + content: The report body text, including a Sources section. + """ + # Split content and sources if the LLM included them together + sources = "" + for marker in ["Sources:", "References:", "Citations:"]: + if marker in content: + parts = content.split(marker, 1) + content = parts[0].strip() + sources = parts[1].strip() + break + + print(f"\n [Writer] Generating report ({len(content)} chars)...") + + max_attempts = 3 + current_content = content + current_sources = sources + + for attempt in range(1, max_attempts + 1): + try: + result = asyncio.run( + _controlled_write_report(content=current_content, sources=current_sources) + ) + + # Client-side citation check: steer if no sources section + if not current_sources: + print(f" [Writer] STEERED (attempt {attempt}): Report lacks source citations.") + current_sources = "Internal database queries (employees, projects, quarterly_metrics tables)" + print(" [Writer] Added default citation sources.") + # Re-run with sources added + result = asyncio.run( + _controlled_write_report(content=current_content, sources=current_sources) + ) + + print(f" [Writer] Report generated successfully (attempt {attempt}).") + return result + + except ControlViolationError as e: + msg = f"BLOCKED by content-safety-policy: {e.message}" + print(f" [Writer] {msg}") + return msg + + except ControlSteerError as e: + print(f" [Writer] STEERED (attempt {attempt}): {e.message}") + try: + guidance = json.loads(e.steering_context) + except (json.JSONDecodeError, TypeError): + guidance = {"reason": e.steering_context} + print(f" [Writer] Steering reason: {guidance.get('reason', e.steering_context)}") + continue + + return "ERROR: Failed to generate report after max attempts." diff --git a/examples/crewai/steering_financial_agent/README.md b/examples/crewai/steering_financial_agent/README.md index 5ae0f9ae..4326f378 100644 --- a/examples/crewai/steering_financial_agent/README.md +++ b/examples/crewai/steering_financial_agent/README.md @@ -56,7 +56,7 @@ export OPENAI_API_KEY="your-key" uv run python setup_controls.py # Run the demo -uv run python financial_agent.py +uv run python -m steering_financial_agent.main ``` ## How Steering Works diff --git a/examples/crewai/steering_financial_agent/financial_agent.py b/examples/crewai/steering_financial_agent/financial_agent.py deleted file mode 100644 index 6cb6c2c2..00000000 --- a/examples/crewai/steering_financial_agent/financial_agent.py +++ /dev/null @@ -1,403 +0,0 @@ -""" -CrewAI Financial Agent with Steering, Deny, and Warn actions. - -Demonstrates the three key Agent Control action types in a realistic -wire-transfer scenario using a CrewAI crew: - - DENY - Sanctioned country or fraud score blocks the transfer immediately. - STEER - Large transfers pause execution and guide the agent through - 2FA verification or manager approval before retrying. - WARN - New recipients and PII in output are logged for audit - without blocking the transfer. - -PREREQUISITE: - Run setup_controls.py first: - - $ uv run python setup_controls.py - - Then run this example: - - $ uv run python financial_agent.py - -Scenarios: - 1. Small legitimate transfer -> ALLOW (warn on new recipient) - 2. Sanctioned country -> DENY (hard block) - 3. Large transfer ($15k) -> STEER (2FA required, then allowed) - 4. Very large transfer ($75k) -> STEER (manager approval, then allowed) - 5. High fraud score -> DENY (hard block) -""" - -import asyncio -import json -import os - -import agent_control -from agent_control import ControlSteerError, ControlViolationError, control -from crewai import Agent, Crew, LLM, Task -from crewai.tools import tool - -# ── Configuration ─────────────────────────────────────────────────────── -AGENT_NAME = "crewai-financial-agent" -SERVER_URL = os.getenv("AGENT_CONTROL_URL", "http://localhost:8000") - -agent_control.init( - agent_name=AGENT_NAME, - agent_description="CrewAI financial agent with steering controls", - server_url=SERVER_URL, -) - -# Simulated state — in production this comes from your auth system -SIMULATED_2FA_CODE = "482901" -SIMULATED_MANAGER = "Sarah Chen (VP Operations)" - - -# ── Protected Transfer Function ───────────────────────────────────────── - -def create_transfer_tool(): - """Build the CrewAI tool with @control protection and steering logic.""" - - llm = LLM(model="gpt-4o-mini", temperature=0.3) - - async def _process_transfer( - amount: float, - recipient: str, - destination_country: str, - fraud_score: float = 0.0, - verified_2fa: bool = False, - manager_approved: bool = False, - ) -> str: - """Process a wire transfer (protected by Agent Control).""" - prompt = f"""You are a banking operations system. Process this wire transfer -and return a short confirmation message (2-3 sentences). - -Transfer details: -- Amount: ${amount:,.2f} -- Recipient: {recipient} -- Destination: {destination_country} -- 2FA verified: {verified_2fa} -- Manager approved: {manager_approved} - -Return a professional confirmation with a reference number.""" - - return llm.call([{"role": "user", "content": prompt}]) - - # Set function metadata for @control() step detection - _process_transfer.name = "process_transfer" # type: ignore[attr-defined] - _process_transfer.tool_name = "process_transfer" # type: ignore[attr-defined] - - # Wrap with Agent Control - controlled_fn = control()(_process_transfer) - - @tool("process_transfer") - def process_transfer_tool(transfer_request: str) -> str: - """Process a wire transfer with compliance, fraud, and approval controls. - - Args: - transfer_request: JSON string with transfer details (amount, recipient, - destination_country). May also include fraud_score, verified_2fa, - manager_approved. - """ - # Parse the request — CrewAI may pass str or dict - if isinstance(transfer_request, dict): - params = transfer_request - else: - try: - params = json.loads(transfer_request) - except (json.JSONDecodeError, TypeError): - return f"Invalid transfer request format. Expected JSON, got: {transfer_request!r}" - - amount = float(params.get("amount", 0)) - recipient = params.get("recipient", "Unknown") - destination_country = params.get("destination_country", "Unknown") - fraud_score = float(params.get("fraud_score", 0.0)) - verified_2fa = params.get("verified_2fa", False) - manager_approved = params.get("manager_approved", False) - - header = ( - f"\n{'=' * 60}\n" - f" TRANSFER REQUEST: ${amount:,.2f} to {recipient} ({destination_country})\n" - f"{'=' * 60}" - ) - print(header) - - # ── Attempt loop: handles steering with retries ───────────── - max_attempts = 3 - for attempt in range(1, max_attempts + 1): - try: - print(f"\n Attempt {attempt}/{max_attempts}") - print(f" 2FA verified: {verified_2fa} | Manager approved: {manager_approved}") - print(f" Sending to Agent Control for evaluation...") - - result = asyncio.run( - controlled_fn( - amount=amount, - recipient=recipient, - destination_country=destination_country, - fraud_score=fraud_score, - verified_2fa=verified_2fa, - manager_approved=manager_approved, - ) - ) - - # If we reach here, all controls passed - print(f"\n ALLOWED - Transfer processed successfully") - return result - - except ControlViolationError as e: - # ── DENY: Permanent block, no retry ───────────────── - print(f"\n DENIED by control: {e.control_name}") - print(f" Reason: {e.message}") - return ( - f"TRANSFER BLOCKED: {e.message}\n" - f"This violation has been logged for compliance review." - ) - - except ControlSteerError as e: - # ── STEER: Agent must correct and retry ───────────── - print(f"\n STEERED by control: {e.control_name}") - - # Parse the steering guidance - try: - guidance = json.loads(e.steering_context) - except (json.JSONDecodeError, TypeError): - guidance = {"reason": str(e.steering_context)} - - reason = guidance.get("reason", "Additional verification required") - actions = guidance.get("required_actions", []) - retry_with = guidance.get("retry_with", {}) - - print(f" Reason: {reason}") - print(f" Required actions: {actions}") - - # ── Handle each required action ───────────────────── - if "verify_2fa" in actions: - print(f"\n [2FA VERIFICATION]") - print(f" Sending 2FA code to customer's registered device...") - print(f" Customer entered code: {SIMULATED_2FA_CODE}") - print(f" Code verified successfully") - verified_2fa = True - - if "collect_justification" in actions: - print(f"\n [BUSINESS JUSTIFICATION]") - print(f" Collecting justification from requestor...") - print(f' Justification: "Quarterly vendor payment per contract #QV-2024-889"') - - if "get_manager_approval" in actions: - print(f"\n [MANAGER APPROVAL]") - print(f" Routing to {SIMULATED_MANAGER} for approval...") - print(f" Manager reviewed transfer details and justification") - print(f" Approval granted by {SIMULATED_MANAGER}") - manager_approved = True - - # Apply any retry flags from steering context - if retry_with.get("verified_2fa"): - verified_2fa = True - if retry_with.get("manager_approved"): - manager_approved = True - - print(f"\n Retrying with corrected parameters...") - continue - - return "TRANSFER FAILED: Maximum steering attempts exceeded." - - return process_transfer_tool - - -# ── CrewAI Crew Setup ─────────────────────────────────────────────────── - -def create_financial_crew(): - transfer_tool = create_transfer_tool() - - banker = Agent( - role="Financial Operations Agent", - goal=( - "Process wire transfer requests accurately and comply with all " - "security and compliance controls" - ), - backstory=( - "You are a senior financial operations agent at a major bank. " - "You process wire transfers using the process_transfer tool. " - "You always pass the transfer details as a JSON string with the " - "exact fields: amount, recipient, destination_country, and " - "optionally fraud_score. You respect all security controls." - ), - tools=[transfer_tool], - verbose=True, - ) - - task = Task( - description=( - "Process this wire transfer request: {transfer_request}\n\n" - "Use the process_transfer tool with a JSON string containing " - "the transfer details. Report the outcome." - ), - expected_output="Transfer confirmation or explanation of why it was blocked", - agent=banker, - ) - - return Crew(agents=[banker], tasks=[task], verbose=True) - - -# ── Scenario Runner ───────────────────────────────────────────────────── - -def verify_server(): - """Check that Agent Control server is reachable and controls exist.""" - import httpx - - try: - r = httpx.get(f"{SERVER_URL}/api/v1/controls?limit=100", timeout=5.0) - r.raise_for_status() - data = r.json() - names = [c["name"] for c in data.get("controls", [])] - required = [ - "deny-sanctioned-countries", - "deny-high-fraud-score", - "steer-require-2fa", - "steer-require-manager-approval", - "warn-new-recipient", - ] - missing = [n for n in required if n not in names] - if missing: - print(f"Missing controls: {missing}") - print("Run: uv run python setup_controls.py") - return False - print(f"Server OK - {len(names)} controls active") - return True - except Exception as e: - print(f"Cannot reach server at {SERVER_URL}: {e}") - print("Start the server: make server-run (from repo root)") - return False - - -def run_scenario(crew, number, title, request, expected): - """Run a single scenario and print results.""" - print(f"\n{'#' * 60}") - print(f" SCENARIO {number}: {title}") - print(f"{'#' * 60}") - print(f" Request: {json.dumps(request)}") - print(f" Expected: {expected}") - - result = crew.kickoff(inputs={"transfer_request": json.dumps(request)}) - - print(f"\n Result: {str(result)[:300]}") - print(f"{'#' * 60}\n") - return result - - -def main(): - print("=" * 60) - print(" CrewAI Financial Agent") - print(" Steering, Deny & Warn with Agent Control") - print("=" * 60) - print() - - if not verify_server(): - return - - if not os.getenv("OPENAI_API_KEY"): - print("\nSet OPENAI_API_KEY to run this example.") - return - - crew = create_financial_crew() - - # ── Scenario 1: Small Legitimate Transfer ─────────────────────── - # Amount < $10k, known country, low fraud → ALLOW - # Unknown recipient → WARN (logged, not blocked) - run_scenario( - crew, - 1, - "Small Legitimate Transfer (ALLOW + WARN)", - { - "amount": 2500, - "recipient": "New Vendor XYZ", - "destination_country": "Germany", - "fraud_score": 0.1, - }, - "ALLOWED with warning (new recipient logged for audit)", - ) - - # ── Scenario 2: Sanctioned Country ────────────────────────────── - # Destination is North Korea → DENY immediately - run_scenario( - crew, - 2, - "Sanctioned Country (DENY)", - { - "amount": 500, - "recipient": "Trade Partner", - "destination_country": "North Korea", - "fraud_score": 0.0, - }, - "DENIED - OFAC sanctioned country", - ) - - # ── Scenario 3: Large Transfer Requiring 2FA ──────────────────── - # Amount $15k → STEER (2FA required), agent verifies, retries → ALLOW - run_scenario( - crew, - 3, - "Large Transfer - 2FA Steering (STEER then ALLOW)", - { - "amount": 15000, - "recipient": "Acme Corp", - "destination_country": "United Kingdom", - "fraud_score": 0.2, - }, - "STEERED (2FA), then ALLOWED after verification", - ) - - # ── Scenario 4: Very Large Transfer Requiring Manager Approval ── - # Amount $75k → STEER (2FA + manager approval), agent handles both → ALLOW - run_scenario( - crew, - 4, - "Very Large Transfer - Manager Approval (STEER then ALLOW)", - { - "amount": 75000, - "recipient": "Global Suppliers Inc", - "destination_country": "Japan", - "fraud_score": 0.15, - }, - "STEERED (2FA + manager), then ALLOWED after approvals", - ) - - # ── Scenario 5: Fraud Detected ────────────────────────────────── - # Fraud score 0.95 → DENY immediately - run_scenario( - crew, - 5, - "High Fraud Score (DENY)", - { - "amount": 3000, - "recipient": "Suspicious Entity", - "destination_country": "Cayman Islands", - "fraud_score": 0.95, - }, - "DENIED - fraud score exceeds threshold", - ) - - # ── Summary ───────────────────────────────────────────────────── - print("=" * 60) - print(" Demo Complete!") - print("=" * 60) - print(""" - Action Types Demonstrated: - - DENY Sanctioned country (Scenario 2) - hard block, no recovery - High fraud score (Scenario 5) - hard block, no recovery - - STEER 2FA verification (Scenario 3) - pause, verify, retry - Manager approval (Scenario 4) - pause, collect + approve, retry - - WARN New recipient (Scenario 1) - logged for audit, not blocked - PII in output (if triggered) - logged for compliance, not blocked - - Key Differences: - DENY = ControlViolationError (agent cannot recover) - STEER = ControlSteerError (agent corrects and retries) - WARN = Logged silently (agent continues uninterrupted) -""") - - -if __name__ == "__main__": - main() diff --git a/examples/crewai/steering_financial_agent/pyproject.toml b/examples/crewai/steering_financial_agent/pyproject.toml index 83439de0..fbc2952a 100644 --- a/examples/crewai/steering_financial_agent/pyproject.toml +++ b/examples/crewai/steering_financial_agent/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "agent-control-crewai-steering-example" +name = "steering-financial-agent" version = "0.1.0" description = "CrewAI financial agent with steering, deny, and warn actions via Agent Control" requires-python = ">=3.12" @@ -11,15 +11,16 @@ dependencies = [ "python-dotenv>=1.0.0", ] -[project.optional-dependencies] -dev = [ - "pytest>=8.0.0", - "pytest-asyncio>=0.23.0", -] +[project.scripts] +steering_financial_agent = "steering_financial_agent.main:main" +run_crew = "steering_financial_agent.main:main" [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -include = ["*.py"] +packages = ["src/steering_financial_agent"] + +[tool.crewai] +type = "crew" diff --git a/examples/crewai/steering_financial_agent/setup_controls.py b/examples/crewai/steering_financial_agent/setup_controls.py index 82ab01c2..afb1d6e7 100644 --- a/examples/crewai/steering_financial_agent/setup_controls.py +++ b/examples/crewai/steering_financial_agent/setup_controls.py @@ -326,7 +326,7 @@ async def setup(): print(" - New/unknown recipient (list evaluator)") print(" - PII in confirmation output (regex evaluator)") print() - print("Run the demo: uv run python financial_agent.py") + print("Run the demo: uv run python -m steering_financial_agent.main") if __name__ == "__main__": diff --git a/examples/crewai/steering_financial_agent/src/steering_financial_agent/__init__.py b/examples/crewai/steering_financial_agent/src/steering_financial_agent/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/crewai/steering_financial_agent/src/steering_financial_agent/config/agents.yaml b/examples/crewai/steering_financial_agent/src/steering_financial_agent/config/agents.yaml new file mode 100644 index 00000000..5348d640 --- /dev/null +++ b/examples/crewai/steering_financial_agent/src/steering_financial_agent/config/agents.yaml @@ -0,0 +1,11 @@ +financial_operations_agent: + role: "Financial Operations Agent" + goal: > + Process wire transfer requests accurately and comply with all + security and compliance controls + backstory: > + You are a senior financial operations agent at a major bank. + You process wire transfers using the process_transfer tool. + You always pass the transfer details as a JSON string with the + exact fields: amount, recipient, destination_country, and + optionally fraud_score. You respect all security controls. diff --git a/examples/crewai/steering_financial_agent/src/steering_financial_agent/config/tasks.yaml b/examples/crewai/steering_financial_agent/src/steering_financial_agent/config/tasks.yaml new file mode 100644 index 00000000..d113aa79 --- /dev/null +++ b/examples/crewai/steering_financial_agent/src/steering_financial_agent/config/tasks.yaml @@ -0,0 +1,8 @@ +transfer_task: + description: > + Process this wire transfer request: {transfer_request} + + Use the process_transfer tool with a JSON string containing + the transfer details. Report the outcome. + expected_output: "Transfer confirmation or explanation of why it was blocked" + agent: financial_operations_agent diff --git a/examples/crewai/steering_financial_agent/src/steering_financial_agent/crew.py b/examples/crewai/steering_financial_agent/src/steering_financial_agent/crew.py new file mode 100644 index 00000000..36148048 --- /dev/null +++ b/examples/crewai/steering_financial_agent/src/steering_financial_agent/crew.py @@ -0,0 +1,38 @@ +"""CrewBase class for the financial operations crew.""" + +from crewai import Agent, Crew, Process, Task +from crewai.project import CrewBase, agent, crew, task + +from steering_financial_agent.tools.process_transfer import create_transfer_tool + + +@CrewBase +class SteeringFinancialCrew: + """Financial operations crew with Agent Control steering.""" + + agents_config = "config/agents.yaml" + tasks_config = "config/tasks.yaml" + + @agent + def financial_operations_agent(self) -> Agent: + transfer_tool = create_transfer_tool() + return Agent( + config=self.agents_config["financial_operations_agent"], + tools=[transfer_tool], + verbose=True, + ) + + @task + def transfer_task(self) -> Task: + return Task( + config=self.tasks_config["transfer_task"], + ) + + @crew + def crew(self) -> Crew: + return Crew( + agents=self.agents, + tasks=self.tasks, + process=Process.sequential, + verbose=True, + ) diff --git a/examples/crewai/steering_financial_agent/src/steering_financial_agent/main.py b/examples/crewai/steering_financial_agent/src/steering_financial_agent/main.py new file mode 100644 index 00000000..5b1f5b4e --- /dev/null +++ b/examples/crewai/steering_financial_agent/src/steering_financial_agent/main.py @@ -0,0 +1,225 @@ +""" +CrewAI Financial Agent with Steering, Deny, and Warn actions. + +Demonstrates the three key Agent Control action types in a realistic +wire-transfer scenario using a CrewAI crew: + + DENY - Sanctioned country or fraud score blocks the transfer immediately. + STEER - Large transfers pause execution and guide the agent through + 2FA verification or manager approval before retrying. + WARN - New recipients and PII in output are logged for audit + without blocking the transfer. + +PREREQUISITE: + Run setup_controls.py first: + + $ uv run python setup_controls.py + + Then run this example: + + $ uv run steering_financial_agent + +Scenarios: + 1. Small legitimate transfer -> ALLOW (warn on new recipient) + 2. Sanctioned country -> DENY (hard block) + 3. Large transfer ($15k) -> STEER (2FA required, then allowed) + 4. Very large transfer ($75k) -> STEER (manager approval, then allowed) + 5. High fraud score -> DENY (hard block) +""" + +import json +import os + +import agent_control + +# ── Configuration ─────────────────────────────────────────────────────── +AGENT_NAME = "crewai-financial-agent" +SERVER_URL = os.getenv("AGENT_CONTROL_URL", "http://localhost:8000") + +agent_control.init( + agent_name=AGENT_NAME, + agent_description="CrewAI financial agent with steering controls", + server_url=SERVER_URL, +) + + +# ── Crew factory ──────────────────────────────────────────────────────── + +def create_financial_crew(): + from steering_financial_agent.crew import SteeringFinancialCrew + + return SteeringFinancialCrew().crew() + + +# ── Server check ──────────────────────────────────────────────────────── + +def verify_server(): + """Check that Agent Control server is reachable and controls exist.""" + import httpx + + try: + r = httpx.get(f"{SERVER_URL}/api/v1/controls?limit=100", timeout=5.0) + r.raise_for_status() + data = r.json() + names = [c["name"] for c in data.get("controls", [])] + required = [ + "deny-sanctioned-countries", + "deny-high-fraud-score", + "steer-require-2fa", + "steer-require-manager-approval", + "warn-new-recipient", + ] + missing = [n for n in required if n not in names] + if missing: + print(f"Missing controls: {missing}") + print("Run: uv run python setup_controls.py") + return False + print(f"Server OK - {len(names)} controls active") + return True + except Exception as e: + print(f"Cannot reach server at {SERVER_URL}: {e}") + print("Start the server: make server-run (from repo root)") + return False + + +# ── Scenario runner ───────────────────────────────────────────────────── + +def run_scenario(crew, number, title, request, expected): + """Run a single scenario and print results.""" + print(f"\n{'#' * 60}") + print(f" SCENARIO {number}: {title}") + print(f"{'#' * 60}") + print(f" Request: {json.dumps(request)}") + print(f" Expected: {expected}") + + result = crew.kickoff(inputs={"transfer_request": json.dumps(request)}) + + print(f"\n Result: {str(result)[:300]}") + print(f"{'#' * 60}\n") + return result + + +# ── Main ──────────────────────────────────────────────────────────────── + +def main(): + print("=" * 60) + print(" CrewAI Financial Agent") + print(" Steering, Deny & Warn with Agent Control") + print("=" * 60) + print() + + if not verify_server(): + return + + if not os.getenv("OPENAI_API_KEY"): + print("\nSet OPENAI_API_KEY to run this example.") + return + + crew = create_financial_crew() + + # ── Scenario 1: Small Legitimate Transfer ─────────────────────── + # Amount < $10k, known country, low fraud → ALLOW + # Unknown recipient → WARN (logged, not blocked) + run_scenario( + crew, + 1, + "Small Legitimate Transfer (ALLOW + WARN)", + { + "amount": 2500, + "recipient": "New Vendor XYZ", + "destination_country": "Germany", + "fraud_score": 0.1, + }, + "ALLOWED with warning (new recipient logged for audit)", + ) + + # ── Scenario 2: Sanctioned Country ────────────────────────────── + # Destination is North Korea → DENY immediately + run_scenario( + crew, + 2, + "Sanctioned Country (DENY)", + { + "amount": 500, + "recipient": "Trade Partner", + "destination_country": "North Korea", + "fraud_score": 0.0, + }, + "DENIED - OFAC sanctioned country", + ) + + # ── Scenario 3: Large Transfer Requiring 2FA ──────────────────── + # Amount $15k → STEER (2FA required), agent verifies, retries → ALLOW + run_scenario( + crew, + 3, + "Large Transfer - 2FA Steering (STEER then ALLOW)", + { + "amount": 15000, + "recipient": "Acme Corp", + "destination_country": "United Kingdom", + "fraud_score": 0.2, + }, + "STEERED (2FA), then ALLOWED after verification", + ) + + # ── Scenario 4: Very Large Transfer Requiring Manager Approval ── + # Amount $75k → STEER (2FA + manager approval), agent handles both → ALLOW + run_scenario( + crew, + 4, + "Very Large Transfer - Manager Approval (STEER then ALLOW)", + { + "amount": 75000, + "recipient": "Global Suppliers Inc", + "destination_country": "Japan", + "fraud_score": 0.15, + }, + "STEERED (2FA + manager), then ALLOWED after approvals", + ) + + # ── Scenario 5: Fraud Detected ────────────────────────────────── + # Fraud score 0.95 → DENY immediately + run_scenario( + crew, + 5, + "High Fraud Score (DENY)", + { + "amount": 3000, + "recipient": "Suspicious Entity", + "destination_country": "Cayman Islands", + "fraud_score": 0.95, + }, + "DENIED - fraud score exceeds threshold", + ) + + # ── Summary ───────────────────────────────────────────────────── + print("=" * 60) + print(" Demo Complete!") + print("=" * 60) + print(""" + Action Types Demonstrated: + + DENY Sanctioned country (Scenario 2) - hard block, no recovery + High fraud score (Scenario 5) - hard block, no recovery + + STEER 2FA verification (Scenario 3) - pause, verify, retry + Manager approval (Scenario 4) - pause, collect + approve, retry + + WARN New recipient (Scenario 1) - logged for audit, not blocked + PII in output (if triggered) - logged for compliance, not blocked + + Key Differences: + DENY = ControlViolationError (agent cannot recover) + STEER = ControlSteerError (agent corrects and retries) + WARN = Logged silently (agent continues uninterrupted) +""") + + +def run(): + """Entry point for [project.scripts].""" + main() + + +if __name__ == "__main__": + main() diff --git a/examples/crewai/steering_financial_agent/src/steering_financial_agent/tools/__init__.py b/examples/crewai/steering_financial_agent/src/steering_financial_agent/tools/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/crewai/steering_financial_agent/src/steering_financial_agent/tools/process_transfer.py b/examples/crewai/steering_financial_agent/src/steering_financial_agent/tools/process_transfer.py new file mode 100644 index 00000000..3d5aba6c --- /dev/null +++ b/examples/crewai/steering_financial_agent/src/steering_financial_agent/tools/process_transfer.py @@ -0,0 +1,162 @@ +"""Transfer tool with @control() protection and steering retry logic.""" + +import asyncio +import json + +from agent_control import ControlSteerError, ControlViolationError, control +from crewai import LLM +from crewai.tools import tool + +# Simulated state — in production this comes from your auth system +SIMULATED_2FA_CODE = "482901" +SIMULATED_MANAGER = "Sarah Chen (VP Operations)" + + +def create_transfer_tool(): + """Build the CrewAI tool with @control protection and steering logic.""" + + llm = LLM(model="gpt-4o-mini", temperature=0.3) + + async def _process_transfer( + amount: float, + recipient: str, + destination_country: str, + fraud_score: float = 0.0, + verified_2fa: bool = False, + manager_approved: bool = False, + ) -> str: + """Process a wire transfer (protected by Agent Control).""" + prompt = f"""You are a banking operations system. Process this wire transfer +and return a short confirmation message (2-3 sentences). + +Transfer details: +- Amount: ${amount:,.2f} +- Recipient: {recipient} +- Destination: {destination_country} +- 2FA verified: {verified_2fa} +- Manager approved: {manager_approved} + +Return a professional confirmation with a reference number.""" + + return llm.call([{"role": "user", "content": prompt}]) + + # Set function metadata for @control() step detection + _process_transfer.name = "process_transfer" # type: ignore[attr-defined] + _process_transfer.tool_name = "process_transfer" # type: ignore[attr-defined] + + # Wrap with Agent Control + controlled_fn = control()(_process_transfer) + + @tool("process_transfer") + def process_transfer_tool(transfer_request: str) -> str: + """Process a wire transfer with compliance, fraud, and approval controls. + + Args: + transfer_request: JSON string with transfer details (amount, recipient, + destination_country). May also include fraud_score, verified_2fa, + manager_approved. + """ + # Parse the request — CrewAI may pass str or dict + if isinstance(transfer_request, dict): + params = transfer_request + else: + try: + params = json.loads(transfer_request) + except (json.JSONDecodeError, TypeError): + return f"Invalid transfer request format. Expected JSON, got: {transfer_request!r}" + + amount = float(params.get("amount", 0)) + recipient = params.get("recipient", "Unknown") + destination_country = params.get("destination_country", "Unknown") + fraud_score = float(params.get("fraud_score", 0.0)) + verified_2fa = params.get("verified_2fa", False) + manager_approved = params.get("manager_approved", False) + + header = ( + f"\n{'=' * 60}\n" + f" TRANSFER REQUEST: ${amount:,.2f} to {recipient} ({destination_country})\n" + f"{'=' * 60}" + ) + print(header) + + # ── Attempt loop: handles steering with retries ───────────── + max_attempts = 3 + for attempt in range(1, max_attempts + 1): + try: + print(f"\n Attempt {attempt}/{max_attempts}") + print(f" 2FA verified: {verified_2fa} | Manager approved: {manager_approved}") + print(f" Sending to Agent Control for evaluation...") + + result = asyncio.run( + controlled_fn( + amount=amount, + recipient=recipient, + destination_country=destination_country, + fraud_score=fraud_score, + verified_2fa=verified_2fa, + manager_approved=manager_approved, + ) + ) + + # If we reach here, all controls passed + print(f"\n ALLOWED - Transfer processed successfully") + return result + + except ControlViolationError as e: + # ── DENY: Permanent block, no retry ───────────────── + print(f"\n DENIED by control: {e.control_name}") + print(f" Reason: {e.message}") + return ( + f"TRANSFER BLOCKED: {e.message}\n" + f"This violation has been logged for compliance review." + ) + + except ControlSteerError as e: + # ── STEER: Agent must correct and retry ───────────── + print(f"\n STEERED by control: {e.control_name}") + + # Parse the steering guidance + try: + guidance = json.loads(e.steering_context) + except (json.JSONDecodeError, TypeError): + guidance = {"reason": str(e.steering_context)} + + reason = guidance.get("reason", "Additional verification required") + actions = guidance.get("required_actions", []) + retry_with = guidance.get("retry_with", {}) + + print(f" Reason: {reason}") + print(f" Required actions: {actions}") + + # ── Handle each required action ───────────────────── + if "verify_2fa" in actions: + print(f"\n [2FA VERIFICATION]") + print(f" Sending 2FA code to customer's registered device...") + print(f" Customer entered code: {SIMULATED_2FA_CODE}") + print(f" Code verified successfully") + verified_2fa = True + + if "collect_justification" in actions: + print(f"\n [BUSINESS JUSTIFICATION]") + print(f" Collecting justification from requestor...") + print(f' Justification: "Quarterly vendor payment per contract #QV-2024-889"') + + if "get_manager_approval" in actions: + print(f"\n [MANAGER APPROVAL]") + print(f" Routing to {SIMULATED_MANAGER} for approval...") + print(f" Manager reviewed transfer details and justification") + print(f" Approval granted by {SIMULATED_MANAGER}") + manager_approved = True + + # Apply any retry flags from steering context + if retry_with.get("verified_2fa"): + verified_2fa = True + if retry_with.get("manager_approved"): + manager_approved = True + + print(f"\n Retrying with corrected parameters...") + continue + + return "TRANSFER FAILED: Maximum steering attempts exceeded." + + return process_transfer_tool From 3a23caa8704077017144328435dbcab25bed40ae Mon Sep 17 00:00:00 2001 From: Joao Moura Date: Wed, 11 Mar 2026 02:27:13 -0700 Subject: [PATCH 5/5] chore(examples): use crewai[tools] extra instead of separate crewai-tools dep Co-Authored-By: Claude Opus 4.6 --- examples/crewai/content_publishing_flow/pyproject.toml | 3 +-- examples/crewai/evaluator_showcase/pyproject.toml | 3 +-- examples/crewai/pyproject.toml | 3 +-- examples/crewai/secure_research_crew/pyproject.toml | 3 +-- examples/crewai/steering_financial_agent/pyproject.toml | 3 +-- 5 files changed, 5 insertions(+), 10 deletions(-) diff --git a/examples/crewai/content_publishing_flow/pyproject.toml b/examples/crewai/content_publishing_flow/pyproject.toml index 6981738f..adc86e0d 100644 --- a/examples/crewai/content_publishing_flow/pyproject.toml +++ b/examples/crewai/content_publishing_flow/pyproject.toml @@ -5,8 +5,7 @@ description = "CrewAI Flow with routing, embedded crews, and Agent Control guard requires-python = ">=3.12" dependencies = [ "agent-control-sdk>=6.3.0", - "crewai>=1.10.1", - "crewai-tools>=0.12.0", + "crewai[tools]>=1.10.1", "openai>=1.0.0", "python-dotenv>=1.0.0", ] diff --git a/examples/crewai/evaluator_showcase/pyproject.toml b/examples/crewai/evaluator_showcase/pyproject.toml index f589816b..235b4f71 100644 --- a/examples/crewai/evaluator_showcase/pyproject.toml +++ b/examples/crewai/evaluator_showcase/pyproject.toml @@ -5,8 +5,7 @@ description = "CrewAI agent demonstrating all built-in evaluators: regex, list, requires-python = ">=3.12" dependencies = [ "agent-control-sdk>=6.3.0", - "crewai>=1.10.1", - "crewai-tools>=0.12.0", + "crewai[tools]>=1.10.1", "openai>=1.0.0", "python-dotenv>=1.0.0", ] diff --git a/examples/crewai/pyproject.toml b/examples/crewai/pyproject.toml index d58bd3fb..23399c36 100644 --- a/examples/crewai/pyproject.toml +++ b/examples/crewai/pyproject.toml @@ -5,8 +5,7 @@ description = "CrewAI content moderation example with Agent Control" requires-python = ">=3.12" dependencies = [ "agent-control-sdk>=6.3.0", - "crewai>=1.10.1", - "crewai-tools>=0.12.0", + "crewai[tools]>=1.10.1", "openai>=1.0.0", "python-dotenv>=1.0.0", "requests>=2.31.0", diff --git a/examples/crewai/secure_research_crew/pyproject.toml b/examples/crewai/secure_research_crew/pyproject.toml index 5f7d4405..b01fb4a9 100644 --- a/examples/crewai/secure_research_crew/pyproject.toml +++ b/examples/crewai/secure_research_crew/pyproject.toml @@ -5,8 +5,7 @@ description = "CrewAI multi-agent research crew with per-agent policies via Agen requires-python = ">=3.12" dependencies = [ "agent-control-sdk>=6.3.0", - "crewai>=1.10.1", - "crewai-tools>=0.12.0", + "crewai[tools]>=1.10.1", "openai>=1.0.0", "python-dotenv>=1.0.0", ] diff --git a/examples/crewai/steering_financial_agent/pyproject.toml b/examples/crewai/steering_financial_agent/pyproject.toml index fbc2952a..c75efb08 100644 --- a/examples/crewai/steering_financial_agent/pyproject.toml +++ b/examples/crewai/steering_financial_agent/pyproject.toml @@ -5,8 +5,7 @@ description = "CrewAI financial agent with steering, deny, and warn actions via requires-python = ">=3.12" dependencies = [ "agent-control-sdk>=6.3.0", - "crewai>=1.10.1", - "crewai-tools>=0.12.0", + "crewai[tools]>=1.10.1", "openai>=1.0.0", "python-dotenv>=1.0.0", ]