Skip to content
Merged
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
38 changes: 33 additions & 5 deletions hub/agents/email/python/gaia_agent_email/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,16 @@ def _detect_targeted_mailboxes(query: str) -> set:
things), ask which message they mean — never guess, and never fall back to
a keyword search for a bare number.

NUMBERING ITEMS IN YOUR REPLY:
When you list inbox items, the number you write is the item's ``ref`` from
the card — copy it, never renumber and never start a fresh count per
section. Say "2.", not "Row 2". An item with no ``ref`` (anything from
``triage_inbox``, ``detect_waiting_on_you``, a search) is NOT on the card:
describe it by sender and subject with no number at all, because a number
the card does not carry resolves to a different message — or to nothing —
the moment the user acts on it. Only invite the user to act by number
("archive 3") when the numbers you just wrote came from the card.

BRIEFING & TASKS:
- For a daily briefing / morning brief / "summarize my inbox for today",
call ``get_briefing`` — NOT ``pre_scan_inbox``. The briefing is the
Expand Down Expand Up @@ -895,6 +905,9 @@ def __init__(self, config: Optional[EmailAgentConfig] = None):
self._load_persisted_preferences()

self.response_mode = "conversational"
# The text finalize_answer already grounded, so process_query's
# fallback never grounds the same answer a second time.
self._grounded_answer: Optional[str] = None
super().__init__(
base_url=effective_base_url,
model_id=effective_model_id,
Expand Down Expand Up @@ -1171,14 +1184,29 @@ def process_query(self, user_input: str, *args, **kwargs):
# consumers never see raw TeX in the final answer (#2115).
if isinstance(result, dict) and isinstance(result.get("result"), str):
result["result"] = _normalize_plain_text_answer(result["result"])
if isinstance(result, dict):
# Single deterministic post-check hook: success-claim / negative-
# claim / cross-mailbox / scaffolding-leak / calendar-conflict
# (#2571) / attention-card (#2636) guards all live in
# answer_grounding.py.
if isinstance(result, dict) and result.get("result") != self._grounded_answer:
# Normally finalize_answer already grounded this text before the
# loop emitted it. This covers the branches that never reach that
# call — the loop setting an actionable answer on an internal error
# and returning it directly — without grounding the same text twice
# (the append-style guards would repeat their correction).
result = ground_final_answer(result)
return result

def finalize_answer(self, answer: str, conversation: Any) -> str:
"""Ground the answer BEFORE the loop emits it (#2789).

Grounding used to run on ``process_query``'s return value, which the
REST/TUI stream never re-reads — so every correction fired, logged, and
reached nobody on the surface users actually drive.
"""
grounded = ground_final_answer(
{"result": answer, "conversation": conversation, "status": "success"}
)
corrected = grounded.get("result")
self._grounded_answer = corrected if isinstance(corrected, str) else answer
return self._grounded_answer

def _mailbox_target_guard(self, user_input: str) -> Optional[Dict[str, Any]]:
"""Reject a request that targets a mailbox the SESSION has ruled out (#2164).

Expand Down
179 changes: 178 additions & 1 deletion hub/agents/email/python/gaia_agent_email/answer_grounding.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
import json
import re
import time
from typing import Any, Dict, Iterator, List, Optional
from typing import Any, Dict, Iterator, List, Optional, Tuple

from gaia_agent_email.attention_cache import ATTENTION_CACHE_TTL_SECONDS
from gaia_agent_email.attention_cache import peek as _peek_attention_cache
Expand Down Expand Up @@ -298,6 +298,176 @@ def strip_scaffolding_leaks(text: str) -> str:
return cleaned.strip()


# A numbered triage item at the start of a line -- the shape the list is
# supposed to have, and the signal that this answer IS a triage list.
# Tolerates the shapes a model reaches for around the number — a bullet, bold
# markers, or both ("- **9.** …"). Missing one of them makes the rebuild below
# think the reply has no list and append a second copy of it.
_NUMBERED_ITEM_LINE_RE = re.compile(
r"^[ \t]*(?:[-*+•·][ \t]*)?\*{0,2}\d{1,3}\.\*{0,2}[ \t]", re.MULTILINE
)

# A numbered item that ran on mid-line instead of starting its own, e.g.
# "...scheduling meetings: 4. Tomasz ... 5. Tomasz ...".
_INLINE_NUMBERED_ITEM_RE = re.compile(r"(?<=\S)[ \t]+(?=\d{1,3}\.[ \t]+\S)")

# Any bare address on an item line, however the model punctuated around it.
# The sender is already named beside it, so a bare address renders twice --
# once as text, once as the mailto: link the markdown renderer expands. An
# explicit mailto: link goes too, for the same reason.
_ITEM_LINE_EMAIL_RE = re.compile(
r"[ \t]*\[?<?(?:mailto:)?[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}>?\]?"
r"(?:\((?:mailto:)?[^)]*\))?"
)


def normalize_triage_list(text: str) -> str:
"""Give a numbered triage list the shape the skill asks for and the model
keeps missing: one item per line, no duplicated sender address.

Formatting a list the tool already computed is not a judgement call, so it
is enforced here rather than requested in the prompt — three consecutive
live runs showed the instruction alone does not hold. Applies only to an
answer that already contains a numbered item at the start of a line, so
ordinary prose that happens to say "in 5. Then" is untouched.
"""
if not text or not _NUMBERED_ITEM_LINE_RE.search(text):
return text
out = _INLINE_NUMBERED_ITEM_RE.sub("\n", text)
out = "\n".join(
_ITEM_LINE_EMAIL_RE.sub("", line) if _NUMBERED_ITEM_LINE_RE.match(line) else line
for line in out.split("\n")
)
return out


# needs_you ``kind`` → the section it belongs under, in the order refs are
# assigned (_NEEDS_YOU_KIND_ORDER, read_tools.py), so the numbers ascend down
# the page without the renderer sorting anything.
_TRIAGE_SECTIONS: List[Tuple[str, Tuple[str, ...]]] = [
("Waiting on your reply", ("urgent", "waiting_on_you")),
("Needs a response", ("needs_response",)),
("Meetings to decide", ("meeting_request",)),
("Needs a manual look", ("needs_review", "action_item")),
]


# ``needs_you.sender`` carries a display name, an address, or both. Only the
# name is worth a row -- an address renders twice once the markdown renderer
# turns it into a mailto: link.
_SENDER_EMAIL_RE = re.compile(r"\s*<?([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})>?")


def _sender_label(sender: Any) -> str:
text = str(sender or "").strip()
if not text:
return "unknown sender"
match = _SENDER_EMAIL_RE.search(text)
if match is None:
return text
name = _SENDER_EMAIL_RE.sub(" ", text).strip(" <>|,-–—")
# Address-only sender: keep it (the reader still needs to know who) but as
# code, so the renderer cannot autolink it into a duplicate.
return name or f"`{match.group(1)}`"


def _age_phrase(age_seconds: Any) -> str:
if not isinstance(age_seconds, (int, float)) or age_seconds < 0:
return ""
days = int(age_seconds // 86400)
if days >= 1:
return f"{days}d ago"
hours = int(age_seconds // 3600)
return f"{hours}h ago" if hours >= 1 else "just now"


def render_needs_you_list(envelope: Dict[str, Any]) -> str:
"""Build the numbered triage list straight from ``needs_you``.

The list is entirely determined by the tool's own output — every field is
already computed, and the refs are already in display order — so composing
it is not a judgement the model should be making. Five consecutive live
runs had it drop items, renumber them, merge sections, or answer with
totals alone; none of those are possible here.
"""
items = envelope.get("needs_you") or []
if not items:
return ""
by_kind: Dict[str, List[Dict[str, Any]]] = {}
for item in items:
by_kind.setdefault(str(item.get("kind") or ""), []).append(item)

blocks: List[str] = []
for heading, kinds in _TRIAGE_SECTIONS:
rows = [row for kind in kinds for row in by_kind.get(kind, [])]
if not rows:
continue
rows.sort(key=lambda r: r.get("ref") or 0)
lines = [f"### {heading}", ""]
for row in rows:
who = _sender_label(row.get("sender"))
what = str(row.get("subject") or "").strip() or "(no subject)"
# ``why`` is the classifier's own reason for the row, not chat-model
# embellishment, so it survives the rewrite.
notes = [
n
for n in (_age_phrase(row.get("age_seconds")), str(row.get("why") or "").strip())
if n
]
suffix = f" ({' · '.join(notes)})" if notes else ""
lines.append(f"{row.get('ref')}. {who} — {what}{suffix}")
blocks.append("\n".join(lines))
return "\n\n".join(blocks)


def _lead_paragraph(text: str) -> str:
"""The answer's opening prose — the one part still worth asking a model for.

Skips headings and any block that has already turned into a list, so a
reply that opens straight into items contributes no lead at all rather
than half a list.
"""
for block in (text or "").split("\n\n"):
candidate = block.strip()
if not candidate or candidate.startswith("#"):
continue
if _NUMBERED_ITEM_LINE_RE.search(candidate):
break
return candidate
return ""


def rewrite_triage_answer(
final_answer: str, conversation: Optional[List[Dict[str, Any]]]
) -> str:
"""Replace a triage reply's list with one built from the scan itself.

The categories are still model judgement — a heuristic, then the
``specific-ai-triage`` SLM, then an LLM fallback, all inside
``pre_scan_inbox``. What is NOT a judgement is transcribing the result,
and asking the chat model to do it produced invented numbering, dropped
items, merged sections, and once no list at all. So the chat model keeps
the opening sentence and this renders the rest.

Deliberately keyed on tool PRESENCE, not on parsing the user's question:
any turn that calls ``pre_scan_inbox`` gets the authoritative list, even
for a narrower ask ("how many urgent emails do I have?"). A hand-
summarized partial view is exactly the failure mode this function
replaces, and ``pre_scan_inbox`` only ever runs when the model judged
the question worth a scan in the first place — so a rewrite here is
never wrong, only sometimes more complete than the question strictly
asked for.
"""
prescan = last_tool_payload(conversation, "pre_scan_inbox")
if not prescan:
return final_answer
rendered = render_needs_you_list(prescan)
if not rendered:
return final_answer
lead = _lead_paragraph(final_answer) or _honest_prescan_summary(prescan)
return f"{lead}\n\n{rendered}"


def _honest_prescan_summary(envelope: Dict[str, Any]) -> str:
"""A minimal, always-grounded pre-scan sentence built straight from the
envelope's own counts — the fallback used when the model's own framing
Expand Down Expand Up @@ -694,6 +864,13 @@ def ground_final_answer(result: Dict[str, Any]) -> Dict[str, Any]:
if find_scaffolding_leak(final_answer):
final_answer = strip_scaffolding_leaks(final_answer)

final_answer = normalize_triage_list(final_answer)

# The list is tool output, not prose. Rendering it here rather than asking
# the model to retype it is what makes one list, correctly numbered, every
# time — see rewrite_triage_answer.
final_answer = rewrite_triage_answer(final_answer, conversation)

success_claim = find_ungrounded_success_claim(final_answer, conversation)
if success_claim:
logger.warning(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@ Triage answers one question per message: **does a human have to act on this?**
- Act on the reversible end only — mark read, star, label, archive. Reply, send,
and delete are proposals the user confirms.

**Report** the reply-needed count first, then one line each: sender, what they
want, how old. Summarise the rest as counts. Nothing needing a reply is a
one-sentence answer.
**Report** one opening sentence and stop: how many items need attention, and
how much of the mailbox was scanned. The numbered breakdown is rendered from
the scan itself — do not write it out, and never re-list, renumber, or
summarise those items yourself.

Age beats volume. A thread the user already answered is handled. "URGENT" in a
subject line is a claim, not a fact.
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,11 @@
# (``test_render_tool_to_lang_maps_stay_in_sync``) pins the two dicts equal so
# this duplication can't silently drift.
_RENDER_TOOL_TO_LANG: Dict[str, str] = {
"pre_scan_inbox": "email_pre_scan",
# ``pre_scan_inbox`` deliberately draws NO card: it landed mid-turn as a
# partial list while the model was still writing the full triage answer,
# so the user read two overlapping views of one inbox and could not tell
# which to act on. The triage reply is the single view; refs still resolve
# from tool data (``resolve_needs_you_reference``), not from a render.
# #2765: a generic ``table`` card (no new client code) so the thread
# view renders straight from tool data instead of model prose.
"get_thread": "table",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1587,9 +1587,11 @@ def triage_inbox_impl(
# reaches the caller via ``totals["needs_review"]``.
PRE_SCAN_NEEDS_REVIEW_CAP = 5

# #2743 — the "one card" worklist. Capped small on purpose: a triage card
# listing 30 rows is a report, not a worklist a person can act on today.
NEEDS_YOU_CAP = 5
# #2743 — the "one card" worklist. Still capped well below the inbox: a
# worklist of 30 rows is a report nobody acts on. Raised from 5 so the
# needs-a-look bucket carries a ``ref`` too — an item the user can see but
# not name is one they cannot ask the agent to act on.
NEEDS_YOU_CAP = 10

# Filter-test ids for BulkSummary.filter_tests (#2743) — ids, never prose
# (see contract.py's NeedsYouItem/BulkSummary docstrings): a renderer maps
Expand Down
Loading
Loading