| 1 | """Scripted one-tool supplier research loop (no live model, no Agents SDK).""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import json |
| 6 | from dataclasses import dataclass, field |
| 7 | from datetime import datetime |
| 8 | from typing import Literal |
| 9 | |
| 10 | from pydantic import BaseModel, ConfigDict |
| 11 | |
| 12 | from .application import CommerceApplication |
| 13 | from .errors import AgentResultInvalid |
| 14 | from .models import ApprovalGrant, PurchaseResult |
| 15 | from .tool import X402FetchTool, build_x402_fetch_tool |
| 16 | |
| 17 | DEFAULT_RESOURCE_URL = ( |
| 18 | "https://merchant.invalid/reports/SYNTH-SUPPLIER-RISK-001" |
| 19 | ) |
| 20 | DEFAULT_PURPOSE = "supplier_due_diligence" |
| 21 | |
| 22 | |
| 23 | class SupplierResearchOutput(BaseModel): |
| 24 | """Typed, non-authoritative summary proposed by the scripted agent.""" |
| 25 | |
| 26 | model_config = ConfigDict(frozen=True) |
| 27 | |
| 28 | status: Literal["completed"] |
| 29 | report_id: Literal["SYNTH-SUPPLIER-RISK-001"] |
| 30 | supplier: Literal["Northstar Components"] |
| 31 | signals: tuple[str, ...] |
| 32 | disclaimer: str |
| 33 | receipt_id: str |
| 34 | amount: Literal["0.25"] |
| 35 | currency: Literal["USDC"] |
| 36 | requires_human_approval: bool |
| 37 | |
| 38 | |
| 39 | @dataclass |
| 40 | class PurchaseResultRecorder: |
| 41 | """Application-owned, per-run record of completed tool purchases.""" |
| 42 | |
| 43 | results: list[PurchaseResult] = field(default_factory=list) |
| 44 | |
| 45 | def record(self, result: PurchaseResult) -> None: |
| 46 | self.results.append(result) |
| 47 | |
| 48 | |
| 49 | @dataclass(frozen=True) |
| 50 | class SupplierResearchRun: |
| 51 | """Agent proposal paired with the application evidence that validated it.""" |
| 52 | |
| 53 | output: SupplierResearchOutput |
| 54 | purchase: PurchaseResult |
| 55 | |
| 56 | |
| 57 | @dataclass |
| 58 | class ScriptedSupplierAgent: |
| 59 | """Tiny stand-in for an Agents SDK Agent with one bound economic tool.""" |
| 60 | |
| 61 | name: str |
| 62 | tools: list[X402FetchTool] |
| 63 | output_type: type[SupplierResearchOutput] |
| 64 | instructions: str |
| 65 | |
| 66 | |
| 67 | def build_supplier_research_agent( |
| 68 | application: CommerceApplication, |
| 69 | *, |
| 70 | request_id: str, |
| 71 | idempotency_key: str, |
| 72 | approval: ApprovalGrant | None, |
| 73 | recorder: PurchaseResultRecorder, |
| 74 | now: datetime | None = None, |
| 75 | ) -> ScriptedSupplierAgent: |
| 76 | """Create a fresh agent whose only economic tool is application-bound.""" |
| 77 | |
| 78 | tool = build_x402_fetch_tool( |
| 79 | application, |
| 80 | request_id=request_id, |
| 81 | idempotency_key=idempotency_key, |
| 82 | approval=approval, |
| 83 | on_purchase=recorder.record, |
| 84 | now=now, |
| 85 | ) |
| 86 | return ScriptedSupplierAgent( |
| 87 | name="Synthetic supplier research agent", |
| 88 | tools=[tool], |
| 89 | output_type=SupplierResearchOutput, |
| 90 | instructions=( |
| 91 | "Use x402_fetch exactly once for the requested paid resource. " |
| 92 | "The application—not you—owns merchant policy, budgets, human " |
| 93 | "approval, payment execution, receipts, and audit state. Copy " |
| 94 | "only facts returned by the tool. Never claim success after a " |
| 95 | "denial, and never invent a report or receipt." |
| 96 | ), |
| 97 | ) |
| 98 | |
| 99 | |
| 100 | def validate_supplier_research_output( |
| 101 | output: SupplierResearchOutput, |
| 102 | recorder: PurchaseResultRecorder, |
| 103 | ) -> PurchaseResult: |
| 104 | """Fail closed unless one tool purchase supports every returned field.""" |
| 105 | |
| 106 | if len(recorder.results) != 1: |
| 107 | raise AgentResultInvalid( |
| 108 | "purchase_count_invalid", |
| 109 | "A valid agent result requires exactly one completed purchase.", |
| 110 | ) |
| 111 | |
| 112 | purchase = recorder.results[0] |
| 113 | expected = { |
| 114 | "status": purchase.status, |
| 115 | "report_id": purchase.report.report_id, |
| 116 | "supplier": purchase.report.supplier, |
| 117 | "signals": purchase.report.signals, |
| 118 | "disclaimer": purchase.report.disclaimer, |
| 119 | "receipt_id": purchase.receipt.receipt_id, |
| 120 | "amount": str(purchase.receipt.amount), |
| 121 | "currency": purchase.receipt.currency, |
| 122 | "requires_human_approval": (purchase.authorization.requires_human_approval), |
| 123 | } |
| 124 | actual = output.model_dump() |
| 125 | mismatches = sorted( |
| 126 | field_name |
| 127 | for field_name, expected_value in expected.items() |
| 128 | if actual[field_name] != expected_value |
| 129 | ) |
| 130 | if mismatches: |
| 131 | raise AgentResultInvalid( |
| 132 | "agent_output_mismatch", |
| 133 | "Agent output did not match application evidence for: " |
| 134 | + ", ".join(mismatches), |
| 135 | ) |
| 136 | return purchase |
| 137 | |
| 138 | |
| 139 | def run_supplier_research( |
| 140 | application: CommerceApplication, |
| 141 | *, |
| 142 | request_id: str, |
| 143 | idempotency_key: str, |
| 144 | approval: ApprovalGrant | None, |
| 145 | resource_url: str = DEFAULT_RESOURCE_URL, |
| 146 | purpose: str = DEFAULT_PURPOSE, |
| 147 | now: datetime | None = None, |
| 148 | ) -> SupplierResearchRun: |
| 149 | """Run a deterministic one-tool loop and validate against app evidence. |
| 150 | |
| 151 | This replaces the OpenAI Agents SDK + scripted Model path from the |
| 152 | upstream cookbook so Omnipay learners need only pydantic + httpx + pytest. |
| 153 | """ |
| 154 | |
| 155 | recorder = PurchaseResultRecorder() |
| 156 | agent = build_supplier_research_agent( |
| 157 | application, |
| 158 | request_id=request_id, |
| 159 | idempotency_key=idempotency_key, |
| 160 | approval=approval, |
| 161 | recorder=recorder, |
| 162 | now=now, |
| 163 | ) |
| 164 | tool = agent.tools[0] |
| 165 | raw = tool(resource_url, purpose) |
| 166 | evidence = json.loads(raw) |
| 167 | if evidence.get("status") != "completed": |
| 168 | raise AgentResultInvalid( |
| 169 | "tool_denied", |
| 170 | "The scripted agent received a denial instead of purchase evidence.", |
| 171 | ) |
| 172 | report = evidence["report"] |
| 173 | output = SupplierResearchOutput( |
| 174 | status=evidence["status"], |
| 175 | report_id=report["report_id"], |
| 176 | supplier=report["supplier"], |
| 177 | signals=tuple(report["signals"]), |
| 178 | disclaimer=report["disclaimer"], |
| 179 | receipt_id=evidence["receipt_id"], |
| 180 | amount=str(evidence["amount"]), |
| 181 | currency=evidence["currency"], |
| 182 | requires_human_approval=evidence["requires_human_approval"], |
| 183 | ) |
| 184 | purchase = validate_supplier_research_output(output, recorder) |
| 185 | return SupplierResearchRun(output=output, purchase=purchase) |