fix(customer): never send an unsubstituted placeholder or a fake customer id
With DISPATCH_AGENT autonomous=true in the agent registry, the assignment-failure
path sends a real customer message. It would have sent
"Delay alert: Order 36 may arrive later than expected. New ETA: {new_eta}"
with customer_id "unknown": the DELAYED template names {new_eta} but DispatchAgent
supplied only order_id, and it has no customer id to give.
- _send_via_channel refuses to send when any {placeholder} survives substitution
(or the template is missing), logs which variables the caller omitted, and
records a failed notification. Silence beats a broken message.
- customer_id is omitted from the payload when it is absent or "unknown" rather
than sent literally; the backend resolves the recipient from order_id.
- DispatchAgent supplies new_eta "being confirmed" — honest, since no ETA exists
at assignment-failure time.
- Tests: the old bug is refused, a complete payload renders clean, the id is
omitted/passed correctly, and DispatchAgent's vars satisfy the template.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012AJLYcbTHCe45fyFnMfEin
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
"""Customer Agent - Handles notifications, tracking, and customer communication."""
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any, Optional
|
||||
@@ -14,6 +15,9 @@ from core.http_client import api_post, api_get
|
||||
from core.registry import registry
|
||||
from config.system_config import GO_API_BASE_URL, INTERNAL_API_KEY
|
||||
|
||||
# Any {placeholder} left after template substitution — a caller forgot a variable.
|
||||
_UNFILLED_PLACEHOLDER = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}")
|
||||
|
||||
|
||||
class NotificationChannel(str, Enum):
|
||||
SMS = "sms"
|
||||
@@ -360,6 +364,30 @@ class CustomerAgent(SpecializedAgent):
|
||||
"action": "provide_options",
|
||||
}
|
||||
|
||||
def _failed_notification(self, order_id, customer_id, channel, notification_type,
|
||||
reason: str, message: str) -> Dict[str, Any]:
|
||||
"""A not-sent result in the same shape as a sent one, recorded for audit."""
|
||||
notification_id = f"NOTIF-{uuid.uuid4().hex[:8].upper()}"
|
||||
key = customer_id or "unknown"
|
||||
self._notifications.setdefault(key, []).append(Notification(
|
||||
notification_id=notification_id,
|
||||
order_id=order_id,
|
||||
customer_id=key,
|
||||
channel=channel,
|
||||
notification_type=notification_type,
|
||||
message=message,
|
||||
sent_at=datetime.now(),
|
||||
delivered_at=None,
|
||||
status="failed",
|
||||
))
|
||||
return {
|
||||
"notification_id": notification_id,
|
||||
"channel": channel.value,
|
||||
"status": "failed",
|
||||
"reason": reason,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
async def _send_via_channel(
|
||||
self,
|
||||
order_id: str,
|
||||
@@ -368,24 +396,51 @@ class CustomerAgent(SpecializedAgent):
|
||||
notification_type: NotificationType,
|
||||
template_vars: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""Render template and call Go backend (POST /api/v1/internal/notify)."""
|
||||
"""Render template and call Go backend (POST /api/v1/internal/notify).
|
||||
|
||||
Refuses to send a message that still contains an unsubstituted
|
||||
{placeholder}: a caller that omits a template variable would otherwise
|
||||
put the literal text in front of a customer. Silence is better than a
|
||||
broken message, and the ERROR names the caller's missing variables."""
|
||||
template = self._templates.get(notification_type, {}).get(channel.value, "")
|
||||
if not template:
|
||||
logger.error(
|
||||
f"Customer Agent: no {channel.value} template for {notification_type.value}; not sending"
|
||||
)
|
||||
return self._failed_notification(order_id, customer_id, channel, notification_type,
|
||||
"no template", "")
|
||||
|
||||
message = template
|
||||
for key, value in template_vars.items():
|
||||
message = message.replace(f"{{{key}}}", str(value))
|
||||
|
||||
missing = _UNFILLED_PLACEHOLDER.findall(message)
|
||||
if missing:
|
||||
logger.error(
|
||||
f"Customer Agent: refusing to send {notification_type.value} for order {order_id} — "
|
||||
f"template variables not supplied: {missing}"
|
||||
)
|
||||
return self._failed_notification(order_id, customer_id, channel, notification_type,
|
||||
f"unfilled template variables: {missing}", message)
|
||||
|
||||
notification_id = f"NOTIF-{uuid.uuid4().hex[:8].upper()}"
|
||||
|
||||
payload = {
|
||||
"notification_id": notification_id,
|
||||
"order_id": order_id,
|
||||
"channel": channel.value,
|
||||
"notification_type": notification_type.value,
|
||||
"message": message,
|
||||
}
|
||||
# Omit customer_id rather than sending a placeholder: callers that only
|
||||
# know the booking (e.g. DispatchAgent on an assignment failure) leave the
|
||||
# backend to resolve the recipient from order_id, which it can already do.
|
||||
if customer_id and str(customer_id) != "unknown":
|
||||
payload["customer_id"] = customer_id
|
||||
|
||||
result = await api_post(
|
||||
f"{GO_API_BASE_URL}/api/v1/internal/notify",
|
||||
json={
|
||||
"notification_id": notification_id,
|
||||
"order_id": order_id,
|
||||
"customer_id": customer_id,
|
||||
"channel": channel.value,
|
||||
"notification_type": notification_type.value,
|
||||
"message": message,
|
||||
},
|
||||
json=payload,
|
||||
headers={"X-Internal-Key": INTERNAL_API_KEY},
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
)
|
||||
@@ -407,11 +462,13 @@ class CustomerAgent(SpecializedAgent):
|
||||
delivered_at=None,
|
||||
status=status,
|
||||
)
|
||||
if customer_id not in self._notifications:
|
||||
self._notifications[customer_id] = []
|
||||
self._notifications[customer_id].append(notification)
|
||||
history_key = customer_id or "unknown"
|
||||
self._notifications.setdefault(history_key, []).append(notification)
|
||||
|
||||
logger.info(f"[{channel.value.upper()}] {notification_type.value} -> {customer_id[:20]} [{status}]")
|
||||
logger.info(
|
||||
f"[{channel.value.upper()}] {notification_type.value} -> "
|
||||
f"{str(history_key)[:20]} [{status}]"
|
||||
)
|
||||
|
||||
return {
|
||||
"notification_id": notification_id,
|
||||
|
||||
@@ -474,11 +474,15 @@ class DispatchAgent(SpecializedAgent):
|
||||
recipient="CUSTOMER_AGENT",
|
||||
message_type=MessageType.AGENT_TASK,
|
||||
payload={
|
||||
# CUSTOMER_AGENT's real task contract (send_notification + DELAYED template)
|
||||
# CUSTOMER_AGENT's real task contract (send_notification + DELAYED
|
||||
# template). Every variable the template names must be supplied or
|
||||
# the send is refused; we have no ETA at assignment-failure time,
|
||||
# so say so rather than inventing one. No customer_id: the event
|
||||
# carries none, and the backend resolves the recipient from order_id.
|
||||
"task_type": "send_notification",
|
||||
"order_id": booking_id,
|
||||
"notification_type": "delayed",
|
||||
"template_vars": {"order_id": booking_id, "reason": "finding the right rider"},
|
||||
"template_vars": {"order_id": booking_id, "new_eta": "being confirmed"},
|
||||
},
|
||||
correlation_id=str(booking_id),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user