Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions deeplyagentic/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""RiffDesk's agent-based customer support application."""
17 changes: 17 additions & 0 deletions deeplyagentic/api.py
Original file line number Diff line number Diff line change
@@ -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,
)
86 changes: 18 additions & 68 deletions deeplyagentic/main.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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()

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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."
Expand All @@ -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,
)

Expand Down Expand Up @@ -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"))

Expand Down
6 changes: 6 additions & 0 deletions deeplyagentic/subagents/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
19 changes: 19 additions & 0 deletions deeplyagentic/subagents/purchase_specialist.py
Original file line number Diff line number Diff line change
@@ -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],
}
25 changes: 25 additions & 0 deletions deeplyagentic/subagents/refund_specialist.py
Original file line number Diff line number Diff line change
@@ -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"]},
},
}
52 changes: 52 additions & 0 deletions deeplyagentic/subagents/tools.py
Original file line number Diff line number Diff line change
@@ -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())
20 changes: 14 additions & 6 deletions riffdesk/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ class InvoiceDetail(Invoice):


class RefundRequestIn(BaseModel):
customer_id: int
invoice_id: int
reason: str

Expand Down Expand Up @@ -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(
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
"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 "
Expand Down Expand Up @@ -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(
Expand Down