handoff fixes

This commit is contained in:
debu-2 2026-10-06 03:14:53 +05:30
parent 89ddebdb60
commit 8d5e9712a6
20 changed files with 1056 additions and 141 deletions

View file

@ -5,18 +5,37 @@ from datetime import datetime, timezone
from bson import ObjectId
from app.services.external_reply_service import (
ExternalReplyThreadingError,
build_external_reply_plan,
internet_message_id_of,
log_original_external_message,
resolve_external_reply_parent,
resolve_internet_message_id,
send_external_reply,
)
from app.services.handoff_email_templates import (
build_relay_confirmation_text,
external_reply_subject,
handoff_email_subject,
)
from app.services.handoff_mirror_policy import (
GENERATED_BY_HANDOFF_BRIDGE,
GENERATED_BY_HUMAN,
GENERATED_BY_SYSTEM,
MESSAGE_ORIGIN_HANDOFF_MIRROR,
MESSAGE_ORIGIN_HUMAN_OWNER,
RELAY_STATUS_FAILED,
RELAY_STATUS_SENT,
should_mirror_to_human,
)
from app.services.handoff_mirror import load_mirror_payload, load_mirror_update_payload
from app.services.handoff_evaluator import evaluate_handoff
from app.services.mail_sender import deliver_email
from app.services.message_ids import normalize_message_id, parse_message_id_list
from app.services.message_ids import (
canonical_message_id,
folded_message_id,
message_id_fields,
parse_message_id_list,
)
from app.services.reply_extractor import extract_new_reply_content, validate_external_reply_body
from app.services.thread_service import (
attach_message_to_thread,
@ -226,9 +245,13 @@ async def resolve_handoff_from_inbound(
return None
for internet_id in reversed(parse_message_id_list(" ".join(search_ids))):
folded = folded_message_id(internet_id)
message = await database.messages.find_one(
{
"message_id": internet_id,
"$or": [
{"message_id_folded": folded},
{"message_id": internet_id},
],
"message_scope": MESSAGE_SCOPE_HANDOFF,
}
)
@ -256,6 +279,26 @@ async def resolve_handoff_from_inbound(
return None
async def _handoff_mirror_exists(database, external_message_id: ObjectId) -> bool:
existing = await database.messages.find_one(
{
"mirrored_from_external_message_id": external_message_id,
"message_scope": MESSAGE_SCOPE_HANDOFF,
},
{"_id": 1},
)
return existing is not None
async def _bridged_external_exists(database, handoff_message_id: ObjectId) -> dict | None:
return await database.messages.find_one(
{
"generated_from_handoff_message_id": handoff_message_id,
"message_scope": MESSAGE_SCOPE_EXTERNAL,
}
)
async def store_handoff_message(
database,
*,
@ -277,6 +320,11 @@ async def store_handoff_message(
raw_body: str | None = None,
new_reply_content: str | None = None,
body_html: str | None = None,
message_origin: str | None = None,
generated_by: str | None = None,
mirrored_from_external_message_id: ObjectId | None = None,
relay_status: str | None = None,
generated_external_message_id: ObjectId | None = None,
) -> dict:
now = event_at or _now()
display_body = new_reply_content if new_reply_content is not None else body_text
@ -297,11 +345,20 @@ async def store_handoff_message(
"sent_at": now if direction == "outbound" else None,
"received_at": now if direction == "inbound" else None,
"status": "received" if direction == "inbound" else "accepted",
"message_id": normalize_message_id(internet_message_id),
"internet_message_id": normalize_message_id(internet_message_id),
"in_reply_to": normalize_message_id(in_reply_to),
**message_id_fields(internet_message_id),
"in_reply_to": canonical_message_id(in_reply_to),
"references": references or [],
}
if message_origin:
document["message_origin"] = message_origin
if generated_by:
document["generated_by"] = generated_by
if mirrored_from_external_message_id:
document["mirrored_from_external_message_id"] = mirrored_from_external_message_id
if relay_status:
document["relay_status"] = relay_status
if generated_external_message_id:
document["generated_external_message_id"] = generated_external_message_id
result = await database.messages.insert_one(document)
document["_id"] = result.inserted_id
await attach_message_to_thread(
@ -357,6 +414,23 @@ async def send_handoff_to_human(
domain,
existing_handoff: dict | None = None,
) -> dict:
if await _handoff_mirror_exists(database, external_message["_id"]):
logger.info(
"handoff_mirror_skipped_duplicate external_message=%s",
external_message["_id"],
)
if existing_handoff:
return existing_handoff
existing = await database.human_handoffs.find_one(
{
"account_id": account_id,
"external_thread_id": external_thread_id,
}
)
if existing:
return existing
raise RuntimeError("Handoff mirror duplicate without handoff record")
external_subject = external_message.get("subject", "")
stable_subject = handoff_email_subject(external_subject)
mirror_source = {**external_message, "_agent_email": agent_email}
@ -394,6 +468,8 @@ async def send_handoff_to_human(
"updated_at": _now(),
"last_external_message_at": message_event_at(external_message),
"last_human_message_at": None,
"trigger_external_message_id": external_message["_id"],
"trigger_internet_message_id": internet_message_id_of(external_message),
}
insert = await database.human_handoffs.insert_one(handoff_doc)
handoff_id = insert.inserted_id
@ -431,6 +507,9 @@ async def send_handoff_to_human(
handoff_id=handoff_id,
link_relationship=relationship,
external_message_id=external_message["_id"],
message_origin=MESSAGE_ORIGIN_HANDOFF_MIRROR,
generated_by=GENERATED_BY_SYSTEM,
mirrored_from_external_message_id=external_message["_id"],
)
await database.human_handoffs.update_one(
@ -504,7 +583,8 @@ async def process_human_reply(
in_reply_to=inbound_parsed.get("in_reply_to"),
references=inbound_parsed.get("references", []),
handoff_id=handoff["_id"],
link_relationship=LINK_HUMAN_TRIGGERED_EXTERNAL,
message_origin=MESSAGE_ORIGIN_HUMAN_OWNER,
generated_by=GENERATED_BY_HUMAN,
)
now = _now()
@ -550,22 +630,68 @@ async def process_human_reply(
return
external_recipient = handoff.get("external_sender") or ""
external_parent = await _latest_external_customer_message(
external_parent = await resolve_external_reply_parent(
database,
account_id,
external_thread_id,
external_recipient,
account_id=account_id,
external_thread_id=external_thread_id,
customer_address=external_recipient,
handoff=handoff,
)
if external_parent is None:
external_parent = await _latest_external_message(database, account_id, external_thread_id)
if external_parent is None:
logger.warning("handoff_reply_missing_external_parent handoff_id=%s", handoff["_id"])
logger.warning(
"handoff_reply_missing_external_parent handoff_id=%s external_thread=%s "
"trigger_internet_message_id=%s",
handoff["_id"],
external_thread_id,
handoff.get("trigger_internet_message_id"),
)
return
external_recipient = external_recipient or external_parent.get("sender", {}).get("address")
parent_sender = (external_parent.get("sender") or {}).get("address")
log_original_external_message(
internal_id=external_parent["_id"],
thread_id=external_thread_id,
internet_message_id=internet_message_id_of(external_parent),
sender=parent_sender,
recipient=agent_email,
subject=external_parent.get("subject"),
in_reply_to=external_parent.get("in_reply_to"),
references=external_parent.get("references"),
raw_message_available=bool(external_parent.get("raw_mime")),
)
external_recipient = external_recipient or parent_sender
if not external_recipient:
return
parent_internet_id = await resolve_internet_message_id(database, external_parent)
if not parent_internet_id:
logger.error(
"handoff_reply_missing_parent_message_id handoff_id=%s parent_internal_id=%s",
handoff["_id"],
external_parent["_id"],
)
return
logger.info(
"handoff_external_reply_threading handoff_id=%s parent_internal_id=%s "
"parent_internet_message_id=%s human_in_reply_to=%s human_references=%s",
handoff["_id"],
external_parent["_id"],
parent_internet_id,
human_message.get("in_reply_to"),
human_message.get("references"),
)
existing_bridge = await _bridged_external_exists(database, human_message["_id"])
if existing_bridge:
logger.info(
"handoff_bridge_already_processed handoff_message=%s external=%s",
human_message["_id"],
existing_bridge["_id"],
)
return
customer_body = new_reply_content
reply_plan = build_external_reply_plan(
parent_external_message=external_parent,
@ -574,62 +700,87 @@ async def process_human_reply(
handoff.get("external_subject") or external_parent.get("subject", "")
),
recipients=[external_recipient],
parent_internet_id_override=parent_internet_id,
)
external_doc = await send_external_reply(
database,
account_id=account_id,
sender=agent_email,
sender_name="Gentic Agent",
external_thread_id=external_thread_id,
plan=reply_plan,
domain=domain,
resolution_method="human_handoff_bridge",
logger.info(
"handoff_external_reply_headers handoff_id=%s b_in_reply_to=%s b_references=%s",
handoff["_id"],
reply_plan.threading.in_reply_to,
reply_plan.threading.references,
)
try:
external_doc = await send_external_reply(
database,
account_id=account_id,
sender=agent_email,
sender_name="Gentic Agent",
external_thread_id=external_thread_id,
plan=reply_plan,
domain=domain,
resolution_method="human_handoff_bridge",
generated_by=GENERATED_BY_HANDOFF_BRIDGE,
generated_from_handoff_message_id=human_message["_id"],
)
except ExternalReplyThreadingError:
await database.messages.update_one(
{"_id": human_message["_id"]},
{"$set": {"relay_status": RELAY_STATUS_FAILED, "relay_failed_at": now}},
)
logger.error(
"handoff_bridge_threading_rejected handoff_id=%s handoff_message=%s "
"parent_internet_message_id=%s",
handoff["_id"],
human_message["_id"],
parent_internet_id,
)
return
except Exception:
await database.messages.update_one(
{"_id": human_message["_id"]},
{"$set": {"relay_status": RELAY_STATUS_FAILED, "relay_failed_at": now}},
)
logger.exception(
"handoff_bridge_send_failed handoff_id=%s handoff_message=%s",
handoff["_id"],
human_message["_id"],
)
raise
relay_body = build_relay_confirmation_text(customer_body)
relay_subject = _handoff_reply_subject(handoff.get("external_subject", ""))
relay_threading = threading_fields_from_parent(human_message)
relay_internet_id = await deliver_email(
sender=agent_email,
recipients=[human_email],
cc=[],
bcc=[],
subject=relay_subject,
body_text=relay_body,
body_html=None,
domain=domain,
in_reply_to=relay_threading.get("in_reply_to"),
references=relay_threading.get("references"),
await database.messages.update_one(
{"_id": human_message["_id"]},
{
"$set": {
"relay_status": RELAY_STATUS_SENT,
"generated_external_message_id": external_doc["_id"],
"relay_delivered_at": now,
}
},
)
await store_handoff_message(
await _record_message_link(
database,
account_id=account_id,
agent_email=agent_email,
handoff_thread_id=handoff_thread_id,
direction="outbound",
sender={"name": "Gentic Agent", "address": agent_email},
recipients=[{"name": human_email.split("@")[0], "address": human_email}],
subject=relay_subject,
body_text=relay_body,
internet_message_id=relay_internet_id,
in_reply_to=relay_threading.get("in_reply_to"),
references=relay_threading.get("references"),
handoff_id=handoff["_id"],
link_relationship=LINK_EXTERNAL_RELAYED,
external_message_id=ext_result.inserted_id,
external_message_id=external_doc["_id"],
handoff_message_id=human_message["_id"],
relationship=LINK_HUMAN_TRIGGERED_EXTERNAL,
)
await database.human_handoffs.update_one(
{"_id": handoff["_id"]},
{
"$set": {
"status": HANDOFF_STATUS_AGENT_REPLIED,
"current_owner": OWNER_AGENT,
"status": HANDOFF_STATUS_WAITING,
"current_owner": OWNER_HUMAN,
"updated_at": now,
"last_human_message_at": now,
}
},
)
logger.info(
"handoff_bridge_complete handoff_id=%s handoff_message=%s external=%s",
handoff["_id"],
human_message["_id"],
external_doc["_id"],
)
async def maybe_trigger_handoff(
@ -641,6 +792,40 @@ async def maybe_trigger_handoff(
external_thread_id: ObjectId,
domain,
) -> None:
if not should_mirror_to_human(external_message):
logger.debug(
"handoff_mirror_skipped message_id=%s generated_by=%s origin=%s direction=%s",
external_message.get("_id"),
external_message.get("generated_by"),
external_message.get("message_origin"),
external_message.get("direction"),
)
return
existing = await find_active_handoff_for_external_thread(
database, account_id, external_thread_id
)
if existing:
if await _handoff_mirror_exists(database, external_message["_id"]):
return
rule = await _handoff_rule_for_human(
database, account_id, existing.get("human_email", "")
)
if rule and not rule.get("allow_replies", True):
return
await send_handoff_to_human(
database,
account_id=account_id,
agent_email=agent_email,
human_email=existing["human_email"],
external_message=external_message,
external_thread_id=external_thread_id,
handoff_reason=existing.get("handoff_reason", ""),
domain=domain,
existing_handoff=existing,
)
return
rules = await database.human_handoff_rules.find(
{"account_id": account_id, "active": True}
).to_list(length=20)
@ -667,9 +852,6 @@ async def maybe_trigger_handoff(
if not rule.get("allow_replies", True):
continue
existing = await find_active_handoff_for_external_thread(
database, account_id, external_thread_id
)
reason = (rule.get("instructions") or "").strip() or "This conversation requires human attention."
await send_handoff_to_human(
database,
@ -680,6 +862,6 @@ async def maybe_trigger_handoff(
external_thread_id=external_thread_id,
handoff_reason=reason,
domain=domain,
existing_handoff=existing,
existing_handoff=None,
)
return