4141 "home" : ("run the home action" , "ran the home action" , "home action done" ),
4242}
4343
44+ # the verbs a final answer uses to say an action happened, by kind ("sent the email", "turned on the lights")
45+ _CLAIM_VERBS : dict [str , str ] = {
46+ "email" : r"sent|emailed|delivered|mailed" ,
47+ "message" : r"sent|posted|messaged|delivered" ,
48+ "file" : r"saved|written|wrote|created|stored" ,
49+ "event" : r"created|added|scheduled|booked" ,
50+ "home" : r"turned|switched|set|done|ran" ,
51+ }
52+
4453# words in a final answer that claim the action happened, by kind
4554_CLAIMS : dict [str , re .Pattern ] = {
46- "email" : re .compile (r"\b(sent|emailed|delivered|mailed)\b" , re .I ),
47- "message" : re .compile (r"\b(sent|posted|messaged|delivered)\b" , re .I ),
48- "file" : re .compile (r"\b(saved|written|wrote|created|stored)\b" , re .I ),
49- "event" : re .compile (r"\b(created|added|scheduled|booked)\b" , re .I ),
50- "home" : re .compile (r"\b(turned|switched|set|done|ran)\b" , re .I ),
55+ kind : re .compile (rf"\b(?:{ verbs } )\b" , re .I ) for kind , verbs in _CLAIM_VERBS .items ()
5156}
5257_CLAIM_ANY = re .compile (r"\b(done|completed|finished|success(?:fully)?|sent|saved|created|updated)\b" , re .I )
5358_BROWSER_CLICK_CLAIM = re .compile (r"\b(?:clicked|was clicked)\b" , re .I )
6570 r"\b(?:if|would|could|might|may)\b[^.!?]{0,50}\b(?:clicked|was clicked|were clicked)\b" , re .I
6671)
6772
73+ _ACTION_SUBJECT = r"(?:i(?:'ve)?|we|sentient)"
74+ _ACTION_MODIFIERS = r"(?:have|had|just|only|also|then|already|recently|finally|even|really|\w+ly)"
75+ # continuations that never describe the tool's own action: "ran into an issue", "set out to help"
76+ _NON_ACTION_TAIL = r"(?!\s+(?:into|out of|across|through|over|apart|aside)\b)"
77+ # a self-attributed completed claim, by kind ("I've sent the email", "I turned on the lights")
78+ _ASSISTANT_ACTION_CLAIM : dict [str , re .Pattern ] = {
79+ kind : re .compile (
80+ rf"\b{ _ACTION_SUBJECT } \s+(?:{ _ACTION_MODIFIERS } \s+){{0,2}}(?:{ verbs } )\b{ _NON_ACTION_TAIL } " ,
81+ re .I ,
82+ )
83+ for kind , verbs in _CLAIM_VERBS .items ()
84+ }
85+ _NEGATED_ACTION_CLAIM : dict [str , re .Pattern ] = {
86+ kind : re .compile (
87+ rf"\b(?:not|didn't|hasn't|wasn't|haven't|never|nothing)\b[^.!?]{{0,50}}\b(?:{ verbs } )\b" ,
88+ re .I ,
89+ )
90+ for kind , verbs in _CLAIM_VERBS .items ()
91+ }
92+ _OTHER_PERSON_ACTION_CLAIM : dict [str , re .Pattern ] = {
93+ kind : re .compile (
94+ rf"\b(?:you|he|she|they|someone|anyone|(?:my|your|his|her|their)\s+\w+)\s+"
95+ rf"(?:have\s+|had\s+|just\s+)?(?:{ verbs } )\b" ,
96+ re .I ,
97+ )
98+ for kind , verbs in _CLAIM_VERBS .items ()
99+ }
100+ _HYPOTHETICAL_ACTION_CLAIM : dict [str , re .Pattern ] = {
101+ kind : re .compile (rf"\b(?:if|would|could|might|may)\b[^.!?]{{0,50}}\b(?:{ verbs } )\b" , re .I )
102+ for kind , verbs in _CLAIM_VERBS .items ()
103+ }
104+ # nouns that name the kind in a claim ("sent the email"): shared verbs ("sent" is email and message, "created"
105+ # is file and event) stay ambiguous without one, and an ambiguous never-made claim is left alone
106+ _KIND_NOUNS : dict [str , re .Pattern ] = {
107+ "email" : re .compile (r"\be-?mails?\b" , re .I ),
108+ "message" : re .compile (r"\b(messages?|texts?|sms|dms?|posts?|chats?)\b" , re .I ),
109+ "file" : re .compile (r"\b(files?|notes?|drafts?|spreadsheets?)\b" , re .I ),
110+ "event" : re .compile (r"\b(events?|meetings?|appointments?|calendar)\b" , re .I ),
111+ "home" : re .compile (
112+ r"\b(lights?|lamps?|thermostat|alarm|doors?|locks?|blinds?|covers?|scenes?|heating|cooling)\b" ,
113+ re .I ,
114+ ),
115+ }
116+ # verbs that also describe ordinary chat text ("wrote a summary", "added context"): a never-made claim with one
117+ # is only corrected when it names the kind ("saved the file", "created the meeting")
118+ _CHAT_VERBS = {"wrote" , "written" , "created" , "added" , "stored" }
119+
120+
121+ def _owns_claim (kind : str , claim_text : str ) -> bool :
122+ """True when ``kind`` unambiguously owns a self-attributed claim: its verb is unique to the kind and not an
123+ ordinary chat verb, or the claim names the kind ("sent the email", "created the meeting"). A shared verb
124+ without a naming noun ("I sent it") stays ambiguous: saying which action didn't happen would risk
125+ contradicting a true reply about the other kind."""
126+ kinds = [k for k , pattern in _ASSISTANT_ACTION_CLAIM .items () if pattern .search (claim_text )]
127+ if kind not in kinds :
128+ return False
129+ verb_match = _CLAIMS [kind ].search (claim_text )
130+ verb = verb_match .group (0 ).lower () if verb_match else ""
131+ if verb in _CHAT_VERBS and not _KIND_NOUNS [kind ].search (claim_text ):
132+ return False
133+ if len (kinds ) == 1 :
134+ return True
135+ return bool (_KIND_NOUNS [kind ].search (claim_text ))
136+
137+
138+ def _owns_passive_claim (kind : str , claim : str ) -> bool :
139+ """True when ``kind`` unambiguously owns a passive claim ("the email was sent")."""
140+ kinds = [k for k in _CLAIM_VERBS if _CLAIMS [k ].search (claim )]
141+ if kind not in kinds :
142+ return False
143+ if len (kinds ) == 1 :
144+ return True
145+ return bool (_KIND_NOUNS [kind ].search (claim ))
146+
68147# an MCP tool's kind from its name (servers declare nothing like it)
69148_NAME_KINDS : list [tuple [str , re .Pattern , re .Pattern ]] = [
70149 ("email" , re .compile (r"mail|smtp" ), re .compile (r"send|reply|forward" )),
@@ -206,6 +285,34 @@ def checked(tool: Any, *, mcp: bool) -> bool:
206285 return mcp and int (getattr (tool , "risk" , 0 )) >= 1
207286
208287
288+ def action_call (
289+ tool : Any ,
290+ arguments : dict ,
291+ res : Any ,
292+ * ,
293+ ran : bool ,
294+ declined : bool ,
295+ is_error : bool ,
296+ err : str | None ,
297+ check : dict | None ,
298+ ) -> dict | None :
299+ """The code-recorded evidence for one call of an action tool with a clear outcome (#492): its kind, whether
300+ the user declined it, whether it worked, and what it acted on. None when the tool has no clear outcome.
301+ Recorded for calls that ran and for declined ones, mirroring ``browser_clicks`` for #407."""
302+ kind = kind_of (tool )
303+ if kind not in KINDS or not (ran or declined ):
304+ return None
305+ targets = check .get ("targets" ) if isinstance (check , dict ) else None
306+ return {
307+ "kind" : kind ,
308+ "declined" : declined ,
309+ "ok" : ran and not is_error and not err and result_problem (res ) is None
310+ and (not isinstance (check , dict ) or check .get ("ok" ) is not False ),
311+ "uncertain" : ran and is_error ,
312+ "targets" : list (targets or []) or targets_of (kind , arguments ),
313+ }
314+
315+
209316def _short_name (tool : Any ) -> str :
210317 name = str (getattr (tool , "name" , "" ) or "" )
211318 plugin = str (getattr (tool , "plugin" , "" ) or "" )
@@ -223,7 +330,7 @@ def labels(check: dict) -> tuple[str, str, str]:
223330 return f"do { what } " , f"did { what } " , what
224331
225332
226- def _targets (kind : str | None , arguments : dict ) -> list [str ]:
333+ def targets_of (kind : str | None , arguments : dict ) -> list [str ]:
227334 """What a call acted on, for telling whether a later call covered a failure: recipients, a file name, a title."""
228335 args = arguments or {}
229336 if kind in {"email" , "message" }:
@@ -272,7 +379,7 @@ async def check(tool: Any, arguments: dict, res: Any, is_error: bool, ctx: Any,
272379 out : dict [str , Any ] = {
273380 "tool" : getattr (tool , "name" , "" ), "app" : app , "kind" : kind ,
274381 "action" : KINDS [kind ][2 ] if kind in KINDS else _short_name (tool ),
275- "ok" : True , "detail" : "" , "how" : "result" , "targets" : _targets (kind , arguments ), "missed" : [],
382+ "ok" : True , "detail" : "" , "how" : "result" , "targets" : targets_of (kind , arguments ), "missed" : [],
276383 }
277384 problem = result_problem (res )
278385 if is_error and problem is None :
@@ -429,6 +536,133 @@ def unmade_browser_click_note(text: str, calls: list[dict], history: list[dict]
429536 return ""
430537
431538
539+ def _claimed_action_target (text : str , kind : str ) -> str :
540+ """The named target after an action claim ("alice" in "I sent the email to Alice"), or "" for "it" and for a
541+ bare kind noun ("the file" in "I saved the file"): the claim could refer to any recorded call of the kind."""
542+ match = _CLAIMS [kind ].search (text )
543+ tail = text [match .end ():] if match is not None else ""
544+ tail = re .split (r"\b(?:on|and|but|because|so)\b|[,;.!?]" , tail , maxsplit = 1 , flags = re .I )[0 ]
545+ target = re .sub (r"^\s*(?:only|just|the|a|an|to|for|my|your)\s+" , "" , tail , flags = re .I ).strip ()
546+ if target .lower () in {"" , "it" , "that" , "this" , "them" , "everything" }:
547+ return ""
548+ return "" if _KIND_NOUNS [kind ].fullmatch (target ) else target
549+
550+
551+ def _call_target_words (call : dict ) -> set [str ]:
552+ """Meaningful words in what a recorded action call acted on."""
553+ return _target_words (" " .join (str (target ) for target in call .get ("targets" ) or []))
554+
555+
556+ def _matches_action_target (claim : str , call : dict ) -> bool :
557+ """Whether a named claim plausibly refers to what this recorded action call acted on."""
558+ if not claim :
559+ return True
560+ claim_words = _target_words (claim )
561+ target_words = _call_target_words (call )
562+
563+ def same_word (left : str , right : str ) -> bool :
564+ """Accept the small morphological variations common in a natural-language reply."""
565+ return left == right or (
566+ len (left ) >= 6 and len (right ) >= 6 and left [:6 ] == right [:6 ]
567+ )
568+
569+ # The recorded target is the dependable evidence. It need only appear in
570+ # the claim: a reply naturally adds context such as "for the newsletter"
571+ # after naming what the action acted on.
572+ return bool (target_words ) and all (
573+ any (same_word (target_word , claim_word ) for claim_word in claim_words )
574+ for target_word in target_words
575+ )
576+
577+
578+ def _action_note (kind : str , target : str , calls : list [dict ], claim_text : str ) -> str :
579+ """The correction for an action claim after selecting evidence for its kind and named target."""
580+ same = [call for call in calls if call .get ("kind" ) == kind ]
581+ has_success = any (call .get ("ok" ) for call in same )
582+ if not target and has_success :
583+ # A targetless claim could refer to any completed action. A later
584+ # declined call is not enough evidence to contradict it.
585+ return ""
586+ matches = [call for call in same if _matches_action_target (target , call )]
587+ if target and not matches and not has_success :
588+ # A declined call may name only its transient target; it is still evidence the named action was not made.
589+ matches = [call for call in same if not call .get ("ok" ) and not _call_target_words (call )]
590+ doing , did , _label = labels ({"kind" : kind })
591+ if target and not matches :
592+ if has_success :
593+ # A successful action on another target makes an unmatched claim
594+ # ambiguous. Do not turn an honest report into a correction.
595+ return ""
596+ # No record of this kind at all: only correct when the kind is unambiguous, never on a shared verb
597+ # ("sent" is email and message) that could contradict a true reply about the other kind.
598+ return f"I didn't { doing } , so nothing changed." if _owns_claim (kind , claim_text ) else ""
599+ if not matches :
600+ return f"I didn't { doing } , so nothing changed." if _owns_claim (kind , claim_text ) else ""
601+ if any (call .get ("ok" ) for call in matches ):
602+ # the claimed action did happen; a later declined or failed try of the same target doesn't undo it (#488)
603+ return ""
604+ call = matches [- 1 ]
605+ if call .get ("declined" ):
606+ return f"I didn't { doing } : you declined, so nothing changed."
607+ if call .get ("uncertain" ):
608+ return f"I couldn't confirm whether I { did } ."
609+ return f"I couldn't { doing } , so nothing changed."
610+
611+
612+ def unmade_action_note (
613+ text : str , action_calls : list [dict ], history : list [dict ] | None = None
614+ ) -> str :
615+ """A short correction when a reply says an action happened although this turn did not (#492).
616+
617+ The #407 approach for the other action tools with a clear outcome: send email/message, file write,
618+ calendar create, Home Assistant calls. Reuses the outcome checks (#165/#380) through the code-recorded
619+ ``action_calls`` of this turn and ``history`` of earlier turns. Deliberately narrow: it only corrects
620+ completed claims the run's own records contradict, never a true reply, and leaves the text alone when
621+ unsure. Voice and channel replies are covered too: they speak and post this same final text.
622+ """
623+ calls = [* (history or []), * (action_calls or [])]
624+ for kind in _CLAIM_VERBS :
625+ if note := _unmade_action_claim_note (text , kind , calls ):
626+ return note
627+ return ""
628+
629+
630+ def _unmade_action_claim_note (text : str , kind : str , calls : list [dict ]) -> str :
631+ claims = re .split (r"(?<=[.!?])\s+" , text or "" )
632+ explicit = _ASSISTANT_ACTION_CLAIM [kind ]
633+ for claim in claims :
634+ if _HYPOTHETICAL_ACTION_CLAIM [kind ].search (claim ):
635+ continue
636+ found = list (explicit .finditer (claim ))
637+ # Each self-attributed claim needs its own evidence. In particular, a
638+ # successful "I sent the report" must not hide a later false
639+ # "I also booked the room" in the same sentence.
640+ parts = [
641+ claim [match .start (): found [i + 1 ].start () if i + 1 < len (found ) else None ]
642+ for i , match in enumerate (found )
643+ ]
644+ for part in parts :
645+ # The reply already tells the user that this attempted action was
646+ # declined, so a second correction would add no information.
647+ if re .search (r"\b(?:declined|denied)\b" , part , re .I ):
648+ continue
649+ if note := _action_note (kind , _claimed_action_target (part , kind ), calls , part ):
650+ return note
651+ passive = (
652+ not found
653+ and any (call .get ("kind" ) == kind for call in calls )
654+ and _owns_passive_claim (kind , claim )
655+ and _CLAIMS [kind ].search (claim )
656+ and not _NEGATED_ACTION_CLAIM [kind ].search (claim )
657+ and not _OTHER_PERSON_ACTION_CLAIM [kind ].search (claim )
658+ )
659+ if passive and (
660+ note := _action_note (kind , _claimed_action_target (claim , kind ), calls , claim )
661+ ):
662+ return note
663+ return ""
664+
665+
432666def _settled (failed : dict , later : list [dict ]) -> bool :
433667 """A failed check counts as settled when a later successful call of the same tool covered what it missed. A
434668 failure that names nothing it acted on (a call with arguments the tool couldn't read) is settled only by the very
0 commit comments