diff --git a/README.md b/README.md index 5c60367..642556e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,23 @@ -Run riffdesk first: uv run riffdesk/main.py -Then run this file: uv run agent_tools.py +# OpenChain / RiffDesk + +A human-in-the-loop customer support demo for the Chinook music store data. + +The support supervisor verifies the customer's identity, then delegates to: + +- a read-only purchase specialist for invoice history and line-item details; +- an approval-gated refund specialist for refund requests. + +Run the API first: + + uv run python -m riffdesk.main + +Then run the interactive support agent in another terminal: + + uv run python -m deeplyagentic.main + +The API defaults to `http://127.0.0.1:8000` and the demo API key +`demo-secret-key`. Set `RIFFDESK_API_URL`, `RIFFDESK_API_KEY`, or `API_KEY` to +override these values. customer id: 1 email: luisg@embraer.com.br diff --git a/deeplyagentic/__init__.py b/deeplyagentic/__init__.py new file mode 100644 index 0000000..b841a8b --- /dev/null +++ b/deeplyagentic/__init__.py @@ -0,0 +1 @@ +"""RiffDesk's agent-based customer support application.""" diff --git a/deeplyagentic/api.py b/deeplyagentic/api.py new file mode 100644 index 0000000..de36ae1 --- /dev/null +++ b/deeplyagentic/api.py @@ -0,0 +1,17 @@ +"""Shared HTTP client configuration for the RiffDesk API.""" + +import os + +import httpx + +API_BASE_URL = os.environ.get("RIFFDESK_API_URL", "http://127.0.0.1:8000") +API_KEY = os.environ.get("RIFFDESK_API_KEY", "demo-secret-key") + + +def api_client() -> httpx.Client: + """Return a configured client for the RiffDesk API.""" + return httpx.Client( + base_url=API_BASE_URL, + headers={"X-API-Key": API_KEY}, + timeout=10.0, + ) diff --git a/deeplyagentic/main.py b/deeplyagentic/main.py index 189cce6..94ae7f6 100644 --- a/deeplyagentic/main.py +++ b/deeplyagentic/main.py @@ -1,21 +1,20 @@ """ -Agent tools for the RiffDesk support bot — these call the riffdesk REST API -(riffdesk/main.py) over HTTP instead of touching SQLite directly. +RiffDesk support supervisor and specialist agents. Their tools call the +riffdesk REST API (riffdesk/main.py) instead of touching SQLite directly. Identity is now collected via a real LangGraph interrupt: the customer_id and email are NOT in the initial prompt. The collect_customer_identity tool pauses the graph mid-execution and asks for them directly. Run riffdesk first: uv run riffdesk/main.py -Then run this file: uv run agent_tools.py +Then run this module: uv run python -m deeplyagentic.main customer id: 1 email: luisg@embraer.com.br """ -import os +from uuid import uuid4 -import httpx import questionary from deepagents import create_deep_agent from langchain.tools import tool @@ -28,8 +27,8 @@ from rich.markdown import Markdown from rich.panel import Panel -API_BASE_URL = os.environ.get("RIFFDESK_API_URL", "http://127.0.0.1:8000") -API_KEY = os.environ.get("RIFFDESK_API_KEY", "demo-secret-key") +from deeplyagentic.api import api_client +from deeplyagentic.subagents import purchase_specialist, refund_specialist console = Console() @@ -66,14 +65,6 @@ def validate(self, document): ) -def _client() -> httpx.Client: - return httpx.Client( - base_url=API_BASE_URL, - headers={"X-API-Key": API_KEY}, - timeout=10.0, - ) - - # --- Tool 1: identity collection via a direct interrupt() --- @tool def collect_customer_identity() -> str: @@ -102,7 +93,7 @@ def collect_customer_identity() -> str: # Turn pydantic's error into one plain-English line per field. error = "; ".join(f"{e['loc'][0]}: {e['msg']}" for e in exc.errors()) - with _client() as client: + with api_client() as client: resp = client.get(f"/customers/{identity.customer_id}") if resp.status_code == 404: return f"No customer found with ID {identity.customer_id}. Ask the customer to try again." @@ -118,64 +109,23 @@ def collect_customer_identity() -> str: ) -# --- Tool 2: order/invoice lookup (read-only) --- -@tool -def query_invoices(customer_id: int, limit: int = 10) -> str: - """Look up a verified customer's recent invoices (id, date, total).""" - with _client() as client: - resp = client.get(f"/customers/{customer_id}/invoices", params={"limit": limit}) - if resp.status_code == 404: - return f"No customer found with ID {customer_id}." - resp.raise_for_status() - return str(resp.json()) - - -# --- Tool 3: invoice line-item detail --- -@tool -def get_invoice_detail(invoice_id: int) -> str: - """Get line-item detail (tracks, price, quantity) for a specific invoice. - Use this to confirm exactly what's being refunded before filing a refund.""" - with _client() as client: - resp = client.get(f"/invoices/{invoice_id}") - if resp.status_code == 404: - return f"No invoice found with ID {invoice_id}." - resp.raise_for_status() - return str(resp.json()) - - -# --- Tool 4: refund request (write, sensitive -> gated via interrupt_on) --- -@tool -def request_refund(invoice_id: int, reason: str) -> str: - """File a refund request for a given invoice. Requires human approval - before it takes effect.""" - with _client() as client: - resp = client.post("/refunds", json={"invoice_id": invoice_id, "reason": reason}) - if resp.status_code == 404: - return f"No invoice found with ID {invoice_id}; refund not filed." - resp.raise_for_status() - return str(resp.json()) - - -# --- Wire up the agent --- +# --- Wire up the supervisor and specialists --- checkpointer = MemorySaver() # required for human-in-the-loop agent = create_deep_agent( model="anthropic:claude-sonnet-4-6", - tools=[collect_customer_identity, query_invoices, get_invoice_detail, request_refund], + tools=[collect_customer_identity], + subagents=[purchase_specialist, refund_specialist], system_prompt=( - "You are a music store support agent for RiffDesk. " + "You are the customer-facing support supervisor for RiffDesk. " "Always call collect_customer_identity FIRST, before anything else, " - "and only proceed to query_invoices, get_invoice_detail, or " - "request_refund once identity has been verified successfully. " - "Confirm the exact invoice with the customer before filing a refund." + "and do not delegate until it succeeds. Pass the verified customer_id " + "and all relevant customer context in every specialist task. Delegate " + "purchase-history and invoice-detail work to purchase-specialist. " + "Delegate refunds to refund-specialist. Do not attempt specialist work " + "yourself, and never substitute a different customer_id. Present each " + "specialist's result clearly to the customer." ), - interrupt_on={ - # collect_customer_identity is NOT listed here — it pauses via its - # own direct interrupt() call, independent of interrupt_on. - "query_invoices": False, - "get_invoice_detail": False, - "request_refund": {"allowed_decisions": ["approve", "reject"]}, - }, checkpointer=checkpointer, ) @@ -244,7 +194,7 @@ def _handle_interrupt_loop(result, config): if __name__ == "__main__": - config = {"configurable": {"thread_id": "demo-thread-1"}} + config = {"configurable": {"thread_id": f"demo-{uuid4()}"}} console.print(Panel("RiffDesk support bot — type 'exit' to quit.", style="bold green")) diff --git a/deeplyagentic/subagents/__init__.py b/deeplyagentic/subagents/__init__.py new file mode 100644 index 0000000..e7da181 --- /dev/null +++ b/deeplyagentic/subagents/__init__.py @@ -0,0 +1,6 @@ +"""Specialist sub-agents available to the RiffDesk supervisor.""" + +from deeplyagentic.subagents.purchase_specialist import purchase_specialist +from deeplyagentic.subagents.refund_specialist import refund_specialist + +__all__ = ["purchase_specialist", "refund_specialist"] diff --git a/deeplyagentic/subagents/purchase_specialist.py b/deeplyagentic/subagents/purchase_specialist.py new file mode 100644 index 0000000..9b71942 --- /dev/null +++ b/deeplyagentic/subagents/purchase_specialist.py @@ -0,0 +1,19 @@ +"""Read-only purchase-history specialist definition.""" + +from deeplyagentic.subagents.tools import get_invoice_detail, query_invoices + +purchase_specialist = { + "name": "purchase-specialist", + "description": ( + "Read-only specialist for listing a verified customer's invoices and " + "explaining the tracks, prices, and quantities on a specific invoice." + ), + "system_prompt": ( + "You are RiffDesk's purchase-history specialist. Use only the verified " + "customer_id supplied by the supervisor. Handle recent-purchase and " + "invoice-detail questions with the available read-only tools. Never " + "perform or promise a refund. Return a concise factual answer to the " + "supervisor." + ), + "tools": [query_invoices, get_invoice_detail], +} diff --git a/deeplyagentic/subagents/refund_specialist.py b/deeplyagentic/subagents/refund_specialist.py new file mode 100644 index 0000000..b8a9337 --- /dev/null +++ b/deeplyagentic/subagents/refund_specialist.py @@ -0,0 +1,25 @@ +"""Approval-gated refund specialist definition.""" + +from deeplyagentic.subagents.tools import get_invoice_detail, request_refund + +refund_specialist = { + "name": "refund-specialist", + "description": ( + "Specialist for confirming an owned invoice and filing a refund request " + "after explicit human approval." + ), + "system_prompt": ( + "You are RiffDesk's refund specialist. Use only the verified customer_id " + "supplied by the supervisor. First call get_invoice_detail to verify " + "ownership and confirm exactly what the invoice contains. Only call " + "request_refund when the customer's desired invoice and reason are " + "explicit. The request_refund tool is approval-gated. If ownership " + "cannot be verified, do not proceed. Return the final outcome to the " + "supervisor." + ), + "tools": [get_invoice_detail, request_refund], + "interrupt_on": { + "get_invoice_detail": False, + "request_refund": {"allowed_decisions": ["approve", "reject"]}, + }, +} diff --git a/deeplyagentic/subagents/tools.py b/deeplyagentic/subagents/tools.py new file mode 100644 index 0000000..7903ea8 --- /dev/null +++ b/deeplyagentic/subagents/tools.py @@ -0,0 +1,52 @@ +"""API-backed tools shared by the RiffDesk specialist sub-agents.""" + +from langchain.tools import tool + +from deeplyagentic.api import api_client + + +@tool +def query_invoices(customer_id: int, limit: int = 10) -> str: + """Look up a verified customer's recent invoices (id, date, total).""" + with api_client() as client: + response = client.get( + f"/customers/{customer_id}/invoices", params={"limit": limit} + ) + if response.status_code == 404: + return f"No customer found with ID {customer_id}." + response.raise_for_status() + return str(response.json()) + + +@tool +def get_invoice_detail(customer_id: int, invoice_id: int) -> str: + """Return track-level detail for an invoice owned by the customer.""" + with api_client() as client: + response = client.get( + f"/invoices/{invoice_id}", params={"customer_id": customer_id} + ) + if response.status_code == 404: + return f"Invoice {invoice_id} does not belong to customer {customer_id}." + response.raise_for_status() + return str(response.json()) + + +@tool +def request_refund(customer_id: int, invoice_id: int, reason: str) -> str: + """File an approval-gated refund request for an owned invoice.""" + with api_client() as client: + response = client.post( + "/refunds", + json={ + "customer_id": customer_id, + "invoice_id": invoice_id, + "reason": reason, + }, + ) + if response.status_code == 404: + return ( + f"Invoice {invoice_id} does not belong to customer {customer_id}; " + "refund not filed." + ) + response.raise_for_status() + return str(response.json()) diff --git a/riffdesk/main.py b/riffdesk/main.py index b780fb7..6eafe1b 100644 --- a/riffdesk/main.py +++ b/riffdesk/main.py @@ -99,6 +99,7 @@ class InvoiceDetail(Invoice): class RefundRequestIn(BaseModel): + customer_id: int invoice_id: int reason: str @@ -175,13 +176,19 @@ def list_invoices( response_model=InvoiceDetail, dependencies=[Depends(require_api_key)], ) -def get_invoice(invoice_id: int, db: sqlite3.Connection = Depends(get_db)) -> InvoiceDetail: +def get_invoice( + invoice_id: int, + customer_id: int = Query(...), + db: sqlite3.Connection = Depends(get_db), +) -> InvoiceDetail: + """Return invoice details when the invoice belongs to the customer.""" invoice = db.execute( - "SELECT InvoiceId, InvoiceDate, Total FROM Invoice WHERE InvoiceId = ?", - (invoice_id,), + "SELECT InvoiceId, InvoiceDate, Total FROM Invoice " + "WHERE InvoiceId = ? AND CustomerId = ?", + (invoice_id, customer_id), ).fetchone() if invoice is None: - raise HTTPException(status_code=404, detail="Invoice not found") + raise HTTPException(status_code=404, detail="Invoice not found for customer") lines = db.execute( "SELECT t.Name AS TrackName, il.UnitPrice, il.Quantity " @@ -213,10 +220,11 @@ def create_refund( body: RefundRequestIn, db: sqlite3.Connection = Depends(get_db) ) -> RefundRequestOut: invoice = db.execute( - "SELECT 1 FROM Invoice WHERE InvoiceId = ?", (body.invoice_id,) + "SELECT 1 FROM Invoice WHERE InvoiceId = ? AND CustomerId = ?", + (body.invoice_id, body.customer_id), ).fetchone() if invoice is None: - raise HTTPException(status_code=404, detail="Invoice not found") + raise HTTPException(status_code=404, detail="Invoice not found for customer") created_at = datetime.now(timezone.utc).isoformat() cur = db.execute(