Skip to content

nbatra/langgraph-agentic-dsp-optimizer

Repository files navigation

LangGraph Agentic DSP Optimizer

An agentic, LLM-powered campaign monitoring system built with LangGraph and Claude (via AWS Bedrock) that continuously watches programmatic ad campaigns, diagnoses delivery issues using AI reasoning, takes autonomous corrective actions through tool-calling, and escalates high-risk decisions to campaign managers with data-driven narratives.


Table of Contents


Architecture

The system is implemented as a LangGraph StateGraph with conditional routing. Three of the four agents are powered by Claude Opus 4.6 on AWS Bedrock with native tool-calling support:

                          +-----------------------------------------------------+
                          |              LangGraph StateGraph Pipeline            |
                          +-----------------------------------------------------+

                   +-----------+      +-------------+      +-------------------+
                   |   Fetch   |----->|   Monitor   |----->|    Diagnosis      |
                   | Campaigns |      |    Agent    |      |   Agent (LLM)     |
                   | (API/Sim) |      |  (Rules) +  |      | Claude + Tools    |
                   +-----------+      |  Cooldown   |      +--------+----------+
                                      |   Filter    |               |
                                      +------+------+               |
                                             |                      v
                                             | no alerts     +----------------+
                                             | (or all in    |     Action     |
                                             |  cooldown)    |   Agent (LLM)  |
                                             |               | Claude + Tools |
                                             |               +-------+--------+
                                             |                       |
                                             |          +------------+------------+
                                             |          |            |            |
                                             |          v            v            v
                                             |    Autonomous    Cooldown     Bid-Change
                                             |    Actions       Recorded     Escalations
                                             |    Executed      (2 min)
                                             |          |                        |
                                             |          v                        v
                                             |    +----------------+    +---------------+
                                             |    | CM Notif (FYI) |    |  Escalation   |
                                             |    | Actions Taken  |    |  Agent (LLM)  |
                                             |    +----------------+    | Claude Writer |
                                             |                          +-------+-------+
                                             |                                  |
                                             v                                  v
                                            END                                END

    State:  { campaigns, alerts, cooldown_skipped, diagnoses, actions_executed,
              escalations, action_reasoning, action_notifications, notifications }

Conditional edges:

From Condition Routes to Rationale
Monitor alerts list is empty (after cooldown filter) END No actionable issues = no LLM calls
Monitor alerts list non-empty Diagnosis Issues found on eligible campaigns
Action escalations is empty END No bid changes = no notifications
Action escalations non-empty Escalation Bid changes need manager approval

How It Works

  1. Continuous Loop -- main.py compiles the LangGraph workflow and invokes it every 60 seconds (configurable in config.yaml) in a while True loop. Each invocation is one cycle.

  2. State Initialization -- Every cycle starts with an empty PipelineState. The fetch node calls the campaign simulator to pull the latest metrics for all 6 campaigns.

  3. Monitor (Rules) + Cooldown Filter -- The monitor agent scans all campaigns against 11 threshold-based checks. Any violation generates an alert. Then the cooldown filter separates alerts into two buckets:

    • Actionable alerts: Campaigns NOT in cooldown -- proceed to Diagnosis.
    • Cooldown-skipped alerts: Campaigns still cooling down from previous actions -- displayed but not acted on.
  4. Short-circuit -- If no actionable alerts remain (either no issues, or all flagged campaigns are in cooldown), the graph routes directly to END -- no LLM calls, no cost.

  5. Diagnosis (Claude LLM + Tools) -- Claude receives the actionable alerts and full campaign data. It reasons about causality across multiple interacting signals, optionally calling read-only tools to inspect campaigns in detail. It outputs structured JSON with root cause, contributing factors, and recommended actions.

  6. Action (Claude LLM + Tool-Calling) -- Claude enters an agentic tool-calling loop (up to 15 rounds). It inspects campaigns, executes autonomous actions (expand_audience, rotate_creative, adjust_frequency_cap, adjust_daily_budget), and flags bid changes for human approval (recommend_bid_change). After execution:

    • Cooldown is recorded for every acted-on campaign (default 2 minutes).
    • FYI notifications are generated for the campaign manager listing all autonomous actions taken.
  7. Escalation (Claude LLM) -- For bid-change recommendations, Claude writes professional, data-driven notifications with cost/benefit analysis, projected impact, and consequences of inaction.

  8. Campaign Manager Notifications -- Two types of notifications are sent to the campaign manager:

    • FYI (blue panel): Informational notifications about autonomous actions taken. No approval needed.
    • Escalation (red panel): Urgent bid-change recommendations requiring explicit approval.
  9. Display -- After each cycle, the display.py module renders Rich-formatted panels: cooldown status, monitor alert table, diagnosis summaries, action confirmations with reasoning, FYI notifications, and escalation notifications.

  10. Feedback Loop -- Because the simulator is stateful and actions modify campaign parameters, the effects of autonomous actions are reflected in the next cycle's metrics. Combined with cooldown, this creates a stable closed-loop: act → wait → observe → decide if more action needed.


Why LLM-Powered (Not Just Rules)

The diagnosis and action problems are fundamentally about reasoning over multiple interacting signals. A campaign might be underpacing because of bid compression, audience exhaustion, creative fatigue, or -- critically -- a combination of all three where one is the primary cause and the others are amplifying factors.

What the LLM provides that rules cannot:

Capability Rules-Based LLM-Powered
Detect threshold violations Yes Not needed (Monitor handles this)
Identify PRIMARY root cause among multiple issues No (would need exponential if/elif) Yes -- reasons about causality
Explain WHY a metric is problematic No Yes -- "bid is $2.38 below clearing, losing 96/100 auctions"
Creative naming for new segments No (hardcoded) Yes -- "Pulmonologists US, Oncology Nurses/NPs"
Sequencing decisions No Yes -- "expand audience BEFORE rotating creative"
Write professional notifications No Yes -- cost/benefit analysis, consequences
Adapt to novel combinations No Yes -- handles unseen multi-signal interactions

Example LLM reasoning:

"The bid is $12.00 below the $14.38 clearing price, which explains the 3.7% win rate. But even with a bid increase, the programmatic audio market is supply-constrained. A bid of $15.82 (10% above clearing) should lift win rate to 15-20%, boosting daily impressions from 71K to 200K+. At the higher CPM, daily spend increases to $3,200-3,400..."

This kind of multi-step causal reasoning with specific numbers is what makes the system truly agentic.


Agent Deep-Dive

Monitor Agent (Rules)

File: agents/monitor.py

The monitor runs 11 independent checks against every campaign each cycle. Each check compares a metric to a hard-coded threshold (configurable in config.yaml) and emits an alert when violated. This is intentionally NOT LLM-powered -- threshold checks should be fast, deterministic, and free.

# Check Metric Threshold Severity Logic
1 Flight Pacing (under) pacing_ratio < 0.75 critical if < 0.50, else warning
2 Flight Pacing (over) pacing_ratio > 1.25 critical if > 1.50, else warning
3 Daily Pacing daily_pacing_ratio < 0.60 warning
4 Win Rate win_rate_pct < 6% critical if < 4%, else warning
5 Budget Burn Rate projected spend vs remaining projected > remaining x 1.3 warning
6 Creative Fatigue fatigue_factor per creative < 0.70 warning
7 Audience Saturation saturation_pct per segment > 70% warning
8 Frequency Cap Saturation users_at_cap_pct > 40% critical if > 60%, else warning
9 Viewability viewability_pct < 55% warning
10 Completion Rate completion_rate_pct < 65% warning
11 Invalid Traffic / Brand Safety invalid_traffic_rate_pct > 3% IVT; > 3 brand safety flags critical (IVT), warning (brand safety)

Output per alert:

{
  "campaign_id": "CMP-1004",
  "campaign_name": "FinServ Retirement Planning — Programmatic Audio",
  "issue": "Low win rate — losing auctions, bid may be too low",
  "severity": "critical",
  "metric": "win_rate=3.7%, clearing_price=$14.38, bid=$12.00",
  "details": {"win_rate": 0.037, "clearing_price": 14.38, "current_bid": 12.0}
}

Diagnosis Agent (LLM + Tools)

File: agents/diagnosis.py

The diagnosis agent uses Claude Opus 4.6 with access to read-only tools to perform root cause analysis. It enters an agentic tool-calling loop (up to 5 rounds) where it can pull additional campaign data before rendering its final diagnosis.

How it works internally:

  1. Receives only actionable alerts (cooldown-filtered) and full campaign data for flagged campaigns.
  2. System prompt encodes deep adtech domain knowledge about causal relationships between metrics.
  3. Claude optionally calls tools (get_campaign_details, get_audience_segments, get_creative_performance) to inspect specific campaigns beyond what's in the alert.
  4. The LLM reasons about causality: not just "what's wrong" but "why it's wrong" and "what's making it worse."
  5. Returns structured JSON array with diagnoses for each affected campaign.

System prompt encodes these causal patterns:

Signal Combination Root Cause Diagnosis
Low win rate + bid below clearing price Bid compression -- campaign can't buy inventory
High audience saturation + underpacing Audience exhaustion -- not enough users left
Creative fatigue factor < 0.70 + CTR decay Creative wear-out -- users ignoring the ad
High frequency cap saturation + small audience Over-frequency on limited pool
Overpacing + high frequency Budget burning on diminishing-return impressions
High IVT + low viewability Fraud or low-quality supply sources

LLM tool-calling loop:

Claude receives alerts → (optionally) calls get_campaign_details for deeper data
  → reasons about causality → (optionally) calls get_audience_segments to check pools
  → formulates root cause → returns structured JSON diagnosis

Output structure:

[
  {
    "campaign_id": "CMP-1004",
    "campaign_name": "FinServ Retirement Planning",
    "root_cause": "Bid compression — bid is $12.00 vs clearing price $14.38, a gap of $2.38",
    "contributing_factors": [
      "Win rate at 3.7% — losing 96 out of every 100 auctions",
      "Programmatic audio is supply-constrained with higher clearing prices",
      "Campaign needs 262K impressions/day but winning only 71K/day"
    ],
    "recommended_actions": [
      {
        "action": "adjust_bid",
        "value": "$15.82 (10% above clearing)",
        "requires_approval": true,
        "reason": "Must exceed clearing price to win auctions and recover pacing"
      }
    ]
  }
]

JSON parsing robustness: The diagnosis response parser handles multiple LLM output formats:

  1. Markdown code fences (\``json ... ````)
  2. Direct JSON text
  3. JSON embedded in prose (bracket matching)
  4. Single object (wraps in array)
  5. Multi-part content blocks from Bedrock

Action Agent (LLM + Tool-Calling)

File: agents/action.py

The action agent uses Claude Opus 4.6 with the full tool suite bound. It enters an agentic tool-calling loop (up to 15 rounds) where it reads campaign details, executes autonomous actions, and recommends bid changes for approval. After execution, it triggers cooldown and generates FYI notifications.

Agentic behavior -- what makes this truly an "agent":

  • Claude decides WHICH actions to take and in WHAT ORDER (not predetermined)
  • It inspects campaigns before acting (read tools) to confirm hypotheses
  • It creates descriptive names for new audiences and creatives (not hardcoded strings)
  • It makes sequencing decisions ("expand audience BEFORE rotating creative because there's no point showing new creative to exhausted users")
  • It can take multiple actions per campaign across multiple campaigns in one invocation
  • It checks results of each tool call and decides if more actions are needed
  • It writes a final reasoning trace explaining its decision logic

Autonomous Actions (executed immediately via tool-calling):

Tool What It Does Example LLM Call
expand_audience Adds new audience segments to the campaign "Pulmonologists US, Oncology Nurses/NPs (~120K users)"
rotate_creative Adds new creative variants with fresh fatigue "Summer Sale V2 -- Category-specific deals native card"
adjust_frequency_cap Reduces/increases frequency cap Cap 12 -> 6
adjust_daily_budget Modifies the daily budget $16,667 -> $10,810

Escalated Actions (requires human approval):

Tool Why It Needs Approval Example
recommend_bid_change Bid changes directly affect CPM costs and total spend across the campaign's entire supply pool "$12.00 -> $15.82"

Post-action effects:

  1. All acted-on campaigns enter cooldown (configurable, default 2 minutes)
  2. FYI notifications are generated per-campaign listing all autonomous actions taken
  3. Bid-change recommendations are collected for the Escalation Agent

Key design decision: Bid changes ALWAYS require human approval because they alter auction economics globally for the campaign. All other actions are considered safe to execute autonomously.


Escalation Agent (LLM)

File: agents/escalation.py

The escalation agent uses Claude Opus 4.6 (text generation only, no tools) to write professional campaign manager notifications for bid-change recommendations. It only runs when the Action Agent has flagged bid changes needing approval.

What Claude writes for each notification:

  • Problem description with specific numbers (not vague)
  • Recommended bid change with justification
  • Cost/benefit analysis (projected win rate improvement, CPM change, additional spend)
  • Delivery forecast (can the campaign still hit its target with the new bid?)
  • Consequences of inaction (what happens if the manager ignores this)
  • Explicit action required (one-line decision point)

Example output:

CRITICAL DELIVERY RISK -- CMP-1004 (WealthFirst Retirement Planning) has delivered only 1.02M of 6M target impressions (17%) through day 6 of 25, pacing at just 0.708x. The root cause is clear: the current $12.00 bid is $2.38 below the $14.38 clearing price, yielding a 3.7% win rate -- the campaign is losing 96 out of every 100 auctions. We recommend increasing the bid to $15.82 (10% above clearing), which should lift the win rate to 15-20% and boost daily impressions from 71K to 200K+. Without this increase, the campaign will finish at roughly 2.5M impressions -- a 58% shortfall against the WealthFirst Advisors commitment.

Why LLM here instead of templates: A template produces: "Suggested bid: $16.47. Current bid: $12.00. Win rate: 3.8%." The LLM produces actionable narratives with cause/effect reasoning that gets a campaign manager to approve at 7 AM instead of putting it off.


Campaign Simulator (Dynamic Mock DSP)

File: campaign_simulator.py

The simulator is NOT static -- it's a fully dynamic, stateful mock DSP that returns different results every tick and responds to agent actions. It's called via Python method calls (not HTTP API) through a module-level singleton.

Design: Interface-Compatible with Real DSPs

The simulator exposes the same interface a real DSP adapter would:

# These 3 functions are the entire interface between agents and data source.
# In production, swap the implementation to call DV360/TTD/Xandr APIs.
def get_campaigns() -> list[dict]         # Called by fetch node each cycle
def get_campaign(campaign_id) -> dict      # Called by LLM read tools
def apply_action(campaign_id, action) -> dict  # Called by LLM write tools

The agents don't know (or care) whether they're talking to a simulator or a real DSP. The dict schema is identical either way.

How It's Dynamic: Tick-Based Metric Drift

Every call to get_campaigns() advances an internal _tick counter and applies issue-specific drift with randomized noise to all 6 campaigns. No two calls return the same numbers:

def _drift_metrics(self):
    for cid, c in self._campaigns.items():
        noise = lambda lo, hi: random.uniform(lo, hi)

        if cid == "CMP-1001":  # underpacing -- delivers only 15-25% of target
            new_imps = int(c.target_daily_impressions * noise(0.15, 0.25))
            c.win_rate = max(0.03, c.win_rate + noise(-0.005, 0.002))

        elif cid == "CMP-1002":  # overpacing -- delivers 120-150% of target
            new_imps = int(c.target_daily_impressions * noise(1.2, 1.5))

        elif cid == "CMP-1003":  # creative fatigue -- fatigue decays per tick
            for cr in c.creatives:
                cr.fatigue_factor = max(0.3, cr.fatigue_factor - noise(0.02, 0.06))
                cr.ctr = max(0.003, cr.ctr * cr.fatigue_factor)

        elif cid == "CMP-1004":  # bid compression -- clearing price drifts UP
            c.avg_clearing_price += noise(-0.20, 0.40)  # Net upward drift
            if bid_gap > 0:  # Still below clearing
                new_imps = int(c.target_daily_impressions * noise(0.10, 0.18))
                c.win_rate = max(0.02, c.win_rate - noise(0.002, 0.008))

        elif cid == "CMP-1005":  # frequency saturation -- users_at_cap climbs
            c.users_at_cap = min(0.85, c.users_at_cap + noise(0.01, 0.04))
            c.avg_frequency = min(c.frequency_cap, c.avg_frequency + noise(0.1, 0.3))

        else:  # healthy -- fluctuates ±5% around target
            new_imps = int(c.target_daily_impressions * noise(0.85, 1.05))

What drifts each tick:

Metric Category What Changes How
Delivery (impressions) New impressions added per tick Issue-specific multiplier × daily target × random noise
Creative fatigue fatigue_factor decays -0.02 to -0.06 per tick, floors at 0.30
Creative CTR Decays with fatigue CTR = CTR × fatigue_factor (compounding)
Bid compression Clearing price drifts up +$0.00 to +$0.40 per tick (net positive)
Win rate Changes based on bid vs clearing Decays when bid < clearing, improves when bid > clearing
Frequency Users at cap increases +1-4% per tick, caps at 85%
Avg frequency Approaches cap +0.1-0.3 per tick toward cap value
Audience saturation All segments saturate +0.5-2% per tick toward 95%
Spend Accumulates with impressions (new_imps / 1000) × CPM × noise
Auction metrics Bid requests, bids submitted, wins Derived from impressions and win rate
Computed metrics CTR, CPC, CPA, CVR, CPM Recomputed from raw counts each tick

Agent Feedback Loop: How Actions Change the Simulation

When the Action Agent calls tools, apply_action() mutates the live campaign state. These changes persist across ticks and alter future drift behavior:

Action What It Modifies Effect on Next Tick
expand_audience Appends new AudienceSegment with 0% saturation and random reach (500K-3M) More addressable users → higher delivery potential for underpacing campaigns
add_creative Appends new Creative with fatigue_factor=1.0 Fresh creative reduces overall fatigue drag on CTR
adjust_frequency_cap Updates frequency_cap field Drift logic respects new cap; users_at_cap may decrease if new cap is higher
adjust_daily_budget Updates daily_budget field Pacing ratio recalculates; overpacing campaigns slow down
adjust_bid Clamps to [bid_floor, bid_ceiling], updates current_bid If bid > clearing: win rate improves next tick. If bid < clearing: continues degrading

Example feedback loop across 3 cycles:

Cycle 1: CMP-1005 detected with 55% users at frequency cap, single audience segment
         → Action Agent: reduce cap 12→6, expand audience (Lapsed Subscribers 60d)
         → Cooldown starts (2 minutes)

Cycle 2: CMP-1005 still in cooldown → skipped (shown in Cooldown panel)
         → Monitor still shows alerts but no action taken
         → Other campaigns may be diagnosed and acted on

Cycle 3: CMP-1005 cooldown expired → re-evaluated
         → New segment has 0% saturation, frequency pressure reduced
         → Monitor may show improved metrics or flag remaining issues

Simulated Campaigns

Campaign ID Name Type Issue Demonstrated Key Starting Metrics
CMP-1001 PharmaCo Branded Display -- HCP Targeting Display Underpacing / Narrow Audience 275K total HCP audience, 8% win rate, 72% saturation, delivers 15-25% of daily target
CMP-1002 AutoBrand CTV -- Q2 Launch CTV Overpacing / Budget Burn $24.50 bid (above $21.80 clearing), 35% win rate, delivers 120-150% of daily target, budget exhausting early
CMP-1003 RetailMax Summer Sale -- Native Native Creative Fatigue + IVT Only 2 creatives with fatigue decaying toward 0.30, 3.2% invalid traffic, 5 brand safety flags
CMP-1004 FinServ Retirement Planning -- Programmatic Audio Audio Bid Compression $12.00 bid vs $14.50+ clearing, 4% win rate falling each tick, clearing price drifting up
CMP-1005 StreamCo Subscription Retargeting -- Display Display Frequency Cap Saturation Single segment (420K users), 52% at cap, avg frequency 8.9 climbing toward cap of 12
CMP-1006 TechGiant Cloud Platform -- Video Awareness Video Healthy (Control) Pacing ~1.0, 18% win rate, good metrics. Occasionally spikes on daily pacing

Why Method Calls (Not HTTP API)

For the POC, method calls via a module-level singleton (_simulator = CampaignSimulator()) provide:

  • Zero network latency (tools respond instantly)
  • In-process state mutation (no persistence layer needed)
  • Same dict interface as a real DSP adapter

In production, you'd replace campaign_simulator.py with an HTTP adapter that calls your DSP's REST API -- the tool signatures and dict schema stay identical.


Configuration System

File: config.yaml (settings) + config.py (loader)

All configurable settings live in a single YAML file. No code changes needed to tune behavior.

config.yaml structure:

# Monitoring loop timing
cycle_interval_seconds: 60

# Cooldown — prevents repeated actions on same campaign
cooldown:
  default_minutes: 2
  per_action:
    expand_audience: 2
    rotate_creative: 2
    adjust_frequency_cap: 2
    adjust_daily_budget: 2
    recommend_bid_change: 5    # Bid changes need longer to propagate

# Campaign manager notification settings
notifications:
  notify_on_autonomous_actions: true
  notify_on_escalations: true
  channels:
    - type: console      # Always active
    - type: email        # Future integration
      enabled: false
    - type: slack        # Future integration
      enabled: false

# LLM settings
llm:
  model_id: "us.anthropic.claude-opus-4-6-v1"
  temperature: 0.3
  max_tokens: 16384
  read_timeout_seconds: 300

# AWS settings
aws:
  region: "us-east-1"
  profile: "default"

# Monitor thresholds (tune alerting sensitivity)
thresholds:
  pacing_underpace: 0.75
  pacing_overpace: 1.25
  win_rate_low: 0.06
  creative_fatigue: 0.70
  audience_saturation: 0.70
  # ... (all 11 checks configurable)

Environment Variable Overrides

Environment variables take precedence over config.yaml values:

Variable Overrides Example
BEDROCK_MODEL_ID llm.model_id us.anthropic.claude-sonnet-4-6
AWS_REGION aws.region us-west-2
AWS_PROFILE aws.profile prod-profile
CYCLE_INTERVAL cycle_interval_seconds 120
COOLDOWN_MINUTES cooldown.default_minutes 5

How config.py works:

from config import CONFIG

# Access any setting
cooldown = CONFIG["cooldown"]["default_minutes"]  # → 2
model = CONFIG["llm"]["model_id"]                 # → "us.anthropic.claude-opus-4-6-v1"
threshold = CONFIG["thresholds"]["win_rate_low"]  # → 0.06

The config is loaded once at import time and cached as a module-level CONFIG dict.


Cooldown System

File: cooldown.py

After the Action Agent takes action on a campaign, that campaign enters a cooldown period (configurable per action type). During cooldown, the pipeline still detects alerts (for visibility) but does NOT invoke LLM diagnosis or take further action -- it waits for the previous fix to propagate.

Why Cooldown Matters

Without cooldown, the agent would:

  • Expand an audience, then immediately expand again 60s later before the DSP has propagated the first expansion
  • Rotate creative 3 times in 3 minutes, flooding the campaign with variants
  • Adjust budget every cycle, creating oscillating instability

With cooldown, the agent acts once, waits for metrics to reflect the change, then re-evaluates.

How It Works

Cycle 1:  Alert detected → Diagnosis → Action taken → Cooldown starts (2 min)
Cycle 2:  Alert still detected → Campaign in cooldown → SKIPPED (shown in gray panel)
Cycle 3:  Alert still detected → Campaign in cooldown → SKIPPED
Cycle 4:  Cooldown expired → Campaign re-evaluated → Metrics improved? → Done or act again

Configuration

In config.yaml:

cooldown:
  default_minutes: 2          # Default for any action
  per_action:                  # Override per action type
    expand_audience: 2         # Audience propagation takes ~1-2 min in DSPs
    rotate_creative: 2         # Creative approval/activation time
    adjust_frequency_cap: 2    # Cap changes are immediate
    adjust_daily_budget: 2     # Budget changes are immediate
    recommend_bid_change: 5    # Bid changes take longer to affect win rates

In production with real DSPs where changes take 5-15 minutes to propagate:

cooldown:
  default_minutes: 10
  per_action:
    expand_audience: 30       # Audience segments take time to populate in DMP
    rotate_creative: 15       # Creative needs approval + CDN propagation
    adjust_frequency_cap: 5   # Near-instant in most DSPs
    adjust_daily_budget: 5    # Near-instant
    recommend_bid_change: 15  # Win rate changes lag 10-15 minutes

Implementation

  • Per-campaign tracking: Cooldown is recorded per campaign_id, not per action type. The entire campaign is paused from further intervention.
  • In-memory singleton: The CooldownTracker persists across cycles within a process. Resets on restart.
  • Visible in terminal: Skipped campaigns appear in a dimmed "Cooldown" panel showing remaining seconds.

Campaign Manager Notifications

The system sends two types of notifications to the campaign manager:

1. Autonomous Action Notifications (FYI)

When: After the Action Agent executes any autonomous action (audience expansion, creative rotation, frequency cap change, budget adjustment).

Purpose: Keep the campaign manager informed about what the system did, without requiring any approval. Transparency and auditability.

Format (terminal display):

╭──────── Campaign Manager Notifications (Autonomous Actions) ────────╮
│ Campaign: PharmaCo Branded Display — HCP Targeting                   │
│   → Expanded audience: Pulmonologists US (~120K users)               │
│   → Expanded audience: Oncology Nurses/NPs (~80K users)              │
│   → Adjusted frequency cap to 10                                     │
│   ✉ Notification sent to campaign manager (FYI — no approval needed) │
│                                                                       │
│ Campaign: RetailMax Summer Sale — Native                              │
│   → Rotated creative: Summer Sale V2 — Category-specific deals       │
│   → Rotated creative: Summer Sale V3 — Urgency countdown card        │
│   ✉ Notification sent to campaign manager (FYI — no approval needed) │
╰───────────────────────────────────────────────────────────────────────╯

Data structure:

{
  "campaign_id": "CMP-1001",
  "campaign_name": "PharmaCo Branded Display — HCP Targeting",
  "type": "autonomous_action_fyi",
  "priority": "info",
  "actions_taken": [
    "Expanded audience: Pulmonologists US (~120K users)",
    "Adjusted frequency cap to 10"
  ],
  "message": "Automated actions taken on PharmaCo... These actions were executed autonomously and do not require approval. Next evaluation after cooldown period."
}

2. Escalation Notifications (Approval Required)

When: The Action Agent recommends a bid change via recommend_bid_change tool.

Purpose: Bid changes affect CPM costs and total spend across the campaign's entire supply pool. They require explicit human approval before execution.

Format (terminal display):

╭──── Escalation Agent (LLM) — Campaign Manager Notification ────╮
│ Campaign: FinServ Retirement Planning   Priority: HIGH          │
│   CRITICAL — Bid $12.00 is $2.38 below $14.38 clearing,        │
│   yielding 3.7% win rate. Recommend $15.82 (10% above          │
│   clearing). Should lift win rate to 15-20%. Without this,      │
│   campaign finishes at 2.5M vs 6M target (58% shortfall).      │
│   Action required: Approve bid increase $12.00 -> $15.82        │
│   → Notification sent to campaign manager                       │
╰─────────────────────────────────────────────────────────────────╯

Notification Channels (config.yaml)

Currently outputs to console. Configured for future integrations:

notifications:
  channels:
    - type: console    # Always: Rich terminal output
    - type: email      # Future: SMTP integration
      enabled: false
      recipients: ["campaign-manager@company.com"]
    - type: slack      # Future: Slack webhook/API
      enabled: false
      channel: "#campaign-alerts"

LLM Configuration

File: llm.py

The system uses Claude Opus 4.6 via AWS Bedrock's Converse API, configured for analytical reasoning. All settings are in config.yaml.

How llm.py Works Internally

def get_llm() -> ChatBedrockConverse:
    # 1. Create boto3 session with SSO credentials
    #    - Clears AWS_BEARER_TOKEN_BEDROCK (Claude Code sets this, breaks SigV4)
    #    - Sets CA bundle for corporate proxy (Netskope)
    session = _create_boto3_session()

    # 2. Create Bedrock runtime client
    #    - SSL verification disabled (corporate proxy issues)
    #    - 300s read timeout (Opus is slow on complex prompts)
    #    - Adaptive retries (handles throttling)
    bedrock_client = session.client("bedrock-runtime", ...)

    # 3. Return LangChain ChatBedrockConverse instance
    #    - Converse API (not InvokeModel) for native tool-calling
    #    - Temperature 0.3 for consistent analytical output
    #    - 16K max tokens for multi-campaign JSON responses
    return ChatBedrockConverse(model=MODEL_ID, client=bedrock_client, ...)

Configuration

Parameter Value Rationale
Model us.anthropic.claude-opus-4-6-v1 Deepest reasoning for complex multi-signal analysis
Temperature 0.3 Low randomness for consistent analytical output
Max Tokens 16,384 Large enough for multi-campaign JSON diagnosis output
Read Timeout 300s (5 min) Opus can take 60-120s on complex prompts
Retries 3 (adaptive) Handles transient Bedrock throttling
SSL Verify False Corporate Netskope proxy causes intermittent issues

Why Converse API (not InvokeModel)

The Bedrock Converse API (ChatBedrockConverse) provides:

  • Native tool-calling: Structured function calls without prompt-engineering hacks
  • Multi-turn conversations: Tool results feed back as proper ToolMessages
  • Consistent interface: Same API regardless of underlying model

Model Override

BEDROCK_MODEL_ID=us.anthropic.claude-sonnet-4-6 python main.py    # Faster (10-20s)
BEDROCK_MODEL_ID=us.anthropic.claude-haiku-4-5-20251001-v1:0 python main.py  # Cheapest (3-5s)

Tool System

File: tools.py

Tools are implemented as LangChain @tool-decorated functions. They are thin wrappers around the campaign simulator -- all business logic lives in campaign_simulator.py. This means you can swap the simulator for a real DSP adapter and the tool signatures stay identical.

How Tools Work with the LLM

@tool
def expand_audience(campaign_id: str, segment_description: str) -> str:
    """
    Add a new audience segment to a campaign to expand addressable reach.
    [LLM reads this docstring to understand when/how to call the tool]
    """
    result = sim.apply_action(campaign_id, {"expand_audience": segment_description})
    return json.dumps(result, indent=2)

When bind_tools() is called:

  1. LangChain converts the @tool schema (name, description, parameters) to Bedrock's tool format
  2. Bedrock includes this in the system context so Claude knows what's available
  3. Claude responds with tool_calls when it decides to use a tool
  4. Our agentic loop executes the call and feeds the result back as a ToolMessage

Read Tools (available to Diagnosis + Action agents)

Tool Parameters Returns When to Use
get_campaign_details campaign_id: str Full campaign dict (all metrics) First call to understand campaign state
get_audience_segments campaign_id: str Segments with saturation data Check if audience is exhausted
get_creative_performance campaign_id: str Creatives with fatigue/CTR Assess creative rotation health

Write Tools (available to Action agent only)

Tool Parameters Mutates Campaign State Safety
expand_audience campaign_id, segment_description Adds segment with 0% saturation, 500K-3M reach Autonomous
rotate_creative campaign_id, creative_description Adds creative with fatigue=1.0 Autonomous
adjust_frequency_cap campaign_id, new_cap Updates cap (min 1) Autonomous
adjust_daily_budget campaign_id, new_daily_budget Updates budget (min $10) Autonomous
recommend_bid_change campaign_id, suggested_bid, rationale Does NOT mutate -- creates escalation record Requires approval

Tool Collections

READ_TOOLS = [get_campaign_details, get_audience_segments, get_creative_performance]
ALL_TOOLS = READ_TOOLS + [expand_audience, rotate_creative, adjust_frequency_cap,
                          adjust_daily_budget, recommend_bid_change]
  • Diagnosis Agent gets READ_TOOLS (inspect only, can't modify)
  • Action Agent gets ALL_TOOLS (full read + write + escalation)

LangGraph Workflow Engine

File: workflow.py

The LangGraph StateGraph orchestrates the entire pipeline. It manages shared state, conditional routing, and node execution.

PipelineState (TypedDict)

class PipelineState(TypedDict):
    campaigns: list[dict]           # From fetch node (raw campaign data)
    alerts: list[dict]              # From monitor node (actionable alerts only)
    cooldown_skipped: list[dict]    # From monitor node (alerts filtered by cooldown)
    diagnoses: list[dict]           # From diagnosis node (LLM root cause analysis)
    actions_executed: list[dict]    # From action node (autonomous actions taken)
    escalations: list[dict]        # From action node (bid changes for approval)
    action_reasoning: str           # From action node (LLM reasoning trace)
    action_notifications: list[dict] # From action node (FYI notifications)
    notifications: list[dict]      # From escalation node (manager notifications)

Node Functions

Each node reads from state and returns a dict of updates. LangGraph merges updates automatically:

Node Reads From State Writes To State LLM?
fetch (nothing) campaigns No
monitor campaigns alerts, cooldown_skipped No
diagnose alerts, campaigns diagnoses Yes (Claude + tools)
act diagnoses, campaigns actions_executed, escalations, action_reasoning, action_notifications Yes (Claude + tools)
escalate escalations, campaigns notifications Yes (Claude text)

Conditional Routing Logic

def should_diagnose(state):
    return "diagnose" if state["alerts"] else "end"

def should_escalate(state):
    return "escalate" if state["escalations"] else "end"

Graph Compilation

workflow = StateGraph(PipelineState)
workflow.add_node("fetch", fetch_campaigns)
workflow.add_node("monitor", monitor_node)
workflow.add_node("diagnose", diagnosis_node)
workflow.add_node("act", action_node)
workflow.add_node("escalate", escalation_node)

workflow.set_entry_point("fetch")
workflow.add_edge("fetch", "monitor")
workflow.add_conditional_edges("monitor", should_diagnose, {"diagnose": "diagnose", "end": END})
workflow.add_edge("diagnose", "act")
workflow.add_conditional_edges("act", should_escalate, {"escalate": "escalate", "end": END})
workflow.add_edge("escalate", END)

app = workflow.compile()  # Called once; reused across all cycles

Display Module

File: display.py

The display module renders clean Rich-formatted terminal output. It's completely decoupled from the agents -- it only reads the final pipeline state. You could swap Rich for Slack messages, email, or a web dashboard without touching agent code.

Panel Types

Panel Color Shows When
Cooldown Status Gray/dim Campaigns skipped due to cooldown with time remaining When any campaigns are in cooldown
Monitor Agent Yellow Alert table (campaign, issue, severity, metric) When alerts detected
Diagnosis Agent (LLM) Magenta Root cause + contributing factors per campaign When diagnosis runs
Action Agent (LLM + Tools) Green Autonomous actions executed + truncated reasoning When actions taken
CM Notifications (FYI) Blue Autonomous actions sent to campaign manager When autonomous actions exist
Escalation Agent (LLM) Red Bid-change notifications with cost/benefit When bid changes recommended
Status (healthy) Green "No issues detected" When no alerts

Design Principles

  • Scannable: Campaign manager can glance and know what happened
  • Non-cluttered: No raw JSON dumps, no debug logs
  • Color-coded: Red = critical, Yellow = warning, Green = actions, Blue = FYI, Cyan = healthy
  • Severity-aware: Critical alerts show in red, warnings in yellow
  • Truncated reasoning: LLM reasoning trace capped at 500 chars to keep output readable

Campaign Parameters Tracked

The system monitors a comprehensive set of programmatic advertising metrics:

Category Metric Field Path Description
Budget Total Budget budget.total Lifetime campaign budget
Daily Budget budget.daily Target daily spend
Total Spend budget.spent_total Cumulative spend to date
Today's Spend budget.spent_today Spend accumulated today
Remaining budget.remaining Budget left in flight
Utilization % budget.utilization_pct spent_total / total_budget
Schedule Flight Day schedule.flight_day Current day of the campaign flight
Total Days schedule.total_days Full flight duration
Days Remaining schedule.days_remaining Days left in the flight
Delivery Target Impressions delivery.target_impressions Lifetime impression goal
Delivered Total delivery.delivered_total Impressions delivered to date
Delivered Today delivery.delivered_today Impressions delivered today
Target Daily delivery.target_daily Expected impressions per day
Pacing Ratio delivery.pacing_ratio (delivered / target) / (day / total_days) -- 1.0 = on pace
Daily Pacing Ratio delivery.daily_pacing_ratio Intraday pacing against hourly expectation
Bidding Base Bid bidding.base_bid Original configured bid
Current Bid bidding.current_bid Active bid (may be adjusted by agent)
Bid Floor bidding.bid_floor Minimum allowable bid
Bid Ceiling bidding.bid_ceiling Maximum allowable bid
Win Rate % bidding.win_rate_pct Percentage of auctions won
Avg Clearing Price bidding.avg_clearing_price Average market clearing price
Performance Clicks performance.clicks Total clicks
CTR % performance.ctr_pct Click-through rate
Conversions performance.conversions Total conversions
CVR % performance.cvr_pct Conversion rate
Viewability % performance.viewability_pct Percentage of impressions viewable
Completion Rate % performance.completion_rate_pct Video/audio completion rate
CPM performance.cpm Cost per thousand impressions
CPC performance.cpc Cost per click
CPA performance.cpa Cost per acquisition
ROAS performance.roas Return on ad spend
Frequency Avg Frequency frequency.avg_frequency Mean impressions per unique user
Frequency Cap frequency.frequency_cap Maximum impressions per user
Unique Reach frequency.unique_reach Number of distinct users reached
Users at Cap % frequency.users_at_cap_pct % of reached users at the frequency cap
Audience Segment Reach audience[].reach Estimated addressable users in segment
Matched Users audience[].matched Users matched so far
Saturation % audience[].saturation_pct Proportion of segment already reached
Creative Impressions creatives[].impressions Impressions served per creative
CTR % creatives[].ctr_pct Creative-level click-through rate
Fatigue Factor creatives[].fatigue_factor 1.0 = fresh, decays toward 0 as creative wears out
Supply Available Inventory supply.available_inventory Addressable supply in target placements
Bid Requests supply.bid_requests Total bid requests received
Bids Submitted supply.bids_submitted Bids the DSP placed
Auctions Won supply.auctions_won Auctions won
Fill Rate % supply.fill_rate_pct auctions_won / bids_submitted
Quality Brand Safety Flags quality.brand_safety_flags Count of brand safety violations
Invalid Traffic Rate % quality.invalid_traffic_rate_pct Suspected fraudulent/invalid traffic rate
Domain Block Rate % quality.domain_block_rate_pct Rate of blocked domains in supply

Setup and Running

Prerequisites

Requirement Version Notes
Python 3.12 or 3.13 3.14 has Pydantic compatibility issues as of Apr 2026
uv (recommended) latest Fast Python package manager from Astral
AWS CLI v2 For SSO login to access Bedrock
AWS Account With Bedrock access Must have Claude models enabled in us-east-1
OS macOS / Linux / WSL Tested on macOS (Darwin/ARM64)

Step 1: Install uv (if not already installed)

# macOS / Linux
curl -LsSf https://astral.sh/uv/install.sh | sh

# Or via Homebrew (macOS)
brew install uv

# Verify installation
uv --version

Step 2: Clone and enter the project

git clone <repo-url>
cd langgraph-agentic-dsp-optimizer

Step 3: Create virtual environment

# Creates .venv/ using Python 3.13
uv venv --python 3.13

# Activate the virtual environment
source .venv/bin/activate        # macOS / Linux / WSL
# .venv\Scripts\activate         # Windows (cmd)

If Python 3.13 is not installed on your system:

# uv downloads and manages Python versions for you
uv python install 3.13
uv venv --python 3.13

Step 4: Install dependencies

# Install from pyproject.toml
uv pip install -e .

# Or install packages directly
uv pip install langgraph langchain-core langchain-aws boto3 rich pyyaml

Step 5: Configure AWS SSO

The agent uses AWS Bedrock to call Claude. You need an AWS profile configured with SSO access to Bedrock.

~/.aws/config should contain a profile like:

[default]
sso_session = my-session
sso_account_id = 123456789012
sso_role_name = BedrockAccess
region = us-east-1

[sso-session my-session]
sso_start_url = https://your-org.awsapps.com/start
sso_region = us-east-1
sso_registration_scopes = sso:account:access

Then login:

aws sso login --profile default

Step 6: (Optional) Customize configuration

Edit config.yaml to adjust cooldown duration, model, thresholds, or cycle interval:

# Example: reduce cooldown to 1 minute for faster iteration
sed -i '' 's/default_minutes: 2/default_minutes: 1/' config.yaml

# Or use env vars for one-off changes
COOLDOWN_MINUTES=1 BEDROCK_MODEL_ID=us.anthropic.claude-sonnet-4-6 python main.py

Step 7: Run the agent

python main.py

The agent starts a continuous monitoring loop, executing one cycle every 60 seconds. Press Ctrl+C to stop.

Dependencies

Package Version Purpose
langgraph >= 0.4.0 LangGraph StateGraph workflow engine
langchain-core >= 0.3.0 Core abstractions, messages, tool decorators
langchain-aws >= 0.2.0 ChatBedrockConverse -- Claude via Bedrock Converse API
boto3 >= 1.35.0 AWS SDK for Bedrock runtime client
rich >= 13.0.0 Terminal formatting -- panels, tables, color output
pyyaml >= 6.0 YAML config file parsing

Quick Start (one-liner)

uv venv --python 3.13 && source .venv/bin/activate && uv pip install -e . && aws sso login && python main.py

Environment Variables

Variable Purpose Default
BEDROCK_MODEL_ID Override the Claude model us.anthropic.claude-opus-4-6-v1
AWS_REGION AWS region for Bedrock us-east-1
AWS_PROFILE AWS profile name for SSO credentials default
CYCLE_INTERVAL Override cycle interval (seconds) 60
COOLDOWN_MINUTES Override default cooldown duration (minutes) 2
LANGSMITH_API_KEY Enable LangSmith tracing for workflow observability (none)
LANGSMITH_PROJECT LangSmith project name for trace grouping default

Model options:

# Deepest reasoning (default) -- 60-120s per call
BEDROCK_MODEL_ID=us.anthropic.claude-opus-4-6-v1 python main.py

# Faster analysis -- 10-20s per call
BEDROCK_MODEL_ID=us.anthropic.claude-sonnet-4-6 python main.py

# Fastest/cheapest -- 3-5s per call
BEDROCK_MODEL_ID=us.anthropic.claude-haiku-4-5-20251001-v1:0 python main.py

Troubleshooting

Problem Solution
Python 3.14 -- TypeError: _eval_type() Pydantic does not support Python 3.14 yet. Use 3.12 or 3.13.
ExpiredTokenException Run aws sso login --profile default to refresh SSO token.
ReadTimeoutError after 60s Opus can take 2+ minutes. The config sets 300s timeout; ensure network is stable.
IncompleteSignatureException The AWS_BEARER_TOKEN_BEDROCK env var may interfere. The code clears it automatically.
SSL certificate errors (Netskope/corporate proxy) SSL verification is disabled by default (verify=False).
ValidationException: model identifier invalid Check model ID with aws bedrock list-inference-profiles.
Diagnosis shows "Parse error" Ensure max_tokens >= 16384 in config.yaml. Opus needs space for multi-campaign JSON.
ModuleNotFoundError: langchain_aws Run uv pip install langchain-aws boto3.
ModuleNotFoundError: yaml Run uv pip install pyyaml.
Campaigns never re-evaluated Cooldown too long. Reduce cooldown.default_minutes in config.yaml.

Sample Output

Below is actual output from a single monitoring cycle (Cycle 1, then Cycle 2 showing cooldown):

Cycle 1 (Initial detection + action):

Campaign Pacing Agent
Model: us.anthropic.claude-opus-4-6-v1 via AWS Bedrock (us-east-1)
Monitoring interval: 60s  ·  Cooldown: 2 min  ·  Press Ctrl+C to stop

──────────────────────── Cycle 1  ·  09:34:25 ────────────────────────

╭──────────────────────── Monitor Agent ────────────────────────╮
│ Campaign                Issue                    Severity  Key Metric              │
│ PharmaCo Branded        Underpacing -- behind    WARNING   pacing=0.66             │
│ Display -- HCP          delivery schedule                  (target=1.0)            │
│ PharmaCo Branded        Audience saturation --   WARNING   saturation=72.8%        │
│ Display -- HCP          'Oncologists' 72.8%               reach=131,065/180,000    │
│ AutoBrand CTV           Overpacing -- ahead of   CRITICAL  pacing=1.74            │
│                         delivery schedule                  (target=1.0)            │
│ AutoBrand CTV           Budget overrun           WARNING   projected=$598K vs      │
│                         projected                         remaining=$194K          │
│ RetailMax Summer Sale   Creative fatigue --      WARNING   fatigue=0.50            │
│                         'Summer Sale Hero'                                         │
│ RetailMax Summer Sale   High invalid traffic     CRITICAL  ivt=3.2%               │
│ FinServ Retirement      Low win rate             CRITICAL  win_rate=3.7%,          │
│ Planning                                                   clearing=$14.38         │
│                                                            bid=$12.00              │
│ StreamCo Retargeting    Frequency cap            WARNING   users_at_cap=55.8%      │
│                         saturation                        avg_freq=9.07            │
╰───────────────────────────────────────────────────────────────╯

╭──────────────── Diagnosis Agent (LLM) ─────────────────╮
│ PharmaCo Branded Display -- HCP Targeting               │
│   Root cause: Audience exhaustion -- combined pool of   │
│   Oncologists (180K) and Hematologists (95K) totals     │
│   only 275K users with 72.8% saturation, leaving too    │
│   few unreached users to sustain 500K daily target      │
│   · Low win rate 8.0% despite bidding $8.50 above       │
│     $7.20 clearing -- structurally limited inventory    │
│   · Needs 585K/day vs current 328K/day -- 78% gap      │
│                                                         │
│ FinServ Retirement Planning                             │
│   Root cause: Bid compression -- $12.00 bid is $2.38   │
│   below $14.38 clearing price, yielding 3.7% win rate  │
│   · 8.06M bids submitted, only 319K won (4% fill)      │
│   · Needs 262K/day, winning only 71K/day -- 3.7x gap   │
╰─────────────────────────────────────────────────────────╯

╭──────────────── Action Agent (LLM + Tools) ────────────────╮
│ Autonomous Actions Taken:                                   │
│   ✓ [CMP-1001] Expanded audience: 'Pulmonologists US,      │
│     Immunologists US, Oncology Nurses/NPs'                  │
│   ✓ [CMP-1002] Daily budget -> $10,810.00                  │
│   ✓ [CMP-1002] Frequency cap -> 6                          │
│   ✓ [CMP-1003] Rotated creative: 'Summer Sale V2'          │
│   ✓ [CMP-1003] Rotated creative: 'Summer Sale V3'          │
│   ✓ [CMP-1005] Expanded audience: 'Lapsed Subscribers 60d' │
│   ✓ [CMP-1005] Frequency cap -> 6                          │
╰─────────────────────────────────────────────────────────────╯

╭──────── Campaign Manager Notifications (Autonomous Actions) ────────╮
│ Campaign: PharmaCo Branded Display — HCP Targeting                   │
│   → Expanded audience: Pulmonologists US, Oncology Nurses/NPs        │
│   ✉ Notification sent to campaign manager (FYI — no approval needed) │
│                                                                       │
│ Campaign: RetailMax Summer Sale — Native                              │
│   → Rotated creative: Summer Sale V2                                  │
│   → Rotated creative: Summer Sale V3                                  │
│   ✉ Notification sent to campaign manager (FYI — no approval needed) │
╰───────────────────────────────────────────────────────────────────────╯

╭──── Escalation Agent (LLM) -- Campaign Manager Notification ────╮
│ Campaign: FinServ Retirement Planning   Priority: HIGH           │
│   CRITICAL -- Bid $12.00 is $2.38 below $14.38 clearing,       │
│   yielding 3.7% win rate. Recommend $15.82 (10% above          │
│   clearing). Should lift win rate to 15-20%. Without this,      │
│   campaign finishes at 2.5M vs 6M target (58% shortfall).      │
│   Action required: Approve bid increase $12.00 -> $15.82        │
│   -> Notification sent to campaign manager                      │
╰─────────────────────────────────────────────────────────────────╯

Cycle 2 (Cooldown in effect):

──────────────────────── Cycle 2  ·  09:35:25 ────────────────────────

╭──────────────── Cooldown — Skipped Campaigns ────────────────╮
│   ⏸  PharmaCo Branded Display — cooldown 58s remaining       │
│   ⏸  AutoBrand CTV — Q2 Launch — cooldown 58s remaining     │
│   ⏸  RetailMax Summer Sale — cooldown 58s remaining          │
│   ⏸  FinServ Retirement Planning — cooldown 238s remaining   │
│   ⏸  StreamCo Subscription Retargeting — cooldown 58s        │
╰──────────────────────────────────────────────────────────────╯

╭──────────────────── Status ─────────────────────╮
│ No actionable issues this cycle.                 │
│ All flagged campaigns are in cooldown.           │
╰──────────────────────────────────────────────────╯

Project Structure

campaign-pacing-agent/
├── agents/
│   ├── __init__.py          # Package init
│   ├── monitor.py           # Monitor Agent — 11 threshold checks (rules, no LLM)
│   ├── diagnosis.py         # Diagnosis Agent — Claude LLM with read tools (5 rounds)
│   ├── action.py            # Action Agent — Claude LLM with full tool suite (15 rounds)
│   └── escalation.py        # Escalation Agent — Claude LLM text generation
├── campaign_simulator.py    # Dynamic mock DSP — 6 campaigns, tick-based drift, agent feedback
├── config.py                # Configuration loader — reads config.yaml + env overrides
├── config.yaml              # All configurable settings (cooldown, thresholds, model, etc.)
├── cooldown.py              # Cooldown tracker — prevents repeated actions on same campaign
├── llm.py                   # LLM factory — ChatBedrockConverse (Claude Opus 4.6 via Bedrock)
├── tools.py                 # LangChain @tool definitions (3 read + 4 write + 1 escalation)
├── workflow.py              # LangGraph StateGraph — conditional routing + cooldown integration
├── display.py               # Rich terminal display — panels, tables, color-coded output
├── main.py                  # Entry point — configurable monitoring loop
├── pyproject.toml           # Project metadata and dependencies
└── .python-version          # Python 3.13

File Responsibilities

File Lines Purpose LLM?
main.py Entry point Compiles workflow, runs 60s loop, displays results No
workflow.py Orchestration StateGraph definition, conditional edges, cooldown integration No
agents/monitor.py Detection 11 rule-based checks, alert generation No
agents/diagnosis.py Analysis Root cause analysis via Claude with read tools Yes
agents/action.py Execution Agentic tool-calling loop (15 rounds), action tracking Yes
agents/escalation.py Communication Professional notification writing Yes
campaign_simulator.py Data source Stateful campaign simulation with drift and feedback No
llm.py Infrastructure boto3 session, Bedrock client, ChatBedrockConverse factory No
tools.py Interface @tool wrappers bridging LLM to simulator No
config.py + config.yaml Configuration All tunable settings in one place No
cooldown.py Rate limiting Prevents action storms on same campaign No
display.py Presentation Rich panels for human consumption No

Cost Estimates

Each cycle with ~5 flagged campaigns involves 3 LLM invocations (diagnosis, action, escalation):

Model Cost per Cycle Cost per Hour Latency per Cycle Best For
Claude Opus 4.6 ~$0.05-0.10 ~$3-6/hour 60-180s Deep analysis, complex reasoning
Claude Sonnet 4.6 ~$0.01-0.02 ~$0.60-1.20/hour 15-40s Production monitoring
Claude Haiku 4.5 ~$0.001-0.003 ~$0.06-0.18/hour 5-15s High-frequency, simple issues

Cost optimization:

  • Clean cycles (no alerts) incur zero LLM cost -- only the rule-based monitor runs
  • Cooldown reduces LLM calls by skipping recently-acted campaigns
  • Conditional edges prevent unnecessary LLM invocations (no escalation if no bid changes)

How to Extend

Connect to a Real DSP API

Replace campaign_simulator.py with an adapter that calls your actual DSP:

# campaign_simulator.py (real implementation)
import your_dsp_sdk

def get_campaigns() -> list[dict]:
    """Fetch live campaigns from DV360, The Trade Desk, Xandr, etc."""
    return your_dsp_sdk.get_active_campaigns(format="dict")

def get_campaign(campaign_id: str) -> dict | None:
    """Fetch single campaign details."""
    return your_dsp_sdk.get_campaign(campaign_id)

def apply_action(campaign_id: str, action: dict) -> dict:
    """Push changes back to the DSP via API."""
    return your_dsp_sdk.update_campaign(campaign_id, **action)

The rest of the pipeline works unchanged -- it only depends on the dict schema.

Add Slack / Email / PagerDuty Notifications

Modify the display/escalation layer to send real notifications:

# notification_sender.py
import slack_sdk
import smtplib

def send_notification(notification: dict, channels: list[dict]):
    for channel in channels:
        if channel["type"] == "slack" and channel.get("enabled"):
            client = slack_sdk.WebClient(token=os.environ["SLACK_TOKEN"])
            client.chat_postMessage(
                channel=channel["channel"],
                text=notification["message"],
            )
        elif channel["type"] == "email" and channel.get("enabled"):
            send_email(notification, channel["recipients"])

Add New Monitor Checks

Add checks in agents/monitor.py by following the existing pattern:

def _check_your_new_metric(campaign: dict, alerts: list):
    value = campaign["your_metric"]["field"]
    if value > YOUR_THRESHOLD:
        alerts.append({
            "campaign_id": campaign["campaign_id"],
            "campaign_name": campaign["campaign_name"],
            "issue": f"Your issue description -- value is {value}",
            "severity": "warning",
            "metric": f"value={value}",
            "details": {"raw_value": value},
        })

Add New Tools

Add tools in tools.py using the @tool decorator:

from langchain_core.tools import tool

@tool
def pause_campaign(campaign_id: str, reason: str) -> str:
    """Pause a campaign entirely. Use when budget is exhausted or fraud detected."""
    result = sim.apply_action(campaign_id, {"pause": reason})
    return json.dumps(result)

# Add to the appropriate tool list
ALL_TOOLS = READ_TOOLS + WRITE_TOOLS + [pause_campaign]

Adjust Cooldown for Your Environment

In config.yaml, tune per-action cooldowns based on your DSP's propagation times:

cooldown:
  default_minutes: 10
  per_action:
    expand_audience: 30       # DMP takes 20-30 min to activate new segments
    rotate_creative: 15       # Creative approval + CDN propagation
    adjust_frequency_cap: 5   # Near-instant in most DSPs
    adjust_daily_budget: 5    # Near-instant
    recommend_bid_change: 15  # Win rate changes lag 10-15 min

Switch Models

Change the default model in config.yaml or use environment variables:

# Use Sonnet for faster iteration during development
BEDROCK_MODEL_ID=us.anthropic.claude-sonnet-4-6 python main.py

# Use Haiku for cost-sensitive production monitoring
BEDROCK_MODEL_ID=us.anthropic.claude-haiku-4-5-20251001-v1:0 python main.py

Persist Action History

Add logging to track agent decisions over time:

import json
from datetime import datetime

def _log_action(action_record: dict):
    with open("action_log.jsonl", "a") as f:
        action_record["timestamp"] = datetime.now().isoformat()
        f.write(json.dumps(action_record) + "\n")

Tune Monitor Thresholds

All thresholds are in config.yaml under the thresholds section:

thresholds:
  pacing_underpace: 0.75       # more lenient: 0.65, stricter: 0.85
  win_rate_low: 0.06           # adjust based on your auction environment
  creative_fatigue: 0.70       # lower = tolerate more fatigue before alerting
  audience_saturation: 0.70    # percentage of segment already reached
  frequency_cap_warning: 0.40  # users at cap before alerting

Built with LangGraph, Claude Opus 4.6 on AWS Bedrock, and Rich.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages