From f3b4069ae04c84dea31f51d8141e367abff81091 Mon Sep 17 00:00:00 2001 From: Sri Rang Date: Sat, 11 Jul 2026 10:32:47 +0200 Subject: [PATCH 1/4] Split support workflow into specialist agents --- README.md | 22 +++++++++- deeplyagentic/main.py | 99 ++++++++++++++++++++++++++++++++----------- riffdesk/main.py | 19 ++++++--- 3 files changed, 107 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 5c60367..c30ed34 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 riffdesk/main.py + +Then run the interactive support agent in another terminal: + + uv run deeplyagentic/main.py + +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/main.py b/deeplyagentic/main.py index 189cce6..92a9c99 100644 --- a/deeplyagentic/main.py +++ b/deeplyagentic/main.py @@ -1,19 +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 file: uv run deeplyagentic/main.py customer id: 1 email: luisg@embraer.com.br """ import os +from uuid import uuid4 import httpx import questionary @@ -132,50 +133,98 @@ def query_invoices(customer_id: int, limit: int = 10) -> str: # --- Tool 3: invoice line-item detail --- @tool -def get_invoice_detail(invoice_id: int) -> str: +def get_invoice_detail(customer_id: int, 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.""" + The invoice must belong to the verified customer. Use this to confirm + exactly what's being refunded before filing a refund.""" with _client() as client: - resp = client.get(f"/invoices/{invoice_id}") + resp = client.get( + f"/invoices/{invoice_id}", params={"customer_id": customer_id} + ) if resp.status_code == 404: - return f"No invoice found with ID {invoice_id}." + return f"Invoice {invoice_id} does not belong to customer {customer_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: +def request_refund(customer_id: int, invoice_id: int, reason: str) -> str: """File a refund request for a given invoice. Requires human approval - before it takes effect.""" + before it takes effect. The invoice must belong to the verified customer.""" with _client() as client: - resp = client.post("/refunds", json={"invoice_id": invoice_id, "reason": reason}) + resp = client.post( + "/refunds", + json={ + "customer_id": customer_id, + "invoice_id": invoice_id, + "reason": reason, + }, + ) if resp.status_code == 404: - return f"No invoice found with ID {invoice_id}; refund not filed." + return ( + f"Invoice {invoice_id} does not belong to customer {customer_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 +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], +} + +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"]}, + }, +} + 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 +293,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/riffdesk/main.py b/riffdesk/main.py index b780fb7..9f39c37 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,18 @@ 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: 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 +219,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( From 3fc45f028df468635dea253600ace33d905d4e0b Mon Sep 17 00:00:00 2001 From: Sri Rang Date: Sat, 11 Jul 2026 10:46:50 +0200 Subject: [PATCH 2/4] Document customer-scoped invoice handler --- riffdesk/main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/riffdesk/main.py b/riffdesk/main.py index 9f39c37..6eafe1b 100644 --- a/riffdesk/main.py +++ b/riffdesk/main.py @@ -181,6 +181,7 @@ def get_invoice( 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 = ? AND CustomerId = ?", From a34d4ad91ac49c8b9d8b34503e486c72a862216d Mon Sep 17 00:00:00 2001 From: Sri Rang Date: Sat, 11 Jul 2026 10:58:57 +0200 Subject: [PATCH 3/4] Extract specialist subagents into modules --- README.md | 2 +- deeplyagentic/__init__.py | 1 + deeplyagentic/api.py | 17 +++ deeplyagentic/main.py | 107 +----------------- deeplyagentic/subagents/__init__.py | 6 + .../subagents/purchase_specialist.py | 19 ++++ deeplyagentic/subagents/refund_specialist.py | 25 ++++ deeplyagentic/subagents/tools.py | 52 +++++++++ 8 files changed, 125 insertions(+), 104 deletions(-) create mode 100644 deeplyagentic/__init__.py create mode 100644 deeplyagentic/api.py create mode 100644 deeplyagentic/subagents/__init__.py create mode 100644 deeplyagentic/subagents/purchase_specialist.py create mode 100644 deeplyagentic/subagents/refund_specialist.py create mode 100644 deeplyagentic/subagents/tools.py diff --git a/README.md b/README.md index c30ed34..3fd9fcd 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Run the API first: Then run the interactive support agent in another terminal: - uv run deeplyagentic/main.py + 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 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 92a9c99..94ae7f6 100644 --- a/deeplyagentic/main.py +++ b/deeplyagentic/main.py @@ -7,16 +7,14 @@ pauses the graph mid-execution and asks for them directly. Run riffdesk first: uv run riffdesk/main.py -Then run this file: uv run deeplyagentic/main.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 @@ -29,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() @@ -67,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: @@ -103,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." @@ -119,98 +109,9 @@ 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(customer_id: int, invoice_id: int) -> str: - """Get line-item detail (tracks, price, quantity) for a specific invoice. - The invoice must belong to the verified customer. Use this to confirm - exactly what's being refunded before filing a refund.""" - with _client() as client: - resp = client.get( - f"/invoices/{invoice_id}", params={"customer_id": customer_id} - ) - if resp.status_code == 404: - return f"Invoice {invoice_id} does not belong to customer {customer_id}." - resp.raise_for_status() - return str(resp.json()) - - -# --- Tool 4: refund request (write, sensitive -> gated via interrupt_on) --- -@tool -def request_refund(customer_id: int, invoice_id: int, reason: str) -> str: - """File a refund request for a given invoice. Requires human approval - before it takes effect. The invoice must belong to the verified customer.""" - with _client() as client: - resp = client.post( - "/refunds", - json={ - "customer_id": customer_id, - "invoice_id": invoice_id, - "reason": reason, - }, - ) - if resp.status_code == 404: - return ( - f"Invoice {invoice_id} does not belong to customer {customer_id}; " - "refund not filed." - ) - resp.raise_for_status() - return str(resp.json()) - - # --- Wire up the supervisor and specialists --- checkpointer = MemorySaver() # required for human-in-the-loop -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], -} - -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"]}, - }, -} - agent = create_deep_agent( model="anthropic:claude-sonnet-4-6", tools=[collect_customer_identity], 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()) From e69e67ca0cafb92e1d56f5f8be037784454b7873 Mon Sep 17 00:00:00 2001 From: Sri Rang Date: Mon, 20 Jul 2026 12:52:13 +0200 Subject: [PATCH 4/4] README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3fd9fcd..642556e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ The support supervisor verifies the customer's identity, then delegates to: Run the API first: - uv run riffdesk/main.py + uv run python -m riffdesk.main Then run the interactive support agent in another terminal: