Zainab4626's picture
Implement loop review pipeline, tool filtering, evals, and support-system A2A.
c89a66c
Raw
History Blame Contribute Delete
6.2 kB
"""Billing specialist agent: ADK ``LlmAgent`` + Supabase MCP tool functions (same impl as MCP server)."""
from __future__ import annotations
import json
import os
from typing import Any
from google.adk.agents.llm_agent import LlmAgent
from google.adk.tools.function_tool import FunctionTool
from src.mcp.supabase_mcp_server import get_billing_info, get_support_tickets
from .adk_runtime import genai_api_configured, looks_like_genai_quota_error, run_llm_agent_once
from .customer_context import resolve_customer_email
from .tool_filter import ensure_tool_allowed
ALLOWED_MCP_TOOLS = {"get_billing_info", "get_support_tickets"}
def _call_allowed_tool(tool_name: str, email: str) -> str:
"""
Guard and invoke a read-only MCP-parity tool by name.
This is used by both the offline fallback path and the ADK tool wrappers so allowlist
enforcement is consistent regardless of runtime mode.
"""
ensure_tool_allowed(tool_name, ALLOWED_MCP_TOOLS)
if tool_name == "get_billing_info":
return get_billing_info(email)
if tool_name == "get_support_tickets":
return get_support_tickets(email)
raise ValueError(f"Unsupported tool mapping for {tool_name!r}")
def _get_billing_info_guarded(email: str) -> str:
return _call_allowed_tool("get_billing_info", email)
def _get_support_tickets_guarded(email: str) -> str:
return _call_allowed_tool("get_support_tickets", email)
def _billing_tools() -> list[Any]:
"""Expose MCP-parity tools to the LLM via ADK ``FunctionTool`` wrappers."""
return [
FunctionTool(_get_billing_info_guarded),
FunctionTool(_get_support_tickets_guarded),
]
_BILLING_INSTRUCTION = """You are a billing assistant for an e-commerce company.
You have tools that mirror the Supabase MCP server:
- ``get_billing_info(email)`` — JSON with customer orders (order_number, total_amount, status).
- ``get_support_tickets(email)`` — JSON list of support tickets.
Rules:
1. The user message includes the **resolved customer email**. Always pass that exact email string to tools.
2. Call the tools when you need factual data; do not invent amounts or order numbers.
3. Reply in concise, friendly natural language summarizing billing status and any open billing-related tickets.
4. If JSON shows ``customer_not_found``, say we could not match an account and ask them to verify their email.
"""
class BillingAgent:
"""
Handles billing-related questions using Gemini + tool calls.
Tools implement the same behavior as ``src/mcp/supabase_mcp_server.py`` (stdio MCP is optional;
in-process calls keep tests and local dev simple).
"""
def __init__(self, model: str | None = None) -> None:
self._model = model or os.getenv("ADK_MODEL", "gemini-2.5-flash")
self._agent = LlmAgent(
name="billing_agent",
model=self._model,
instruction=_BILLING_INSTRUCTION,
tools=_billing_tools(),
)
async def handle(self, customer_id: str, message: str) -> str:
"""
Answer a billing question for ``customer_id`` (UUID or email) and user ``message``.
Uses the LLM when ``GOOGLE_API_KEY`` / ``GEMINI_API_KEY`` is set; otherwise returns a
deterministic summary from the same tool functions.
"""
email = resolve_customer_email(customer_id)
if not email:
return (
"We could not resolve an email address for this customer id. "
"Please provide a customer id that exists in our system or use your account email."
)
if not genai_api_configured():
billing_json = _call_allowed_tool("get_billing_info", email)
tickets_json = _call_allowed_tool("get_support_tickets", email)
return _format_billing_fallback(billing_json, tickets_json, message)
user_prompt = (
f"Resolved customer email (use for tool calls): {email}\n"
f"Customer id (reference): {customer_id}\n\n"
f"User question:\n{message}\n"
)
try:
return await run_llm_agent_once(
agent=self._agent,
user_message=user_prompt,
app_name="billing",
)
except Exception as exc:
if looks_like_genai_quota_error(exc):
billing_json = _call_allowed_tool("get_billing_info", email)
tickets_json = _call_allowed_tool("get_support_tickets", email)
base = _format_billing_fallback(billing_json, tickets_json, message)
return (
f"{base}\n\n"
"[Note] Gemini quota or rate limit was hit; this summary was built "
"directly from billing tools without the LLM."
)
raise
def _format_billing_fallback(billing_json: str, tickets_json: str, message: str) -> str:
"""Readable summary without an LLM (offline / CI)."""
try:
billing = json.loads(billing_json)
tickets_payload = json.loads(tickets_json)
except json.JSONDecodeError:
return "[BillingAgent] Unable to parse billing data."
lines = [
"[BillingAgent — offline summary]",
f'Your question: "{message[:200]}"',
"",
]
if billing.get("error") == "customer_not_found":
lines.append("No customer record found for that email.")
return "\n".join(lines)
cust = billing.get("customer") or {}
lines.append(f"Customer: {cust.get('name', 'Unknown')} ({cust.get('email', '')})")
orders = billing.get("orders") or []
if not orders:
lines.append("No orders on file.")
else:
lines.append(f"Orders ({len(orders)}):")
for o in orders[:10]:
lines.append(
f" - {o.get('order_number')}: "
f"amount={o.get('total_amount')}, status={o.get('status')}"
)
if len(orders) > 10:
lines.append(f" … and {len(orders) - 10} more.")
tickets = tickets_payload.get("tickets") or []
lines.append("")
lines.append(f"Support tickets on file: {len(tickets)}.")
return "\n".join(lines)