|
| 1 | +# Salesagent side-car experiment — Phase 2 reference implementation |
| 2 | + |
| 3 | +The side-car runtime that runs alongside salesagent's existing |
| 4 | +`adcp-server` container, serving one experiment tenant via SDK |
| 5 | +primitives while leaving every other tenant on the legacy runtime. |
| 6 | + |
| 7 | +This directory is the **reference implementation** in |
| 8 | +adcp-client-python. The actual deployment is into a salesagent |
| 9 | +fork worktree, where these files mount under `src/sdk_runtime/` |
| 10 | +and import salesagent's `_impl` functions, models, and |
| 11 | +workflow_manager. |
| 12 | + |
| 13 | +See the [experiment plan](../../docs/proposals/salesagent-sidecar-experiment.md) |
| 14 | +for the full design (Phases 1A, 1B, 2; Step 0 prereqs; learning |
| 15 | +questions Q1–Q5; pre-registered falsification signals). |
| 16 | + |
| 17 | +## What's here |
| 18 | + |
| 19 | +| File | What it does | |
| 20 | +|---|---| |
| 21 | +| `account_store.py` | `SalesagentBuyerAgentRegistry` (Principal.access_token bearer lookup) + `SalesagentAccountStore` (Account row → SDK Account, AgentAccountAccess scoping) + `fetch_gam_manual_approval_required` (HITL flag) | |
| 22 | +| `gam_platform.py` | `GAMPlatform` wraps salesagent's `_impl` functions: `_get_products_impl`, `_create_media_buy_impl`, `_update_media_buy_impl`, `_get_media_buy_delivery_impl`, `_sync_creatives_impl`. Builds `ResolvedIdentity` from SDK ctx | |
| 23 | +| `hitl_gate.py` | `compose_method` `before` hook — checks `gam_manual_approval_required`, writes `WorkflowStep` + `MediaBuy(raw_request=...)` rows via salesagent's existing `WorkflowManager`, short-circuits with `status='pending_approval'`. Wire→GAM-internal operation name mapping for the approval-config check | |
| 24 | +| `serve_sidecar.py` | Entrypoint — `adcp.serve(...)` with the platform + auth shim + `WebhookSender` configured | |
| 25 | + |
| 26 | +## How it ties to salesagent |
| 27 | + |
| 28 | +``` |
| 29 | +┌──────────────────────────────────────────────────────────────┐ |
| 30 | +│ nginx proxy (port 8000) │ |
| 31 | +│ route by X-Tenant-Id header: │ |
| 32 | +│ experiment_tenant → adcp-sidecar (port 8081) │ |
| 33 | +│ everyone else → adcp-server (port 8080) │ |
| 34 | +└──────────────────┬─────────────────────┬─────────────────────┘ |
| 35 | + │ │ |
| 36 | + ┌──────────▼─────────┐ ┌────────▼───────────┐ |
| 37 | + │ adcp-server (8080) │ │ adcp-sidecar (8081)│ |
| 38 | + │ legacy runtime: │ │ this code: │ |
| 39 | + │ - FastMCP server │ │ - adcp.serve(...) │ |
| 40 | + │ - A2A server │ │ - GAMPlatform │ |
| 41 | + │ - existing tools │ │ - HITL gates │ |
| 42 | + └──────────┬─────────┘ └────────┬───────────┘ |
| 43 | + │ │ |
| 44 | + ↓ ↓ |
| 45 | + ┌─────────────────────────────────────────────┐ |
| 46 | + │ Postgres (shared) — Tenant, Principal, │ |
| 47 | + │ Account, Product, MediaBuy, WorkflowStep │ |
| 48 | + └─────────────────────────────────────────────┘ |
| 49 | +``` |
| 50 | + |
| 51 | +## Deployment recipe (salesagent fork only — local, no upstream PR) |
| 52 | + |
| 53 | +In a salesagent worktree (created via |
| 54 | +`git worktree add /Users/brianokelley/Developer/salesagent/.conductor/sidecar-experiment -b bokelley/sidecar-experiment main`): |
| 55 | + |
| 56 | +### 1. Copy this directory in |
| 57 | + |
| 58 | +```bash |
| 59 | +cp -r /path/to/adcp-client-python/examples/salesagent_sidecar \ |
| 60 | + /Users/brianokelley/Developer/salesagent/.conductor/sidecar-experiment/src/sdk_runtime |
| 61 | +``` |
| 62 | + |
| 63 | +The imports inside the files use `from src.core.tools...` and |
| 64 | +`from src.core.database.models...` — they'll resolve in the salesagent |
| 65 | +container. |
| 66 | + |
| 67 | +### 2. Patch the two cross-tenant schedulers (Step 0.4) |
| 68 | + |
| 69 | +In the worktree, edit: |
| 70 | + |
| 71 | +* `src/services/media_buy_status_scheduler.py` — add tenant filter |
| 72 | +* `src/services/delivery_webhook_scheduler.py` — add tenant filter |
| 73 | + |
| 74 | +```python |
| 75 | +# At top of each scheduler module |
| 76 | +import os |
| 77 | +SKIP_TENANT_IDS = { |
| 78 | + t.strip() |
| 79 | + for t in os.environ.get("EXPERIMENT_TENANT_IDS", "").split(",") |
| 80 | + if t.strip() |
| 81 | +} |
| 82 | + |
| 83 | +# In the cross-tenant query, add: |
| 84 | +stmt = stmt.where(MediaBuy.tenant_id.notin_(SKIP_TENANT_IDS)) |
| 85 | +``` |
| 86 | + |
| 87 | +(Two cross-tenant schedulers; per-tenant disable is local-fork only, |
| 88 | +not pushed upstream.) |
| 89 | + |
| 90 | +### 3. Add the side-car service to docker-compose |
| 91 | + |
| 92 | +Append to `docker-compose.yml`: |
| 93 | + |
| 94 | +```yaml |
| 95 | + adcp-sidecar: |
| 96 | + build: |
| 97 | + context: . |
| 98 | + dockerfile: Dockerfile |
| 99 | + entrypoint: [] |
| 100 | + env_file: |
| 101 | + - path: .env |
| 102 | + required: false |
| 103 | + environment: |
| 104 | + DATABASE_URL: postgresql://adcp_user:secure_password_change_me@postgres:5432/adcp?sslmode=disable |
| 105 | + EXPERIMENT_TENANT_IDS: "tenant_acme_test" |
| 106 | + SIDECAR_PORT: "8081" |
| 107 | + SIDECAR_WEBHOOK_SECRET: "test-secret-bytes-for-adcp-legacy" |
| 108 | + PYTHONPATH: "/app/.venv/lib/python3.12/site-packages:/app" |
| 109 | + depends_on: |
| 110 | + postgres: |
| 111 | + condition: service_healthy |
| 112 | + db-init: |
| 113 | + condition: service_completed_successfully |
| 114 | + volumes: |
| 115 | + - .:/app |
| 116 | + - /app/.venv |
| 117 | + # Mount adcp-client-python source for live changes |
| 118 | + - ../../adcp-client-python/src/adcp:/app/.venv/lib/python3.12/site-packages/adcp:ro |
| 119 | + command: ["python", "-m", "src.sdk_runtime.serve_sidecar"] |
| 120 | + healthcheck: |
| 121 | + test: ["CMD", "curl", "-f", "http://localhost:8081/health"] |
| 122 | + interval: 30s |
| 123 | + timeout: 10s |
| 124 | + retries: 3 |
| 125 | +``` |
| 126 | +
|
| 127 | +### 4. Update nginx config to route by tenant |
| 128 | +
|
| 129 | +In `config/nginx/nginx-development.conf`, add a `map` block at the |
| 130 | +top to detect the experiment tenant from headers and route: |
| 131 | + |
| 132 | +```nginx |
| 133 | +map $http_x_tenant_id $upstream_runtime { |
| 134 | + default adcp-server:8080; |
| 135 | + tenant_acme_test adcp-sidecar:8081; |
| 136 | +} |
| 137 | +
|
| 138 | +# Then in the server block, replace the upstream proxy_pass with: |
| 139 | +proxy_pass http://$upstream_runtime; |
| 140 | +``` |
| 141 | + |
| 142 | +### 5. Bring it up |
| 143 | + |
| 144 | +```bash |
| 145 | +cd /Users/brianokelley/Developer/salesagent/.conductor/sidecar-experiment |
| 146 | +docker compose up --build |
| 147 | +``` |
| 148 | + |
| 149 | +Both adcp-server (legacy) and adcp-sidecar (SDK) come up. Nginx routes |
| 150 | +by `X-Tenant-Id` header. |
| 151 | + |
| 152 | +### 6. Configure the experiment tenant |
| 153 | + |
| 154 | +Through salesagent's admin UI at http://localhost:8000/: |
| 155 | + |
| 156 | +* Create tenant `tenant_acme_test` |
| 157 | +* Create at least one Principal under it (note the `access_token`) |
| 158 | +* Create at least one Account with `sandbox=True` |
| 159 | +* Configure GAM credentials (real sandbox Network ID + service account JSON) |
| 160 | +* Set `gam_manual_approval_required=False` for first run; flip to `True` |
| 161 | + for HITL exercise |
| 162 | + |
| 163 | +### 7. Run the storyboards |
| 164 | + |
| 165 | +```bash |
| 166 | +# Happy path (instant approval) |
| 167 | +npx -y -p @adcp/client adcp storyboard run \ |
| 168 | + -H "X-Tenant-Id: tenant_acme_test" \ |
| 169 | + -H "Authorization: Bearer <principal_access_token>" \ |
| 170 | + http://localhost:8000/mcp media_buy_seller --json |
| 171 | +
|
| 172 | +# HITL exercise (approval flow) — set gam_manual_approval_required=True first |
| 173 | +npx -y -p @adcp/client adcp storyboard run \ |
| 174 | + -H "X-Tenant-Id: tenant_acme_test" \ |
| 175 | + -H "Authorization: Bearer <principal_access_token>" \ |
| 176 | + http://localhost:8000/mcp media_buy_guaranteed_approval --json |
| 177 | +``` |
| 178 | + |
| 179 | +## Exit criteria (from PR #506) |
| 180 | + |
| 181 | +1. Both storyboards pass against sandbox GAM |
| 182 | +2. Recipe carries `implementation_config` without escape hatches |
| 183 | + (✅ already confirmed in Phase 1B — |
| 184 | + [`examples/recipe_falsification/`](../recipe_falsification/)) |
| 185 | +3. Glue LOC under ratio thresholds (proposal-side ≤60%, |
| 186 | + decisioning-side ≤30%) |
| 187 | +4. Zero structural-guard allowlist additions (per Step 0.3, no |
| 188 | + guards fire on `src/sdk_runtime/`) |
| 189 | +5. At least one finding contradicts a #502 prior (✅ already |
| 190 | + satisfied — Q1.5 in Phase 1A) |
| 191 | +6. Webhook signature verified by a subscribed test buyer (per |
| 192 | + Step 0.6, parity is SDK→SDK only since salesagent's scheme |
| 193 | + is incompatible) |
| 194 | + |
| 195 | +## Open work in this scaffold |
| 196 | + |
| 197 | +The reference implementation here is structurally complete but has |
| 198 | +gaps that surface during deployment. Document each as a finding when |
| 199 | +you hit it: |
| 200 | + |
| 201 | +* **`_build_resolved_identity` may need more fields** — `_impl`s |
| 202 | + read `identity.tenant.gemini_api_key`, `identity.tenant.advertising_policy`, |
| 203 | + etc. The minimal projection here may need expanding. |
| 204 | +* **`_create_media_buy_impl` returns `CreateMediaBuyResult`** (a |
| 205 | + wrapper with `.response` and `.status`) — ensure the return-type |
| 206 | + projection matches the wire shape. |
| 207 | +* **`WorkflowManager` constructor signature** varies across |
| 208 | + salesagent versions — pin to a specific commit and adjust if |
| 209 | + needed. |
| 210 | +* **`_already_approved` setattr survival** — verified against |
| 211 | + `compose_method` in Step 0.5, but make sure no salesagent-side |
| 212 | + request projection re-validates between admin UI approval and |
| 213 | + the sidecar's gate check. |
| 214 | +* **F12 webhook test against subscribed buyer** uses |
| 215 | + `adcp.WebhookReceiver` with matching secret (per Step 0.6) — |
| 216 | + not real salesagent buyers. |
| 217 | + |
| 218 | +## Why local-fork, not upstream PR |
| 219 | + |
| 220 | +The user explicitly scoped this experiment as local-fork only |
| 221 | +(no PRs to salesagent). The scheduler patches and `src/sdk_runtime/` |
| 222 | +directory live as local commits in the salesagent worktree; |
| 223 | +`git checkout main` reverts everything cleanly. |
| 224 | + |
| 225 | +## See also |
| 226 | + |
| 227 | +* [Experiment plan](../../docs/proposals/salesagent-sidecar-experiment.md) |
| 228 | +* [Recipe falsification (Phase 1B)](../recipe_falsification/) |
| 229 | +* [Product architecture (revised post-experiment)](../../docs/proposals/product-architecture.md) |
0 commit comments