handoff fixes

This commit is contained in:
debu-2 2026-10-06 03:24:57 +05:30
parent 83f0eba64d
commit 8653f319f7
9 changed files with 30 additions and 1309 deletions

View file

@ -8,18 +8,12 @@ from app.database import get_mongo_database
from app.models import Account from app.models import Account
from app.routers.auth import get_current_account from app.routers.auth import get_current_account
from app.schemas import ( from app.schemas import (
HandoffConversationRead,
HandoffConversationSummary,
HandoffLinkedExternal,
HandoffMessageRead,
HandoffRuleCreate, HandoffRuleCreate,
HandoffRuleRead, HandoffRuleRead,
HandoffRuleUpdate, HandoffRuleUpdate,
HandoffTestRequest, HandoffTestRequest,
HandoffTestResult, HandoffTestResult,
) )
from app.services.handoff_mirror_policy import is_relay_acknowledgment_message
from app.services.human_handoff_service import MESSAGE_SCOPE_HANDOFF
from app.services.handoff_evaluator import EMAIL_TYPE_META, HANDOFF_LABELS, evaluate_handoff from app.services.handoff_evaluator import EMAIL_TYPE_META, HANDOFF_LABELS, evaluate_handoff
router = APIRouter(prefix="/agent/handoff", tags=["agent-handoff"]) router = APIRouter(prefix="/agent/handoff", tags=["agent-handoff"])
@ -34,20 +28,6 @@ def _owner_name_from_email(email: str) -> str:
return re.sub(r"[._-]+", " ", local).strip().title() or email return re.sub(r"[._-]+", " ", local).strip().title() or email
def _status_label(status: str, current_owner: str) -> str:
if status == "WAITING_FOR_HUMAN":
return "Waiting for Human"
if status == "HUMAN_REPLIED":
return "Needs Agent Reply"
if status == "AGENT_REPLIED":
return "Replied"
if status == "CLOSED":
return "Closed"
if current_owner == "HUMAN":
return "Waiting for Human"
return "Active"
def _serialize(document: dict) -> HandoffRuleRead: def _serialize(document: dict) -> HandoffRuleRead:
return HandoffRuleRead( return HandoffRuleRead(
id=str(document["_id"]), id=str(document["_id"]),
@ -171,150 +151,15 @@ async def test_handoff(
return HandoffTestResult( return HandoffTestResult(
owner_email=owner_email, owner_email=owner_email,
**evaluation, **evaluation,
result_path="Agent → Human Owner" if evaluation["would_handoff"] else "Agent handles email", result_path=(
"Would match handoff rule (configuration only)"
if evaluation["would_handoff"]
else "Would not match handoff rule"
),
reason=evaluation["instruction_reason"], reason=evaluation["instruction_reason"],
) )
@router.get("/conversations", response_model=list[HandoffConversationSummary])
async def list_handoff_conversations(
database=Depends(get_mongo_database),
account: Account = Depends(get_current_account),
):
account_id = str(account.id)
cursor = database.human_handoffs.find({"account_id": account_id}).sort("updated_at", -1)
items: list[HandoffConversationSummary] = []
async for handoff in cursor:
handoff_thread_id = handoff["handoff_thread_id"]
count = await database.mailbox_entries.count_documents(
{
"account_id": account_id,
"thread_id": handoff_thread_id,
"message_scope": MESSAGE_SCOPE_HANDOFF,
}
)
latest = await database.mailbox_entries.find_one(
{
"account_id": account_id,
"thread_id": handoff_thread_id,
"message_scope": MESSAGE_SCOPE_HANDOFF,
},
sort=[("event_at", -1), ("_id", -1)],
)
snippet = latest.get("snippet", "") if latest else ""
items.append(
HandoffConversationSummary(
id=str(handoff["_id"]),
external_subject=handoff.get("external_subject", ""),
external_sender=handoff.get("external_sender", ""),
human_email=handoff.get("human_email", ""),
status=handoff.get("status", "ACTIVE"),
status_label=_status_label(
handoff.get("status", "ACTIVE"),
handoff.get("current_owner", "AGENT"),
),
current_owner=handoff.get("current_owner", "AGENT"),
snippet=snippet,
message_count=count,
updated_at=handoff.get("updated_at") or handoff.get("created_at"),
)
)
return items
@router.get("/conversations/{handoff_id}", response_model=HandoffConversationRead)
async def get_handoff_conversation(
handoff_id: str,
database=Depends(get_mongo_database),
account: Account = Depends(get_current_account),
):
if not ObjectId.is_valid(handoff_id):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid handoff ID.")
account_id = str(account.id)
handoff = await database.human_handoffs.find_one(
{"_id": ObjectId(handoff_id), "account_id": account_id}
)
if handoff is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Handoff conversation not found.")
pipeline = [
{
"$match": {
"account_id": account_id,
"thread_id": handoff["handoff_thread_id"],
"message_scope": MESSAGE_SCOPE_HANDOFF,
}
},
{"$sort": {"event_at": 1, "_id": 1}},
{
"$lookup": {
"from": "messages",
"localField": "message_id",
"foreignField": "_id",
"as": "message",
}
},
{"$unwind": "$message"},
]
cursor = await database.mailbox_entries.aggregate(pipeline)
rows = [row async for row in cursor]
human_email = handoff.get("human_email", "").lower()
messages: list[HandoffMessageRead] = []
for row in rows:
message = row["message"]
if is_relay_acknowledgment_message(message):
continue
sender_address = (message.get("sender") or {}).get("address", "").lower()
if message.get("mirrored_from_external_message_id"):
role = "customer"
elif sender_address == human_email:
role = "human"
else:
role = "agent"
event_at = row.get("event_at") or message.get("received_at") or message.get("sent_at") or message["created_at"]
raw_body = message.get("raw_body")
new_reply_content = message.get("new_reply_content")
display_body = new_reply_content or message.get("body_text", "")
messages.append(
HandoffMessageRead(
id=str(message["_id"]),
role=role,
subject=message.get("subject", ""),
snippet=message.get("snippet", "") or display_body[:240],
body_text=display_body,
new_reply_content=new_reply_content,
raw_body=raw_body,
has_quoted_text=bool(
raw_body
and new_reply_content
and raw_body.strip() != new_reply_content.strip()
),
relay_status=message.get("relay_status") if role == "human" else None,
event_at=event_at,
)
)
return HandoffConversationRead(
id=str(handoff["_id"]),
external_subject=handoff.get("external_subject", ""),
external_sender=handoff.get("external_sender", ""),
human_email=handoff.get("human_email", ""),
handoff_reason=handoff.get("handoff_reason", ""),
status=handoff.get("status", "ACTIVE"),
status_label=_status_label(
handoff.get("status", "ACTIVE"),
handoff.get("current_owner", "AGENT"),
),
current_owner=handoff.get("current_owner", "AGENT"),
linked_external=HandoffLinkedExternal(
thread_id=str(handoff["external_thread_id"]),
subject=handoff.get("external_subject", ""),
sender=handoff.get("external_sender", ""),
),
messages=messages,
)
@router.get("/meta") @router.get("/meta")
async def handoff_meta(): async def handoff_meta():
return { return {

View file

@ -1,867 +1,8 @@
import logging """Human Handoff runtime is disabled — configuration-only via agent_handoff router.
import re
from datetime import datetime, timezone
from bson import ObjectId This module retains scope constants used by normal mail filtering. All email
sending, receiving, mirroring, forwarding, and relay behavior has been removed.
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 (
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 (
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,
message_event_at,
threading_fields_from_parent,
)
logger = logging.getLogger(__name__)
MESSAGE_SCOPE_EXTERNAL = "EXTERNAL" MESSAGE_SCOPE_EXTERNAL = "EXTERNAL"
MESSAGE_SCOPE_HANDOFF = "HANDOFF" MESSAGE_SCOPE_HANDOFF = "HANDOFF"
THREAD_TYPE_EXTERNAL = "EXTERNAL"
THREAD_TYPE_HUMAN_HANDOFF = "HUMAN_HANDOFF"
HANDOFF_STATUS_ACTIVE = "ACTIVE"
HANDOFF_STATUS_WAITING = "WAITING_FOR_HUMAN"
HANDOFF_STATUS_HUMAN_REPLIED = "HUMAN_REPLIED"
HANDOFF_STATUS_AGENT_REPLIED = "AGENT_REPLIED"
OWNER_AGENT = "AGENT"
OWNER_HUMAN = "HUMAN"
LINK_EXTERNAL_TRIGGERED = "EXTERNAL_TRIGGERED_HANDOFF"
LINK_EXTERNAL_RELAYED = "EXTERNAL_RELAYED_TO_HUMAN"
LINK_HUMAN_TRIGGERED_EXTERNAL = "HUMAN_TRIGGERED_EXTERNAL_REPLY"
LINK_EXTERNAL_UPDATE_RELAYED = "EXTERNAL_UPDATE_RELAYED_TO_HUMAN"
def _now() -> datetime:
return datetime.now(timezone.utc)
def _snippet(text: str, limit: int = 240) -> str:
return re.sub(r"\s+", " ", (text or "")).strip()[:limit]
def _handoff_reply_subject(external_subject: str) -> str:
return f"Re: {handoff_email_subject(external_subject)}"
async def create_empty_handoff_thread(
database,
*,
account_id: str,
subject: str,
) -> ObjectId:
now = _now()
document = {
"account_id": account_id,
"thread_type": THREAD_TYPE_HUMAN_HANDOFF,
"normalized_subject": subject,
"created_at": now,
"updated_at": now,
"last_message_at": now,
"latest_message_id": None,
"message_count": 0,
}
result = await database.email_threads.insert_one(document)
return result.inserted_id
async def _record_message_link(
database,
*,
handoff_id: ObjectId,
external_message_id: ObjectId | None,
handoff_message_id: ObjectId,
relationship: str,
) -> None:
await database.handoff_message_links.insert_one(
{
"handoff_id": handoff_id,
"external_message_id": external_message_id,
"handoff_message_id": handoff_message_id,
"relationship": relationship,
"created_at": _now(),
}
)
async def _latest_external_customer_message(
database,
account_id: str,
external_thread_id: ObjectId,
customer_address: str,
) -> dict | None:
customer = customer_address.lower()
pipeline = [
{
"$match": {
"account_id": account_id,
"thread_id": external_thread_id,
"message_scope": MESSAGE_SCOPE_EXTERNAL,
"deletion_state": {"$in": [None, ""]},
}
},
{"$sort": {"event_at": -1, "_id": -1}},
{
"$lookup": {
"from": "messages",
"localField": "message_id",
"foreignField": "_id",
"as": "message",
}
},
{"$unwind": "$message"},
{
"$match": {
"$or": [
{"message.direction": "inbound"},
{"message.sender.address": customer},
]
}
},
{"$limit": 1},
]
cursor = await database.mailbox_entries.aggregate(pipeline)
rows = [row async for row in cursor]
return rows[0]["message"] if rows else None
async def _latest_external_message(database, account_id: str, external_thread_id: ObjectId) -> dict | None:
pipeline = [
{
"$match": {
"account_id": account_id,
"thread_id": external_thread_id,
"message_scope": MESSAGE_SCOPE_EXTERNAL,
"deletion_state": {"$in": [None, ""]},
}
},
{"$sort": {"event_at": -1, "_id": -1}},
{"$limit": 1},
{
"$lookup": {
"from": "messages",
"localField": "message_id",
"foreignField": "_id",
"as": "message",
}
},
{"$unwind": "$message"},
]
cursor = await database.mailbox_entries.aggregate(pipeline)
rows = [row async for row in cursor]
return rows[0]["message"] if rows else None
async def _latest_handoff_message(database, account_id: str, handoff_thread_id: ObjectId) -> dict | None:
pipeline = [
{
"$match": {
"account_id": account_id,
"thread_id": handoff_thread_id,
"message_scope": MESSAGE_SCOPE_HANDOFF,
}
},
{"$sort": {"event_at": -1, "_id": -1}},
{"$limit": 1},
{
"$lookup": {
"from": "messages",
"localField": "message_id",
"foreignField": "_id",
"as": "message",
}
},
{"$unwind": "$message"},
]
cursor = await database.mailbox_entries.aggregate(pipeline)
rows = [row async for row in cursor]
return rows[0]["message"] if rows else None
async def find_active_handoff_for_external_thread(
database,
account_id: str,
external_thread_id: ObjectId,
) -> dict | None:
return await database.human_handoffs.find_one(
{
"account_id": account_id,
"external_thread_id": external_thread_id,
"status": {"$in": [HANDOFF_STATUS_ACTIVE, HANDOFF_STATUS_WAITING, HANDOFF_STATUS_HUMAN_REPLIED, HANDOFF_STATUS_AGENT_REPLIED]},
}
)
async def resolve_handoff_from_inbound(
database,
*,
account_id: str,
agent_email: str,
sender_address: str,
in_reply_to: str | None,
references: list[str],
) -> dict | None:
sender = sender_address.lower()
rules = await database.human_handoff_rules.find(
{"account_id": account_id, "owner_email": sender, "active": True}
).to_list(length=10)
if not rules:
return None
search_ids = [*([in_reply_to] if in_reply_to else []), *references]
if not search_ids:
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(
{
"$or": [
{"message_id_folded": folded},
{"message_id": internet_id},
],
"message_scope": MESSAGE_SCOPE_HANDOFF,
}
)
if message is None:
continue
entry = await database.mailbox_entries.find_one(
{
"account_id": account_id,
"message_id": message["_id"],
"message_scope": MESSAGE_SCOPE_HANDOFF,
}
)
if entry is None:
continue
handoff = await database.human_handoffs.find_one(
{
"account_id": account_id,
"handoff_thread_id": entry["thread_id"],
"human_email": sender,
"status": {"$ne": "CLOSED"},
}
)
if handoff:
return handoff
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,
*,
account_id: str,
agent_email: str,
handoff_thread_id: ObjectId,
direction: str,
sender: dict,
recipients: list[dict],
subject: str,
body_text: str,
internet_message_id: str | None = None,
in_reply_to: str | None = None,
references: list[str] | None = None,
handoff_id: ObjectId | None = None,
link_relationship: str | None = None,
external_message_id: ObjectId | None = None,
event_at: datetime | None = None,
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
document = {
"direction": direction,
"message_scope": MESSAGE_SCOPE_HANDOFF,
"account_id": account_id,
"sender": sender,
"to": recipients,
"subject": subject,
"snippet": _snippet(display_body),
"body_text": display_body,
"raw_body": raw_body,
"new_reply_content": new_reply_content,
"body_html": body_html,
"attachments": [],
"created_at": now,
"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_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(
database,
account_id=account_id,
thread_id=handoff_thread_id,
message_id=result.inserted_id,
subject=subject,
event_at=now,
)
await database.mailbox_entries.insert_one(
{
"account_id": account_id,
"message_id": result.inserted_id,
"thread_id": handoff_thread_id,
"mailbox": "handoff",
"message_scope": MESSAGE_SCOPE_HANDOFF,
"direction": direction,
"sender": sender,
"recipients": recipients,
"subject": subject,
"snippet": document["snippet"],
"attachments": [],
"status": document["status"],
"is_read": direction == "outbound",
"is_starred": False,
"event_at": now,
"created_at": now,
"trashed_at": None,
"deletion_state": None,
}
)
if handoff_id and link_relationship:
await _record_message_link(
database,
handoff_id=handoff_id,
external_message_id=external_message_id,
handoff_message_id=result.inserted_id,
relationship=link_relationship,
)
return document
async def send_handoff_to_human(
database,
*,
account_id: str,
agent_email: str,
human_email: str,
external_message: dict,
external_thread_id: ObjectId,
handoff_reason: str,
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}
if existing_handoff:
handoff_id = existing_handoff["_id"]
handoff_thread_id = existing_handoff["handoff_thread_id"]
parent = await _latest_handoff_message(database, account_id, handoff_thread_id)
threading = threading_fields_from_parent(parent) if parent else {}
subject = _handoff_reply_subject(external_subject)
mirror = await load_mirror_update_payload(mirror_source, agent_email=agent_email)
relationship = LINK_EXTERNAL_UPDATE_RELAYED
else:
handoff_thread_id = await create_empty_handoff_thread(
database,
account_id=account_id,
subject=stable_subject,
)
threading = {}
subject = stable_subject
mirror = await load_mirror_payload(mirror_source)
external_sender_text = (external_message.get("sender") or {}).get("address") or "unknown sender"
handoff_doc = {
"account_id": account_id,
"agent_email": agent_email.lower(),
"human_email": human_email.lower(),
"external_thread_id": external_thread_id,
"handoff_thread_id": handoff_thread_id,
"external_subject": external_subject,
"external_sender": external_sender_text,
"handoff_reason": handoff_reason,
"status": HANDOFF_STATUS_WAITING,
"current_owner": OWNER_HUMAN,
"created_at": _now(),
"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
relationship = LINK_EXTERNAL_TRIGGERED
internet_message_id = await deliver_email(
sender=agent_email,
recipients=[human_email],
cc=[],
bcc=[],
subject=subject,
body_text=mirror.body_text,
body_html=mirror.body_html,
domain=domain,
in_reply_to=threading.get("in_reply_to"),
references=threading.get("references"),
inline_parts=mirror.inline_parts,
file_attachments=mirror.file_attachments,
)
handoff_message = await store_handoff_message(
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=subject,
body_text=mirror.body_text,
body_html=mirror.body_html,
internet_message_id=internet_message_id,
in_reply_to=threading.get("in_reply_to"),
references=threading.get("references"),
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(
{"_id": handoff_id},
{
"$set": {
"status": HANDOFF_STATUS_WAITING,
"current_owner": OWNER_HUMAN,
"updated_at": _now(),
"last_external_message_at": message_event_at(external_message),
"latest_handoff_message_id": handoff_message["_id"],
}
},
)
logger.info(
"handoff_sent handoff_id=%s external_thread=%s handoff_thread=%s",
handoff_id,
external_thread_id,
handoff_thread_id,
)
return await database.human_handoffs.find_one({"_id": handoff_id})
async def _handoff_rule_for_human(database, account_id: str, human_email: str) -> dict | None:
return await database.human_handoff_rules.find_one(
{
"account_id": account_id,
"owner_email": human_email.lower(),
"active": True,
}
)
async def process_human_reply(
database,
*,
account_id: str,
agent_email: str,
handoff: dict,
inbound_parsed: dict,
inbound_body: str,
domain,
) -> None:
handoff_thread_id = handoff["handoff_thread_id"]
external_thread_id = handoff["external_thread_id"]
human_email = handoff["human_email"]
rule = await _handoff_rule_for_human(database, account_id, human_email)
allow_replies = rule.get("allow_replies", True) if rule else True
extracted = extract_new_reply_content(
body_text=inbound_body,
body_html=inbound_parsed.get("body_html"),
)
new_reply_content = extracted["new_reply_content"].strip()
raw_body = extracted["raw_body"]
human_message = await store_handoff_message(
database,
account_id=account_id,
agent_email=agent_email,
handoff_thread_id=handoff_thread_id,
direction="inbound",
sender={"name": human_email.split("@")[0], "address": human_email},
recipients=[{"name": "Gentic Agent", "address": agent_email}],
subject=inbound_parsed.get("subject", ""),
body_text=raw_body,
raw_body=raw_body,
new_reply_content=new_reply_content,
body_html=inbound_parsed.get("body_html"),
internet_message_id=inbound_parsed.get("message_id"),
in_reply_to=inbound_parsed.get("in_reply_to"),
references=inbound_parsed.get("references", []),
handoff_id=handoff["_id"],
message_origin=MESSAGE_ORIGIN_HUMAN_OWNER,
generated_by=GENERATED_BY_HUMAN,
)
now = _now()
if not allow_replies:
await database.human_handoffs.update_one(
{"_id": handoff["_id"]},
{
"$set": {
"status": HANDOFF_STATUS_HUMAN_REPLIED,
"current_owner": OWNER_AGENT,
"updated_at": now,
"last_human_message_at": now,
}
},
)
return
if not new_reply_content:
logger.warning(
"handoff_reply_empty_extract handoff_id=%s account=%s",
handoff["_id"],
account_id,
)
await database.human_handoffs.update_one(
{"_id": handoff["_id"]},
{
"$set": {
"status": HANDOFF_STATUS_HUMAN_REPLIED,
"current_owner": OWNER_AGENT,
"updated_at": now,
"last_human_message_at": now,
}
},
)
return
if not validate_external_reply_body(new_reply_content):
logger.error(
"handoff_reply_leakage_guard handoff_id=%s account=%s",
handoff["_id"],
account_id,
)
return
external_recipient = handoff.get("external_sender") or ""
external_parent = await resolve_external_reply_parent(
database,
account_id=account_id,
external_thread_id=external_thread_id,
customer_address=external_recipient,
handoff=handoff,
)
if external_parent is None:
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
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,
body_text=customer_body,
subject_override=external_reply_subject(
handoff.get("external_subject") or external_parent.get("subject", "")
),
recipients=[external_recipient],
parent_internet_id_override=parent_internet_id,
)
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
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 _record_message_link(
database,
handoff_id=handoff["_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_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(
database,
*,
account_id: str,
agent_email: str,
external_message: dict,
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)
if not rules:
return
body_text = external_message.get("body_text") or external_message.get("snippet") or ""
email_payload = {
"subject": external_message.get("subject", ""),
"snippet": external_message.get("snippet", ""),
"body_text": body_text,
"is_starred": False,
}
for rule in rules:
evaluation = evaluate_handoff(
email=email_payload,
labels=rule.get("labels", []),
email_types=rule.get("email_types", []),
instructions=rule.get("instructions", ""),
)
if not evaluation["would_handoff"]:
continue
if not rule.get("allow_replies", True):
continue
reason = (rule.get("instructions") or "").strip() or "This conversation requires human attention."
await send_handoff_to_human(
database,
account_id=account_id,
agent_email=agent_email,
human_email=rule["owner_email"],
external_message=external_message,
external_thread_id=external_thread_id,
handoff_reason=reason,
domain=domain,
existing_handoff=None,
)
return

View file

@ -9,19 +9,8 @@ from sqlalchemy import func, select
from app.config import get_settings from app.config import get_settings
from app.database import SessionFactory, mongo_db from app.database import SessionFactory, mongo_db
from app.models import Account from app.models import Account
from app.services.domain_provisioning import get_domain_by_name
from app.services.handoff_mirror_policy import (
GENERATED_BY_CUSTOMER,
MESSAGE_ORIGIN_EXTERNAL_CUSTOMER,
)
from app.services.human_handoff_service import (
MESSAGE_SCOPE_EXTERNAL,
MESSAGE_SCOPE_HANDOFF,
process_human_reply,
resolve_handoff_from_inbound,
maybe_trigger_handoff,
)
from app.services.external_reply_service import log_original_external_message from app.services.external_reply_service import log_original_external_message
from app.services.human_handoff_service import MESSAGE_SCOPE_EXTERNAL
from app.services.message_ids import canonical_message_id, message_id_fields from app.services.message_ids import canonical_message_id, message_id_fields
from app.services.mime_parser import parse_mime from app.services.mime_parser import parse_mime
from app.services.raw_mime_storage import delivery_digest, get_raw_mime_storage from app.services.raw_mime_storage import delivery_digest, get_raw_mime_storage
@ -136,62 +125,6 @@ class InboundHandler:
internet_message_id = canonical_message_id(parsed.get("message_id")) internet_message_id = canonical_message_id(parsed.get("message_id"))
id_fields = message_id_fields(internet_message_id) id_fields = message_id_fields(internet_message_id)
for recipient in recipients:
account_id = envelope.account_ids[recipient]
agent_email = recipient
handoff = await resolve_handoff_from_inbound(
mongo_db,
account_id=account_id,
agent_email=agent_email,
sender_address=sender_address,
in_reply_to=parsed.get("in_reply_to"),
references=parsed.get("references", []),
)
if handoff:
async with SessionFactory() as database_session:
domain = await get_domain_by_name(
database_session,
agent_email.split("@", 1)[1],
)
if domain is None:
logger.warning("handoff_reply_no_domain account=%s", account_id)
continue
message = {
**parsed,
"body_text": body_text,
"body_html": body_html,
"direction": "inbound",
"message_scope": MESSAGE_SCOPE_HANDOFF,
"delivery_key": delivery_key,
"received_at": now,
"created_at": now,
**id_fields,
}
await process_human_reply(
mongo_db,
account_id=account_id,
agent_email=agent_email,
handoff=handoff,
inbound_parsed=message,
inbound_body=body_text,
domain=domain,
)
log_smtp_message_received(
message_id=internet_message_id,
in_reply_to=parsed.get("in_reply_to"),
references=parsed.get("references", []),
sender=sender_address,
recipient=recipient,
subject=parsed.get("subject", ""),
resolved_scope=MESSAGE_SCOPE_HANDOFF,
resolved_thread_id=handoff.get("handoff_thread_id"),
resolution_method="handoff_in_reply_to",
stored_message_id=None,
mailbox_id="handoff",
)
return f"250 2.0.0 Handoff reply accepted as {delivery_key}"
message = { message = {
**parsed, **parsed,
"direction": "inbound", "direction": "inbound",
@ -232,7 +165,6 @@ class InboundHandler:
participants = participants_from_parsed(parsed) participants = participants_from_parsed(parsed)
for recipient in recipients: for recipient in recipients:
account_id = envelope.account_ids[recipient] account_id = envelope.account_ids[recipient]
agent_email = recipient
resolution = await resolve_thread( resolution = await resolve_thread(
mongo_db, mongo_db,
account_id=account_id, account_id=account_id,
@ -263,8 +195,6 @@ class InboundHandler:
"parent_message_id": resolution.parent_message_id, "parent_message_id": resolution.parent_message_id,
"thread_relation": resolution.thread_relation, "thread_relation": resolution.thread_relation,
"thread_resolution_method": resolution.thread_resolution_method, "thread_resolution_method": resolution.thread_resolution_method,
"message_origin": MESSAGE_ORIGIN_EXTERNAL_CUSTOMER,
"generated_by": GENERATED_BY_CUSTOMER,
} }
}, },
) )
@ -298,28 +228,6 @@ class InboundHandler:
}, },
upsert=True, upsert=True,
) )
async with SessionFactory() as database_session:
domain = await get_domain_by_name(
database_session,
agent_email.split("@", 1)[1],
)
persisted.update(
{
"message_scope": MESSAGE_SCOPE_EXTERNAL,
"direction": "inbound",
"message_origin": MESSAGE_ORIGIN_EXTERNAL_CUSTOMER,
"generated_by": GENERATED_BY_CUSTOMER,
}
)
if domain is not None:
await maybe_trigger_handoff(
mongo_db,
account_id=account_id,
agent_email=agent_email,
external_message=persisted,
external_thread_id=thread_id,
domain=domain,
)
log_original_external_message( log_original_external_message(
internal_id=persisted["_id"], internal_id=persisted["_id"],
thread_id=thread_id, thread_id=thread_id,

View file

@ -1,125 +1,13 @@
import pytest from app.services.human_handoff_service import MESSAGE_SCOPE_EXTERNAL, MESSAGE_SCOPE_HANDOFF
from bson import ObjectId
from app.services.human_handoff_service import (
MESSAGE_SCOPE_EXTERNAL,
MESSAGE_SCOPE_HANDOFF,
resolve_handoff_from_inbound,
)
class FakeCollection: def test_scope_constants_are_distinct():
def __init__(self, documents=None):
self.documents = list(documents or [])
async def find_one(self, query):
for document in self.documents:
if all(document.get(key) == value for key, value in query.items()):
return document
if "$ne" in query.get("status", {}):
if document.get("status") == query["status"]["$ne"]:
continue
for document in self.documents:
matches = True
for key, value in query.items():
if key == "status" and isinstance(value, dict) and "$ne" in value:
if document.get("status") == value["$ne"]:
matches = False
break
elif document.get(key) != value:
matches = False
break
if matches:
return document
return None
def find(self, query):
class Cursor:
def __init__(self, items):
self.items = items
async def to_list(self, length):
return self.items[:length]
filtered = [
document
for document in self.documents
if all(document.get(key) == value for key, value in query.items())
]
return Cursor(filtered)
class FakeDatabase:
def __init__(self):
self.human_handoff_rules = FakeCollection()
self.messages = FakeCollection()
self.mailbox_entries = FakeCollection()
self.human_handoffs = FakeCollection()
@pytest.mark.asyncio
async def test_resolve_handoff_requires_owner_and_thread_context():
database = FakeDatabase()
handoff_thread_id = ObjectId()
message_id = ObjectId()
database.human_handoff_rules.documents = [
{
"account_id": "acct-1",
"owner_email": "owner@gmail.com",
"active": True,
}
]
database.messages.documents = [
{
"_id": message_id,
"message_id": "<handoff-1@gentic.email>",
"message_id_folded": "<handoff-1@gentic.email>",
"message_scope": MESSAGE_SCOPE_HANDOFF,
}
]
database.mailbox_entries.documents = [
{
"account_id": "acct-1",
"message_id": message_id,
"thread_id": handoff_thread_id,
"message_scope": MESSAGE_SCOPE_HANDOFF,
}
]
database.human_handoffs.documents = [
{
"_id": ObjectId(),
"account_id": "acct-1",
"handoff_thread_id": handoff_thread_id,
"human_email": "owner@gmail.com",
"status": "WAITING_FOR_HUMAN",
}
]
handoff = await resolve_handoff_from_inbound(
database,
account_id="acct-1",
agent_email="support@gentic.email",
sender_address="owner@gmail.com",
in_reply_to="<handoff-1@gentic.email>",
references=[],
)
assert handoff is not None
@pytest.mark.asyncio
async def test_resolve_handoff_rejects_unknown_sender():
database = FakeDatabase()
handoff = await resolve_handoff_from_inbound(
database,
account_id="acct-1",
agent_email="support@gentic.email",
sender_address="stranger@example.com",
in_reply_to="<handoff-1@gentic.email>",
references=[],
)
assert handoff is None
@pytest.mark.asyncio
async def test_external_scope_constant_isolated_from_handoff():
assert MESSAGE_SCOPE_EXTERNAL != MESSAGE_SCOPE_HANDOFF assert MESSAGE_SCOPE_EXTERNAL != MESSAGE_SCOPE_HANDOFF
def test_human_handoff_runtime_is_disabled():
import app.services.human_handoff_service as module
assert not hasattr(module, "maybe_trigger_handoff")
assert not hasattr(module, "process_human_reply")
assert not hasattr(module, "resolve_handoff_from_inbound")

View file

@ -145,10 +145,6 @@ export const deleteHandoffRule = (ruleId, token) =>
request(`/agent/handoff/${encodeURIComponent(ruleId)}`, { method: "DELETE", token }); request(`/agent/handoff/${encodeURIComponent(ruleId)}`, { method: "DELETE", token });
export const testHandoffRule = (payload, token) => export const testHandoffRule = (payload, token) =>
request("/agent/handoff/test", { method: "POST", payload, token }); request("/agent/handoff/test", { method: "POST", payload, token });
export const listHandoffConversations = (token) =>
request("/agent/handoff/conversations", { token });
export const getHandoffConversation = (handoffId, token) =>
request(`/agent/handoff/conversations/${encodeURIComponent(handoffId)}`, { token });
export const listStickyNotes = (token) => export const listStickyNotes = (token) =>
request("/sticky-notes", { token }); request("/sticky-notes", { token });

View file

@ -130,23 +130,12 @@ export default function HandoffSettings() {
<div className="handoff-settings"> <div className="handoff-settings">
<div className="handoff-settings-intro"> <div className="handoff-settings-intro">
<p> <p>
<strong>Your Agent handles email. You step in when needed.</strong> <strong>Configure Human Handoff rules for your Agent.</strong>
</p> </p>
<p> <p>
Human Handoff sends selected conversations to the Agent&apos;s Human Owner. The owner can Saved settings are stored for future use. They do not send, forward, mirror, or relay
reply from their normal inbox, and Gentic carries that response back to the original sender email today — all mail continues through the normal Gentic inbox and outbox.
without exposing or mixing the private Agent ↔ Human conversation.
</p> </p>
<div className="handoff-flow-diagram" aria-hidden="true">
<span>External Sender</span>
<span className="handoff-flow-arrow">↕</span>
<span>Gentic Agent</span>
<span className="handoff-flow-arrow">↕</span>
<span>Human Owner</span>
</div>
<small>
The external conversation and Human Handoff remain separate and independently threaded.
</small>
</div> </div>
<div className="handoff-settings-toolbar"> <div className="handoff-settings-toolbar">
@ -167,8 +156,8 @@ export default function HandoffSettings() {
</div> </div>
<h2>No handoff rules yet</h2> <h2>No handoff rules yet</h2>
<p> <p>
Configure when your Agent should hand conversations to the Human Owner. Configure when your Agent should involve a Human Owner. Settings are saved only;
Handoff transport messages stay out of your normal Inbox and Sent folders. no emails are sent automatically.
</p> </p>
<button type="button" className="handoff-btn primary" onClick={openCreate}> <button type="button" className="handoff-btn primary" onClick={openCreate}>
<Plus size={16} strokeWidth={1.75} /> Add handoff rule <Plus size={16} strokeWidth={1.75} /> Add handoff rule

View file

@ -1,58 +1,18 @@
import { useState } from "react";
import HandoffConversation from "./HandoffConversation";
import HandoffInbox from "./HandoffInbox";
import HandoffSettings from "./HandoffSettings"; import HandoffSettings from "./HandoffSettings";
export default function HumanHandoff() { export default function HumanHandoff() {
const [tab, setTab] = useState("handoffs");
const [selectedHandoffId, setSelectedHandoffId] = useState(null);
function switchTab(nextTab) {
setTab(nextTab);
if (nextTab !== "handoffs") {
setSelectedHandoffId(null);
}
}
return ( return (
<section className="human-handoff-page"> <section className="human-handoff-page">
<header className="human-handoff-head"> <header className="human-handoff-head">
<div> <div>
<h1>Human Handoff</h1> <h1>Human Handoff</h1>
<p> <p>
Private Agent ↔ Human Owner conversations, linked to external customer threads Configure when your Agent should involve a Human Owner. Settings are saved here;
but kept on separate RFC threading planes. email routing is not affected until handoff is enabled in a future release.
</p> </p>
<nav className="human-handoff-tabs" aria-label="Human Handoff sections">
<button
type="button"
className={tab === "handoffs" ? "active" : ""}
onClick={() => switchTab("handoffs")}
>
Handoffs
</button>
<button
type="button"
className={tab === "settings" ? "active" : ""}
onClick={() => switchTab("settings")}
>
Settings
</button>
</nav>
</div> </div>
</header> </header>
<HandoffSettings />
{tab === "handoffs" && (
selectedHandoffId ? (
<HandoffConversation
handoffId={selectedHandoffId}
onBack={() => setSelectedHandoffId(null)}
/>
) : (
<HandoffInbox onSelect={setSelectedHandoffId} />
)
)}
{tab === "settings" && <HandoffSettings />}
</section> </section>
); );
} }

View file

@ -244,14 +244,8 @@ export default function HumanHandoffDrawer({
onChange={setAllowReplies} onChange={setAllowReplies}
/> />
<p className="handoff-helper"> <p className="handoff-helper">
When you reply to a handed-off email from your regular inbox, AGMail will recognize your response and continue the original conversation through the agent&apos;s email address. Saved for future use. Human reply handling is not active yet — all email uses the normal Gentic inbox and outbox.
</p> </p>
{allowReplies && (
<div className="handoff-reply-info">
<strong>How it works</strong>
<p>Email arrives → Agent handles it. If human attention is required → Human Handoff → Your inbox. You reply normally → AGMail → Original conversation.</p>
</div>
)}
{allowReplies && ( {allowReplies && (
<div className="handoff-reply-as"> <div className="handoff-reply-as">
<strong>Replies will be sent as</strong> <strong>Replies will be sent as</strong>

View file

@ -76,7 +76,7 @@ export function buildHandoffSummary({
? "when they require your personal decision or attention" ? "when they require your personal decision or attention"
: "when your handoff instructions apply"; : "when your handoff instructions apply";
return `Your agent will handle email normally. ${labelText} that are ${typeText} will be handed to ${ownerEmail || "the human owner"} ${instructionText}.`; return `Saved rule: ${labelText} that are ${typeText} would involve ${ownerEmail || "the human owner"} ${instructionText}. Email routing is not active yet.`;
} }
export function buildRuleCardSummary(rule) { export function buildRuleCardSummary(rule) {